mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +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_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_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_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
|
// team tests for setup experience software/script
|
||||||
{"testdata/gitops/team_setup_software_valid.yml", ""},
|
{"testdata/gitops/team_setup_software_valid.yml", ""},
|
||||||
{"testdata/gitops/team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"},
|
{"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,
|
Teams: nil,
|
||||||
}, 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})
|
_, err = runAppNoChecks([]string{"gitops", "-f", c.file})
|
||||||
if c.wantErr == "" {
|
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_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_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_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
|
// No team tests for setup experience software/script
|
||||||
{"testdata/gitops/no_team_setup_software_valid.yml", ""},
|
{"testdata/gitops/no_team_setup_software_valid.yml", ""},
|
||||||
{"testdata/gitops/no_team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"},
|
{"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,
|
Teams: nil,
|
||||||
}, 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", "")
|
t.Setenv("APPLE_BM_DEFAULT_TEAM", "")
|
||||||
globalFile := "./testdata/gitops/global_config_no_paths.yml"
|
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.
|
- "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.
|
- "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.
|
||||||
|
|
||||||
#### Example
|
#### Example
|
||||||
|
|
||||||
|
|
@ -1252,7 +1254,17 @@ This activity contains the following fields:
|
||||||
"team_name": "Workstations",
|
"team_name": "Workstations",
|
||||||
"team_id": 123,
|
"team_id": 123,
|
||||||
"self_service": true,
|
"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_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.
|
- "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.
|
||||||
|
|
||||||
#### Example
|
#### Example
|
||||||
|
|
||||||
|
|
@ -1275,7 +1289,17 @@ This activity contains the following fields:
|
||||||
"software_package": "FalconSensor-6.44.pkg",
|
"software_package": "FalconSensor-6.44.pkg",
|
||||||
"team_name": "Workstations",
|
"team_name": "Workstations",
|
||||||
"team_id": 123,
|
"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_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.
|
- "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.
|
||||||
|
|
||||||
#### Example
|
#### Example
|
||||||
|
|
||||||
|
|
@ -1298,7 +1324,17 @@ This activity contains the following fields:
|
||||||
"software_package": "FalconSensor-6.44.pkg",
|
"software_package": "FalconSensor-6.44.pkg",
|
||||||
"team_name": "Workstations",
|
"team_name": "Workstations",
|
||||||
"team_id": 123,
|
"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,
|
appID uint,
|
||||||
installScript, preInstallQuery, postInstallScript, uninstallScript string,
|
installScript, preInstallQuery, postInstallScript, uninstallScript string,
|
||||||
selfService bool,
|
selfService bool,
|
||||||
|
labelsIncludeAny, labelsExcludeAny []string,
|
||||||
) (titleID uint, err error) {
|
) (titleID uint, err error) {
|
||||||
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|
@ -36,6 +37,12 @@ func (svc *Service) AddFleetMaintainedApp(
|
||||||
return 0, fleet.ErrNoContext
|
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 {
|
if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{installScript, postInstallScript, uninstallScript}); err != nil {
|
||||||
return 0, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error()))
|
return 0, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error()))
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +126,7 @@ func (svc *Service) AddFleetMaintainedApp(
|
||||||
SelfService: selfService,
|
SelfService: selfService,
|
||||||
InstallScript: installScript,
|
InstallScript: installScript,
|
||||||
UninstallScript: uninstallScript,
|
UninstallScript: uninstallScript,
|
||||||
|
ValidatedLabels: validatedLabels,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create record in software installers table
|
// Create record in software installers table
|
||||||
|
|
@ -142,13 +150,16 @@ func (svc *Service) AddFleetMaintainedApp(
|
||||||
teamName = &t.Name
|
teamName = &t.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels)
|
||||||
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
|
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
|
||||||
SoftwareTitle: payload.Title,
|
SoftwareTitle: payload.Title,
|
||||||
SoftwarePackage: payload.Filename,
|
SoftwarePackage: payload.Filename,
|
||||||
TeamName: teamName,
|
TeamName: teamName,
|
||||||
TeamID: payload.TeamID,
|
TeamID: payload.TeamID,
|
||||||
SelfService: payload.SelfService,
|
SelfService: payload.SelfService,
|
||||||
SoftwareTitleID: titleID,
|
SoftwareTitleID: titleID,
|
||||||
|
LabelsIncludeAny: actLabelsIncl,
|
||||||
|
LabelsExcludeAny: actLabelsExcl,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return 0, ctxerr.Wrap(ctx, err, "creating activity for added software")
|
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/file"
|
||||||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||||||
"github.com/fleetdm/fleet/v4/server/authz"
|
"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"
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||||
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
|
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
|
||||||
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
||||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
|
||||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
||||||
"github.com/go-kit/log"
|
"github.com/go-kit/log"
|
||||||
kitlog "github.com/go-kit/log"
|
kitlog "github.com/go-kit/log"
|
||||||
"github.com/go-kit/log/level"
|
"github.com/go-kit/log/level"
|
||||||
|
|
@ -37,6 +37,13 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
|
||||||
return err
|
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)
|
vc, ok := viewer.FromContext(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fleet.ErrNoContext
|
return fleet.ErrNoContext
|
||||||
|
|
@ -86,13 +93,16 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create activity
|
// Create activity
|
||||||
|
actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels)
|
||||||
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
|
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
|
||||||
SoftwareTitle: payload.Title,
|
SoftwareTitle: payload.Title,
|
||||||
SoftwarePackage: payload.Filename,
|
SoftwarePackage: payload.Filename,
|
||||||
TeamName: teamName,
|
TeamName: teamName,
|
||||||
TeamID: payload.TeamID,
|
TeamID: payload.TeamID,
|
||||||
SelfService: payload.SelfService,
|
SelfService: payload.SelfService,
|
||||||
SoftwareTitleID: titleID,
|
SoftwareTitleID: titleID,
|
||||||
|
LabelsIncludeAny: actLabelsIncl,
|
||||||
|
LabelsExcludeAny: actLabelsExcl,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return ctxerr.Wrap(ctx, err, "creating activity for added software")
|
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
|
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}))`)
|
var packageIDRegex = regexp.MustCompile(`((("\$PACKAGE_ID")|(\$PACKAGE_ID))(?P<suffix>\W|$))|(("\${PACKAGE_ID}")|(\${PACKAGE_ID}))`)
|
||||||
|
|
||||||
func preProcessUninstallScript(payload *fleet.UploadSoftwareInstallerPayload) {
|
func preProcessUninstallScript(payload *fleet.UploadSoftwareInstallerPayload) {
|
||||||
|
|
@ -141,7 +187,7 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
|
||||||
|
|
||||||
var teamName *string
|
var teamName *string
|
||||||
if *payload.TeamID != 0 {
|
if *payload.TeamID != 0 {
|
||||||
t, err := svc.ds.Team(ctx, *payload.TeamID)
|
t, err := svc.ds.TeamWithoutExtras(ctx, *payload.TeamID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 &&
|
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
|
return existingInstaller, nil // no payload, noop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,10 +244,24 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
|
||||||
dirty["SelfService"] = true
|
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{
|
activity := fleet.ActivityTypeEditedSoftware{
|
||||||
SoftwareTitle: existingInstaller.SoftwareTitle,
|
SoftwareTitle: existingInstaller.SoftwareTitle,
|
||||||
TeamName: teamName,
|
TeamName: teamName,
|
||||||
TeamID: payload.TeamID,
|
TeamID: actTeamID,
|
||||||
SelfService: existingInstaller.SelfService,
|
SelfService: existingInstaller.SelfService,
|
||||||
SoftwarePackage: &existingInstaller.Name,
|
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 {
|
if err := svc.NewActivity(ctx, vc.User, activity); err != nil {
|
||||||
return nil, ctxerr.Wrap(ctx, err, "creating activity for edited software")
|
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
|
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 {
|
func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error {
|
||||||
if teamID == nil {
|
if teamID == nil {
|
||||||
return fleet.NewInvalidArgumentError("team_id", "is required")
|
return fleet.NewInvalidArgumentError("team_id", "is required")
|
||||||
|
|
@ -441,20 +577,15 @@ func (svc *Service) deleteSoftwareInstaller(ctx context.Context, meta *fleet.Sof
|
||||||
teamName = &t.Name
|
teamName = &t.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
var teamID *uint
|
actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromSoftwareScopeLabels(meta.LabelsIncludeAny, meta.LabelsExcludeAny)
|
||||||
switch {
|
|
||||||
case meta.TeamID == nil:
|
|
||||||
teamID = ptr.Uint(0)
|
|
||||||
case meta.TeamID != nil:
|
|
||||||
teamID = meta.TeamID
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedSoftware{
|
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedSoftware{
|
||||||
SoftwareTitle: meta.SoftwareTitle,
|
SoftwareTitle: meta.SoftwareTitle,
|
||||||
SoftwarePackage: meta.Name,
|
SoftwarePackage: meta.Name,
|
||||||
TeamName: teamName,
|
TeamName: teamName,
|
||||||
TeamID: teamID,
|
TeamID: meta.TeamID,
|
||||||
SelfService: meta.SelfService,
|
SelfService: meta.SelfService,
|
||||||
|
LabelsIncludeAny: actLabelsIncl,
|
||||||
|
LabelsExcludeAny: actLabelsExcl,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return ctxerr.Wrap(ctx, err, "creating activity for deleted software")
|
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 we found an installer, use that
|
||||||
if installer != nil {
|
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)
|
lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID)
|
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(
|
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) {
|
) (string, error) {
|
||||||
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -1185,6 +1328,12 @@ func (svc *Service) BatchSetSoftwareInstallers(
|
||||||
fmt.Sprintf("Couldn't edit software. URL (%q) is invalid", payload.URL),
|
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)
|
allScripts = append(allScripts, payload.InstallScript, payload.PostInstallScript, payload.UninstallScript)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1229,7 +1378,7 @@ func (svc *Service) softwareBatchUpload(
|
||||||
requestUUID string,
|
requestUUID string,
|
||||||
teamID *uint,
|
teamID *uint,
|
||||||
userID uint,
|
userID uint,
|
||||||
payloads []fleet.SoftwareInstallerPayload,
|
payloads []*fleet.SoftwareInstallerPayload,
|
||||||
dryRun bool,
|
dryRun bool,
|
||||||
) {
|
) {
|
||||||
var batchErr error
|
var batchErr error
|
||||||
|
|
@ -1341,6 +1490,9 @@ func (svc *Service) softwareBatchUpload(
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
URL: p.URL,
|
URL: p.URL,
|
||||||
InstallDuringSetup: p.InstallDuringSetup,
|
InstallDuringSetup: p.InstallDuringSetup,
|
||||||
|
LabelsIncludeAny: p.LabelsIncludeAny,
|
||||||
|
LabelsExcludeAny: p.LabelsExcludeAny,
|
||||||
|
ValidatedLabels: p.ValidatedLabels,
|
||||||
}
|
}
|
||||||
|
|
||||||
// set the filename before adding metadata, as it is used as fallback
|
// 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)
|
ext := filepath.Ext(installer.Name)
|
||||||
requiredPlatform := packageExtensionToPlatform(ext)
|
requiredPlatform := packageExtensionToPlatform(ext)
|
||||||
if requiredPlatform == "" {
|
if requiredPlatform == "" {
|
||||||
|
|
@ -1642,3 +1805,40 @@ func UninstallSoftwareMigration(
|
||||||
|
|
||||||
return nil
|
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) {
|
func TestPreProcessUninstallScript(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
var input = `
|
input := `
|
||||||
blah$PACKAGE_IDS
|
blah$PACKAGE_IDS
|
||||||
pkgids=$PACKAGE_ID
|
pkgids=$PACKAGE_ID
|
||||||
they are $PACKAGE_ID, right $MY_SECRET?
|
they are $PACKAGE_ID, right $MY_SECRET?
|
||||||
|
|
@ -74,7 +74,6 @@ quotes and braces for (
|
||||||
"com.bar"
|
"com.bar"
|
||||||
)`
|
)`
|
||||||
assert.Equal(t, expected, payload.UninstallScript)
|
assert.Equal(t, expected, payload.UninstallScript)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInstallUninstallAuth(t *testing.T) {
|
func TestInstallUninstallAuth(t *testing.T) {
|
||||||
|
|
@ -93,7 +92,8 @@ func TestInstallUninstallAuth(t *testing.T) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint,
|
ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint,
|
||||||
withScriptContents bool) (*fleet.SoftwareInstaller, error) {
|
withScriptContents bool,
|
||||||
|
) (*fleet.SoftwareInstaller, error) {
|
||||||
return &fleet.SoftwareInstaller{
|
return &fleet.SoftwareInstaller{
|
||||||
Name: "installer.pkg",
|
Name: "installer.pkg",
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
|
|
@ -104,14 +104,16 @@ func TestInstallUninstallAuth(t *testing.T) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool, policyID *uint) (string,
|
ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool, policyID *uint) (string,
|
||||||
error) {
|
error,
|
||||||
|
) {
|
||||||
return "request_id", nil
|
return "request_id", nil
|
||||||
}
|
}
|
||||||
ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) {
|
ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) {
|
||||||
return []byte("script"), nil
|
return []byte("script"), nil
|
||||||
}
|
}
|
||||||
ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult,
|
ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult,
|
||||||
error) {
|
error,
|
||||||
|
) {
|
||||||
return &fleet.HostScriptResult{
|
return &fleet.HostScriptResult{
|
||||||
ExecutionID: "execution_id",
|
ExecutionID: "execution_id",
|
||||||
}, nil
|
}, nil
|
||||||
|
|
@ -120,6 +122,10 @@ func TestInstallUninstallAuth(t *testing.T) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
user *fleet.User
|
user *fleet.User
|
||||||
|
|
@ -197,7 +203,6 @@ func TestUninstallSoftwareTitle(t *testing.T) {
|
||||||
// Host scripts disabled
|
// Host scripts disabled
|
||||||
host.ScriptsEnabled = ptr.Bool(false)
|
host.ScriptsEnabled = ptr.Bool(false)
|
||||||
require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptsOrbitDisabledErrMsg)
|
require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptsOrbitDisabledErrMsg)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkAuthErr(t *testing.T, shouldFail bool, err error) {
|
func checkAuthErr(t *testing.T, shouldFail bool, err error) {
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,7 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
|
||||||
version: "1.2.3",
|
version: "1.2.3",
|
||||||
uploaded_at: "2020-01-01T00:00:00.000Z",
|
uploaded_at: "2020-01-01T00:00:00.000Z",
|
||||||
install_script: "sudo installer -pkg /temp/FalconSensor-6.44.pkg -target /",
|
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';",
|
pre_install_query: "SELECT 1 FROM macos_profiles WHERE uuid='abc123';",
|
||||||
post_install_script:
|
post_install_script:
|
||||||
"sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123",
|
"sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123",
|
||||||
|
|
@ -216,6 +217,8 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
|
||||||
last_install: null,
|
last_install: null,
|
||||||
last_uninstall: null,
|
last_uninstall: null,
|
||||||
package_url: "",
|
package_url: "",
|
||||||
|
labels_include_any: null,
|
||||||
|
labels_exclude_any: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createMockSoftwarePackage = (
|
export const createMockSoftwarePackage = (
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export const PlatformSelector = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${parentClass}__${baseClass} ${baseClass} form-field`}>
|
<div className={`${parentClass}__${baseClass} ${baseClass} form-field`}>
|
||||||
<span className={labelClasses}>Checks on:</span>
|
<span className={labelClasses}>Targets:</span>
|
||||||
<span className={`${baseClass}__checkboxes`}>
|
<span className={`${baseClass}__checkboxes`}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
value={checkDarwin}
|
value={checkDarwin}
|
||||||
|
|
@ -71,7 +71,8 @@ export const PlatformSelector = ({
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</span>
|
</span>
|
||||||
<div className="form-field__help-text">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,8 @@
|
||||||
.form-field__label--disabled {
|
.form-field__label--disabled {
|
||||||
color: $ui-fleet-black-50;
|
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
|
<div
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
|
aria-label={name}
|
||||||
aria-checked={indeterminate ? "mixed" : value || undefined}
|
aria-checked={indeterminate ? "mixed" : value || undefined}
|
||||||
aria-readonly={readOnly}
|
aria-readonly={readOnly}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ILabelSoftwareTitle } from "./label";
|
||||||
import { Platform } from "./platform";
|
import { Platform } from "./platform";
|
||||||
import { IPolicy } from "./policy";
|
import { IPolicy } from "./policy";
|
||||||
import { IQuery } from "./query";
|
import { IQuery } from "./query";
|
||||||
|
|
@ -184,4 +185,7 @@ export interface IActivityDetails {
|
||||||
app_store_id?: number;
|
app_store_id?: number;
|
||||||
location?: string; // name of location associated with VPP token
|
location?: string; // name of location associated with VPP token
|
||||||
webhook_url?: string;
|
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;
|
label_type: LabelType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ILabelSoftwareTitle {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ILabel extends ILabelSummary {
|
export interface ILabel extends ILabelSummary {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import PropTypes from "prop-types";
|
||||||
import { IconNames } from "components/icons";
|
import { IconNames } from "components/icons";
|
||||||
|
|
||||||
import vulnerabilityInterface from "./vulnerability";
|
import vulnerabilityInterface from "./vulnerability";
|
||||||
|
import { ILabelSoftwareTitle } from "./label";
|
||||||
|
|
||||||
export default PropTypes.shape({
|
export default PropTypes.shape({
|
||||||
type: PropTypes.string,
|
type: PropTypes.string,
|
||||||
|
|
@ -68,6 +69,7 @@ export interface ISoftwarePackage {
|
||||||
version: string;
|
version: string;
|
||||||
uploaded_at: string;
|
uploaded_at: string;
|
||||||
install_script: string;
|
install_script: string;
|
||||||
|
uninstall_script: string;
|
||||||
pre_install_query?: string;
|
pre_install_query?: string;
|
||||||
post_install_script?: string;
|
post_install_script?: string;
|
||||||
self_service: boolean;
|
self_service: boolean;
|
||||||
|
|
@ -81,6 +83,8 @@ export interface ISoftwarePackage {
|
||||||
};
|
};
|
||||||
automatic_install_policies?: ISoftwarePackagePolicy[];
|
automatic_install_policies?: ISoftwarePackagePolicy[];
|
||||||
install_during_setup?: boolean;
|
install_during_setup?: boolean;
|
||||||
|
labels_include_any: ILabelSoftwareTitle[] | null;
|
||||||
|
labels_exclude_any: ILabelSoftwareTitle[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isSoftwarePackage = (
|
export const isSoftwarePackage = (
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import SoftwareUninstallDetailsModal from "components/ActivityDetails/InstallDet
|
||||||
import ActivityItem from "./ActivityItem";
|
import ActivityItem from "./ActivityItem";
|
||||||
import ActivityAutomationDetailsModal from "./components/ActivityAutomationDetailsModal";
|
import ActivityAutomationDetailsModal from "./components/ActivityAutomationDetailsModal";
|
||||||
import RunScriptDetailsModal from "./components/RunScriptDetailsModal/RunScriptDetailsModal";
|
import RunScriptDetailsModal from "./components/RunScriptDetailsModal/RunScriptDetailsModal";
|
||||||
|
import SoftwareDetailsModal from "./components/SoftwareDetailsModal";
|
||||||
|
|
||||||
const baseClass = "activity-feed";
|
const baseClass = "activity-feed";
|
||||||
interface IActvityCardProps {
|
interface IActvityCardProps {
|
||||||
|
|
@ -57,6 +58,11 @@ const ActivityFeed = ({
|
||||||
activityAutomationDetails,
|
activityAutomationDetails,
|
||||||
setActivityAutomationDetails,
|
setActivityAutomationDetails,
|
||||||
] = useState<IActivityDetails | null>(null);
|
] = useState<IActivityDetails | null>(null);
|
||||||
|
const [
|
||||||
|
softwareDetails,
|
||||||
|
setSoftwareDetails,
|
||||||
|
] = useState<IActivityDetails | null>(null);
|
||||||
|
|
||||||
const queryShown = useRef("");
|
const queryShown = useRef("");
|
||||||
const queryImpact = useRef<string | undefined>(undefined);
|
const queryImpact = useRef<string | undefined>(undefined);
|
||||||
const scriptExecutionId = useRef("");
|
const scriptExecutionId = useRef("");
|
||||||
|
|
@ -131,6 +137,11 @@ const ActivityFeed = ({
|
||||||
case ActivityType.EditedActivityAutomations:
|
case ActivityType.EditedActivityAutomations:
|
||||||
setActivityAutomationDetails({ ...details });
|
setActivityAutomationDetails({ ...details });
|
||||||
break;
|
break;
|
||||||
|
case ActivityType.AddedSoftware:
|
||||||
|
case ActivityType.EditedSoftware:
|
||||||
|
case ActivityType.DeletedSoftware:
|
||||||
|
setSoftwareDetails({ ...details });
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -245,6 +256,12 @@ const ActivityFeed = ({
|
||||||
onCancel={() => setActivityAutomationDetails(null)}
|
onCancel={() => setActivityAutomationDetails(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{softwareDetails && (
|
||||||
|
<SoftwareDetailsModal
|
||||||
|
details={softwareDetails}
|
||||||
|
onCancel={() => setSoftwareDetails(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1160,9 +1160,7 @@ describe("Activity Feed", () => {
|
||||||
});
|
});
|
||||||
render(<ActivityItem activity={activity} isPremiumTier />);
|
render(<ActivityItem activity={activity} isPremiumTier />);
|
||||||
|
|
||||||
expect(
|
expect(screen.getByText("added", { exact: false })).toBeInTheDocument();
|
||||||
screen.getByText("added software ", { exact: false })
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("foobar.pkg", { exact: false })
|
screen.getByText("foobar.pkg", { exact: false })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
@ -1188,9 +1186,7 @@ describe("Activity Feed", () => {
|
||||||
});
|
});
|
||||||
render(<ActivityItem activity={activity} isPremiumTier />);
|
render(<ActivityItem activity={activity} isPremiumTier />);
|
||||||
|
|
||||||
expect(
|
expect(screen.getByText("edited", { exact: false })).toBeInTheDocument();
|
||||||
screen.getByText("edited software", { exact: false })
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(" on the ", {
|
screen.getByText(" on the ", {
|
||||||
exact: false,
|
exact: false,
|
||||||
|
|
@ -1213,9 +1209,7 @@ describe("Activity Feed", () => {
|
||||||
});
|
});
|
||||||
render(<ActivityItem activity={activity} isPremiumTier />);
|
render(<ActivityItem activity={activity} isPremiumTier />);
|
||||||
|
|
||||||
expect(
|
expect(screen.getByText("deleted", { exact: false })).toBeInTheDocument();
|
||||||
screen.getByText("deleted software ", { exact: false })
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("foobar.pkg", { exact: false })
|
screen.getByText("foobar.pkg", { exact: false })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
@ -1237,9 +1231,7 @@ describe("Activity Feed", () => {
|
||||||
});
|
});
|
||||||
render(<ActivityItem activity={activity} isPremiumTier />);
|
render(<ActivityItem activity={activity} isPremiumTier />);
|
||||||
|
|
||||||
expect(
|
expect(screen.getByText("added", { exact: false })).toBeInTheDocument();
|
||||||
screen.getByText("added software ", { exact: false })
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("foobar.pkg", { exact: false })
|
screen.getByText("foobar.pkg", { exact: false })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
@ -1258,9 +1250,7 @@ describe("Activity Feed", () => {
|
||||||
});
|
});
|
||||||
render(<ActivityItem activity={activity} isPremiumTier />);
|
render(<ActivityItem activity={activity} isPremiumTier />);
|
||||||
|
|
||||||
expect(
|
expect(screen.getByText("edited", { exact: false })).toBeInTheDocument();
|
||||||
screen.getByText("edited software", { exact: false })
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("on no team", { exact: false })
|
screen.getByText("on no team", { exact: false })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
@ -1273,9 +1263,7 @@ describe("Activity Feed", () => {
|
||||||
});
|
});
|
||||||
render(<ActivityItem activity={activity} isPremiumTier />);
|
render(<ActivityItem activity={activity} isPremiumTier />);
|
||||||
|
|
||||||
expect(
|
expect(screen.getByText("deleted", { exact: false })).toBeInTheDocument();
|
||||||
screen.getByText("deleted software ", { exact: false })
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("foobar.pkg", { exact: false })
|
screen.getByText("foobar.pkg", { exact: false })
|
||||||
).toBeInTheDocument();
|
).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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
added software <b>{activity.details?.software_title}</b> (
|
added <b>{activity.details?.software_package}</b> to{" "}
|
||||||
{activity.details?.software_package}) to{" "}
|
|
||||||
{activity.details?.team_name ? (
|
{activity.details?.team_name ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
|
||||||
the <b>{activity.details?.team_name}</b> team.
|
the <b>{activity.details?.team_name}</b> team.
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"no 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
edited software <b>{activity.details?.software_title}</b> (
|
edited <b>{activity.details?.software_package}</b> on{" "}
|
||||||
{activity.details?.software_package}) on{" "}
|
|
||||||
{activity.details?.team_name ? (
|
{activity.details?.team_name ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
|
||||||
the <b>{activity.details?.team_name}</b> team.
|
the <b>{activity.details?.team_name}</b> team.
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"no 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
deleted software <b>{activity.details?.software_title}</b> (
|
deleted <b>{activity.details?.software_package}</b> from{" "}
|
||||||
{activity.details?.software_package}) from{" "}
|
|
||||||
{activity.details?.team_name ? (
|
{activity.details?.team_name ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
|
||||||
the <b>{activity.details?.team_name}</b> team.
|
the <b>{activity.details?.team_name}</b> team.
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"no 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);
|
return TAGGED_TEMPLATES.resentConfigProfile(activity);
|
||||||
}
|
}
|
||||||
case ActivityType.AddedSoftware: {
|
case ActivityType.AddedSoftware: {
|
||||||
return TAGGED_TEMPLATES.addedSoftware(activity);
|
return TAGGED_TEMPLATES.addedSoftware(activity, onDetailsClick);
|
||||||
}
|
}
|
||||||
case ActivityType.EditedSoftware: {
|
case ActivityType.EditedSoftware: {
|
||||||
return TAGGED_TEMPLATES.editedSoftware(activity);
|
return TAGGED_TEMPLATES.editedSoftware(activity, onDetailsClick);
|
||||||
}
|
}
|
||||||
case ActivityType.DeletedSoftware: {
|
case ActivityType.DeletedSoftware: {
|
||||||
return TAGGED_TEMPLATES.deletedSoftware(activity);
|
return TAGGED_TEMPLATES.deletedSoftware(activity, onDetailsClick);
|
||||||
}
|
}
|
||||||
case ActivityType.InstalledSoftware: {
|
case ActivityType.InstalledSoftware: {
|
||||||
return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick);
|
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";
|
import mdmAPI from "services/entities/mdm";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import Dropdown from "components/forms/fields/Dropdown";
|
|
||||||
import Button from "components/buttons/Button";
|
import Button from "components/buttons/Button";
|
||||||
import Card from "components/Card";
|
import Card from "components/Card";
|
||||||
import Checkbox from "components/forms/fields/Checkbox";
|
|
||||||
import DataError from "components/DataError";
|
import DataError from "components/DataError";
|
||||||
import Icon from "components/Icon";
|
import Icon from "components/Icon";
|
||||||
import Modal from "components/Modal";
|
import Modal from "components/Modal";
|
||||||
import Radio from "components/forms/fields/Radio";
|
|
||||||
import Spinner from "components/Spinner";
|
import Spinner from "components/Spinner";
|
||||||
|
import TargetLabelSelector from "components/TargetLabelSelector";
|
||||||
import ProfileGraphic from "../AddProfileGraphic";
|
import ProfileGraphic from "../AddProfileGraphic";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -30,9 +27,7 @@ import {
|
||||||
} from "../../helpers";
|
} from "../../helpers";
|
||||||
import {
|
import {
|
||||||
CUSTOM_TARGET_OPTIONS,
|
CUSTOM_TARGET_OPTIONS,
|
||||||
CustomTargetOption,
|
|
||||||
generateLabelKey,
|
generateLabelKey,
|
||||||
getDescriptionText,
|
|
||||||
listNamesFromSelectedLabels,
|
listNamesFromSelectedLabels,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
|
|
@ -91,118 +86,6 @@ const FileDetails = ({ details: { name, platform } }: IFileDetailsProps) => (
|
||||||
</div>
|
</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 {
|
interface IAddProfileModalProps {
|
||||||
currentTeamId: number;
|
currentTeamId: number;
|
||||||
isPremiumTier: boolean;
|
isPremiumTier: boolean;
|
||||||
|
|
@ -223,14 +106,13 @@ const AddProfileModal = ({
|
||||||
name: string;
|
name: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
} | null>(null);
|
} | 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 [selectedLabels, setSelectedLabels] = useState<Record<string, boolean>>(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
const [
|
const [selectedCustomTarget, setSelectedCustomTarget] = useState(
|
||||||
customTargetOption,
|
"labelsIncludeAll"
|
||||||
setCustomTargetOption,
|
);
|
||||||
] = useState<CustomTargetOption>("labelsIncludeAll");
|
|
||||||
|
|
||||||
const fileRef = useRef<File | null>(null);
|
const fileRef = useRef<File | null>(null);
|
||||||
|
|
||||||
|
|
@ -242,7 +124,6 @@ const AddProfileModal = ({
|
||||||
} = useQuery<ILabelSummary[], Error>(
|
} = useQuery<ILabelSummary[], Error>(
|
||||||
["custom_labels"],
|
["custom_labels"],
|
||||||
() => labelsAPI.summary().then((res) => getCustomLabels(res.labels)),
|
() => labelsAPI.summary().then((res) => getCustomLabels(res.labels)),
|
||||||
|
|
||||||
{
|
{
|
||||||
enabled: isPremiumTier,
|
enabled: isPremiumTier,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
|
@ -268,8 +149,8 @@ const AddProfileModal = ({
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const labelKey = generateLabelKey(
|
const labelKey = generateLabelKey(
|
||||||
selectedTarget,
|
selectedTargetType,
|
||||||
customTargetOption,
|
selectedCustomTarget,
|
||||||
selectedLabels
|
selectedLabels
|
||||||
);
|
);
|
||||||
await mdmAPI.uploadProfile({
|
await mdmAPI.uploadProfile({
|
||||||
|
|
@ -308,8 +189,16 @@ const AddProfileModal = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSelectCustomTargetOption = (val: CustomTargetOption) => {
|
const onSelectTargetType = (val: string) => {
|
||||||
setCustomTargetOption(val);
|
setSelectedTargetType(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectCustomTargetOption = (val: string) => {
|
||||||
|
setSelectedCustomTarget(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => {
|
||||||
|
setSelectedLabels((prevItems) => ({ ...prevItems, [name]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -327,23 +216,19 @@ const AddProfileModal = ({
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
{isPremiumTier && (
|
{isPremiumTier && (
|
||||||
<div className={`${baseClass}__target`}>
|
<TargetLabelSelector
|
||||||
<TargetChooser
|
selectedTargetType={selectedTargetType}
|
||||||
selectedTarget={selectedTarget}
|
selectedCustomTarget={selectedCustomTarget}
|
||||||
setSelectedTarget={setSelectedTarget}
|
selectedLabels={selectedLabels}
|
||||||
/>
|
customTargetOptions={CUSTOM_TARGET_OPTIONS}
|
||||||
{selectedTarget === "Custom" && (
|
className={`${baseClass}__target`}
|
||||||
<LabelChooser
|
onSelectTargetType={onSelectTargetType}
|
||||||
customTargetOption={customTargetOption}
|
onSelectCustomTarget={onSelectCustomTargetOption}
|
||||||
isError={isErrorLabels}
|
onSelectLabel={onSelectLabel}
|
||||||
isLoading={isFetchingLabels}
|
isErrorLabels={isErrorLabels}
|
||||||
labels={labels || []}
|
isLoadingLabels={isFetchingLabels || isLoadingLabels}
|
||||||
selectedLabels={selectedLabels}
|
labels={labels || []}
|
||||||
setSelectedLabels={setSelectedLabels}
|
/>
|
||||||
onSelectCustomTargetOption={onSelectCustomTargetOption}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className={`${baseClass}__button-wrap`}>
|
<div className={`${baseClass}__button-wrap`}>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -352,7 +237,7 @@ const AddProfileModal = ({
|
||||||
onClick={onFileUpload}
|
onClick={onFileUpload}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
disabled={
|
disabled={
|
||||||
(selectedTarget === "Custom" &&
|
(selectedTargetType === "Custom" &&
|
||||||
!listNamesFromSelectedLabels(selectedLabels).length) ||
|
!listNamesFromSelectedLabels(selectedLabels).length) ||
|
||||||
!fileDetails
|
!fileDetails
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,14 +47,9 @@ export const listNamesFromSelectedLabels = (dict: Record<string, boolean>) => {
|
||||||
}, [] as string[]);
|
}, [] as string[]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CustomTargetOption =
|
|
||||||
| "labelsIncludeAll"
|
|
||||||
| "labelsIncldeAny"
|
|
||||||
| "labelsExcludeAny";
|
|
||||||
|
|
||||||
export const generateLabelKey = (
|
export const generateLabelKey = (
|
||||||
target: string,
|
target: string,
|
||||||
customTargetOption: CustomTargetOption,
|
customTargetOption: string,
|
||||||
selectedLabels: Record<string, boolean>
|
selectedLabels: Record<string, boolean>
|
||||||
) => {
|
) => {
|
||||||
if (target !== "Custom") {
|
if (target !== "Custom") {
|
||||||
|
|
@ -65,8 +60,3 @@ export const generateLabelKey = (
|
||||||
[customTargetOption]: listNamesFromSelectedLabels(selectedLabels),
|
[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 React, { useContext, useEffect } from "react";
|
||||||
import { InjectedRouter } from "react-router";
|
import { InjectedRouter } from "react-router";
|
||||||
|
import { useQuery } from "react-query";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
import PATHS from "router/paths";
|
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 { getFileDetails, IFileDetails } from "utilities/file/fileUtils";
|
||||||
import { buildQueryStringFromParams, QueryParams } from "utilities/url";
|
import { buildQueryStringFromParams, QueryParams } from "utilities/url";
|
||||||
import softwareAPI, {
|
import softwareAPI, {
|
||||||
MAX_FILE_SIZE_BYTES,
|
MAX_FILE_SIZE_BYTES,
|
||||||
MAX_FILE_SIZE_MB,
|
MAX_FILE_SIZE_MB,
|
||||||
} from "services/entities/software";
|
} from "services/entities/software";
|
||||||
|
import labelsAPI, { getCustomLabels } from "services/entities/labels";
|
||||||
|
|
||||||
import { NotificationContext } from "context/notification";
|
import { NotificationContext } from "context/notification";
|
||||||
import { AppContext } from "context/app";
|
import { AppContext } from "context/app";
|
||||||
import { getErrorReason } from "interfaces/errors";
|
import { getErrorReason } from "interfaces/errors";
|
||||||
|
import { ILabelSummary } from "interfaces/label";
|
||||||
|
|
||||||
import CustomLink from "components/CustomLink";
|
import CustomLink from "components/CustomLink";
|
||||||
import FileProgressModal from "components/FileProgressModal";
|
import FileProgressModal from "components/FileProgressModal";
|
||||||
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
|
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
|
||||||
|
import Spinner from "components/Spinner";
|
||||||
|
import DataError from "components/DataError";
|
||||||
|
|
||||||
import PackageForm from "pages/SoftwarePage/components/PackageForm";
|
import PackageForm from "pages/SoftwarePage/components/PackageForm";
|
||||||
import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm";
|
import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm";
|
||||||
|
|
@ -46,6 +54,19 @@ const SoftwareCustomPackage = ({
|
||||||
null
|
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(() => {
|
useEffect(() => {
|
||||||
const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
|
const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -159,29 +180,42 @@ const SoftwareCustomPackage = ({
|
||||||
setUploadDetails(null);
|
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) {
|
if (!isPremiumTier) {
|
||||||
return (
|
return (
|
||||||
<PremiumFeatureMessage className={`${baseClass}__premium-message`} />
|
<PremiumFeatureMessage className={`${baseClass}__premium-message`} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <div className={baseClass}>{renderContent()}</div>;
|
||||||
<div className={baseClass}>
|
|
||||||
<PackageForm
|
|
||||||
showSchemaButton={!isSidePanelOpen}
|
|
||||||
onClickShowSchema={() => setSidePanelOpen(true)}
|
|
||||||
className={`${baseClass}__package-form`}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
/>
|
|
||||||
{uploadDetails && (
|
|
||||||
<FileProgressModal
|
|
||||||
fileDetails={uploadDetails}
|
|
||||||
fileProgress={uploadProgress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SoftwareCustomPackage;
|
export default SoftwareCustomPackage;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
margin-top: $pad-xxxlarge;
|
margin-top: $pad-xxxlarge;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__data-error {
|
||||||
|
margin-top: $pad-xxxlarge;
|
||||||
|
}
|
||||||
|
|
||||||
// formatting the form buttons to be on the left. Tshis is done here because
|
// 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
|
// this form can be used in other places where the buttons should be on
|
||||||
// the right.
|
// the right.
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
import { ILabelSummary } from "interfaces/label";
|
||||||
|
|
||||||
import Checkbox from "components/forms/fields/Checkbox";
|
import Checkbox from "components/forms/fields/Checkbox";
|
||||||
import TooltipWrapper from "components/TooltipWrapper";
|
import TooltipWrapper from "components/TooltipWrapper";
|
||||||
import RevealButton from "components/buttons/RevealButton";
|
import RevealButton from "components/buttons/RevealButton";
|
||||||
import Button from "components/buttons/Button";
|
import Button from "components/buttons/Button";
|
||||||
import Radio from "components/forms/fields/Radio";
|
import Radio from "components/forms/fields/Radio";
|
||||||
|
import TargetLabelSelector from "components/TargetLabelSelector";
|
||||||
|
|
||||||
import AdvancedOptionsFields from "pages/SoftwarePage/components/AdvancedOptionsFields";
|
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";
|
const baseClass = "fleet-app-details-form";
|
||||||
|
|
||||||
|
|
@ -19,14 +26,19 @@ export interface IFleetMaintainedAppFormData {
|
||||||
postInstallScript?: string;
|
postInstallScript?: string;
|
||||||
uninstallScript?: string;
|
uninstallScript?: string;
|
||||||
installType: string;
|
installType: string;
|
||||||
|
targetType: string;
|
||||||
|
customTarget: string;
|
||||||
|
labelTargets: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFormValidation {
|
export interface IFormValidation {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
preInstallQuery?: { isValid: boolean; message?: string };
|
preInstallQuery?: { isValid: boolean; message?: string };
|
||||||
|
customTarget?: { isValid: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IFleetAppDetailsFormProps {
|
interface IFleetAppDetailsFormProps {
|
||||||
|
labels: ILabelSummary[] | null;
|
||||||
defaultInstallScript: string;
|
defaultInstallScript: string;
|
||||||
defaultPostInstallScript: string;
|
defaultPostInstallScript: string;
|
||||||
defaultUninstallScript: string;
|
defaultUninstallScript: string;
|
||||||
|
|
@ -37,6 +49,7 @@ interface IFleetAppDetailsFormProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const FleetAppDetailsForm = ({
|
const FleetAppDetailsForm = ({
|
||||||
|
labels,
|
||||||
defaultInstallScript,
|
defaultInstallScript,
|
||||||
defaultPostInstallScript,
|
defaultPostInstallScript,
|
||||||
defaultUninstallScript,
|
defaultUninstallScript,
|
||||||
|
|
@ -54,6 +67,9 @@ const FleetAppDetailsForm = ({
|
||||||
postInstallScript: defaultPostInstallScript,
|
postInstallScript: defaultPostInstallScript,
|
||||||
uninstallScript: defaultUninstallScript,
|
uninstallScript: defaultUninstallScript,
|
||||||
installType: "manual",
|
installType: "manual",
|
||||||
|
targetType: "All hosts",
|
||||||
|
customTarget: "labelsIncludeAny",
|
||||||
|
labelTargets: {},
|
||||||
});
|
});
|
||||||
const [formValidation, setFormValidation] = useState<IFormValidation>({
|
const [formValidation, setFormValidation] = useState<IFormValidation>({
|
||||||
isValid: true,
|
isValid: true,
|
||||||
|
|
@ -95,6 +111,26 @@ const FleetAppDetailsForm = ({
|
||||||
setFormData(newData);
|
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>) => {
|
const onSubmitForm = (evt: React.FormEvent<HTMLFormElement>) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
onSubmit(formData);
|
onSubmit(formData);
|
||||||
|
|
@ -136,6 +172,21 @@ const FleetAppDetailsForm = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</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
|
<Checkbox
|
||||||
value={formData.selfService}
|
value={formData.selfService}
|
||||||
onChange={onToggleSelfServiceCheckbox}
|
onChange={onToggleSelfServiceCheckbox}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { IDropdownOption } from "interfaces/dropdownOption";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import validateQuery from "components/forms/validators/validate_query";
|
import validateQuery from "components/forms/validators/validate_query";
|
||||||
|
|
||||||
|
|
@ -8,6 +12,7 @@ import {
|
||||||
|
|
||||||
type IMessageFunc = (formData: IFleetMaintainedAppFormData) => string;
|
type IMessageFunc = (formData: IFleetMaintainedAppFormData) => string;
|
||||||
type IValidationMessage = string | IMessageFunc;
|
type IValidationMessage = string | IMessageFunc;
|
||||||
|
type IFormValidationKey = keyof Omit<IFormValidation, "isValid">;
|
||||||
|
|
||||||
interface IValidation {
|
interface IValidation {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -16,7 +21,7 @@ interface IValidation {
|
||||||
}
|
}
|
||||||
|
|
||||||
const FORM_VALIDATION_CONFIG: Record<
|
const FORM_VALIDATION_CONFIG: Record<
|
||||||
"preInstallQuery",
|
IFormValidationKey,
|
||||||
{ validations: IValidation[] }
|
{ validations: IValidation[] }
|
||||||
> = {
|
> = {
|
||||||
preInstallQuery: {
|
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 = (
|
const getErrorMessage = (
|
||||||
|
|
@ -54,7 +75,7 @@ export const generateFormValidation = (
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.keys(FORM_VALIDATION_CONFIG).forEach((key) => {
|
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(
|
const failedValidation = FORM_VALIDATION_CONFIG[objKey].validations.find(
|
||||||
(validation) => !validation.isValid(formData)
|
(validation) => !validation.isValid(formData)
|
||||||
);
|
);
|
||||||
|
|
@ -74,3 +95,45 @@ export const generateFormValidation = (
|
||||||
|
|
||||||
return formValidation;
|
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 { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
||||||
import softwareAPI from "services/entities/software";
|
import softwareAPI from "services/entities/software";
|
||||||
import teamPoliciesAPI from "services/entities/team_policies";
|
import teamPoliciesAPI from "services/entities/team_policies";
|
||||||
|
import labelsAPI, { getCustomLabels } from "services/entities/labels";
|
||||||
import { QueryContext } from "context/query";
|
import { QueryContext } from "context/query";
|
||||||
import { AppContext } from "context/app";
|
import { AppContext } from "context/app";
|
||||||
import { NotificationContext } from "context/notification";
|
import { NotificationContext } from "context/notification";
|
||||||
import { getErrorReason } from "interfaces/errors";
|
import { getErrorReason } from "interfaces/errors";
|
||||||
import { Platform, PLATFORM_DISPLAY_NAMES } from "interfaces/platform";
|
import { Platform, PLATFORM_DISPLAY_NAMES } from "interfaces/platform";
|
||||||
|
import { ILabelSummary } from "interfaces/label";
|
||||||
import useToggleSidePanel from "hooks/useToggleSidePanel";
|
import useToggleSidePanel from "hooks/useToggleSidePanel";
|
||||||
|
|
||||||
import BackLink from "components/BackLink";
|
import BackLink from "components/BackLink";
|
||||||
|
|
@ -113,7 +115,11 @@ const FleetMaintainedAppDetailsPage = ({
|
||||||
setShowAddFleetAppSoftwareModal,
|
setShowAddFleetAppSoftwareModal,
|
||||||
] = useState(false);
|
] = useState(false);
|
||||||
|
|
||||||
const { data: fleetApp, isLoading, isError } = useQuery(
|
const {
|
||||||
|
data: fleetApp,
|
||||||
|
isLoading: isLoadingFleetApp,
|
||||||
|
isError: isErrorFleetApp,
|
||||||
|
} = useQuery(
|
||||||
["fleet-maintained-app", appId],
|
["fleet-maintained-app", appId],
|
||||||
() => softwareAPI.getFleetMainainedApp(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) => {
|
const onOsqueryTableSelect = (tableName: string) => {
|
||||||
setSelectedOsqueryTable(tableName);
|
setSelectedOsqueryTable(tableName);
|
||||||
};
|
};
|
||||||
|
|
@ -221,12 +242,12 @@ const FleetMaintainedAppDetailsPage = ({
|
||||||
return <PremiumFeatureMessage />;
|
return <PremiumFeatureMessage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoadingFleetApp || isLoadingLabels) {
|
||||||
return <Spinner />;
|
return <Spinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isErrorFleetApp || isErrorLabels) {
|
||||||
return <DataError />;
|
return <DataError className={`${baseClass}__data-error`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fleetApp) {
|
if (fleetApp) {
|
||||||
|
|
@ -245,6 +266,7 @@ const FleetMaintainedAppDetailsPage = ({
|
||||||
version={fleetApp.version}
|
version={fleetApp.version}
|
||||||
/>
|
/>
|
||||||
<FleetAppDetailsForm
|
<FleetAppDetailsForm
|
||||||
|
labels={labels || []}
|
||||||
showSchemaButton={!isSidePanelOpen}
|
showSchemaButton={!isSidePanelOpen}
|
||||||
defaultInstallScript={fleetApp.install_script}
|
defaultInstallScript={fleetApp.install_script}
|
||||||
defaultPostInstallScript={fleetApp.post_install_script}
|
defaultPostInstallScript={fleetApp.post_install_script}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
.fleet-maintained-app-details-page {
|
.fleet-maintained-app-details-page {
|
||||||
|
&__data-error {
|
||||||
|
margin-top: $pad-xxlarge;
|
||||||
|
}
|
||||||
|
|
||||||
&__back-to-add-software {
|
&__back-to-add-software {
|
||||||
margin-bottom: $pad-medium;
|
margin-bottom: $pad-medium;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
import React, { useContext, useState, useEffect } from "react";
|
import React, { useContext, useState, useEffect } from "react";
|
||||||
import { InjectedRouter } from "react-router";
|
import { useQuery } from "react-query";
|
||||||
import classnames from "classnames";
|
import classnames from "classnames";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
import { getErrorReason } from "interfaces/errors";
|
import { getErrorReason } from "interfaces/errors";
|
||||||
|
import { ILabelSummary } from "interfaces/label";
|
||||||
|
import { ISoftwarePackage } from "interfaces/software";
|
||||||
|
|
||||||
import { NotificationContext } from "context/notification";
|
import { NotificationContext } from "context/notification";
|
||||||
import softwareAPI, {
|
import softwareAPI, {
|
||||||
MAX_FILE_SIZE_BYTES,
|
MAX_FILE_SIZE_BYTES,
|
||||||
MAX_FILE_SIZE_MB,
|
MAX_FILE_SIZE_MB,
|
||||||
} from "services/entities/software";
|
} 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 deepDifference from "utilities/deep_difference";
|
||||||
import { getFileDetails } from "utilities/file/fileUtils";
|
import { getFileDetails } from "utilities/file/fileUtils";
|
||||||
|
|
||||||
|
|
@ -21,6 +27,12 @@ import Modal from "components/Modal";
|
||||||
|
|
||||||
import PackageForm from "pages/SoftwarePage/components/PackageForm";
|
import PackageForm from "pages/SoftwarePage/components/PackageForm";
|
||||||
import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm";
|
import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm";
|
||||||
|
import {
|
||||||
|
generateSelectedLabels,
|
||||||
|
getCustomTarget,
|
||||||
|
getTargetType,
|
||||||
|
} from "pages/SoftwarePage/components/PackageForm/helpers";
|
||||||
|
|
||||||
import { getErrorMessage } from "./helpers";
|
import { getErrorMessage } from "./helpers";
|
||||||
import ConfirmSaveChangesModal from "../ConfirmSaveChangesModal";
|
import ConfirmSaveChangesModal from "../ConfirmSaveChangesModal";
|
||||||
|
|
||||||
|
|
@ -29,8 +41,7 @@ const baseClass = "edit-software-modal";
|
||||||
interface IEditSoftwareModalProps {
|
interface IEditSoftwareModalProps {
|
||||||
softwareId: number;
|
softwareId: number;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
router: InjectedRouter;
|
software: ISoftwarePackage; // TODO
|
||||||
software?: any; // TODO
|
|
||||||
refetchSoftwareTitle: () => void;
|
refetchSoftwareTitle: () => void;
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -56,9 +67,24 @@ const EditSoftwareModal = ({
|
||||||
software: null,
|
software: null,
|
||||||
installScript: "",
|
installScript: "",
|
||||||
selfService: false,
|
selfService: false,
|
||||||
|
targetType: "",
|
||||||
|
customTarget: "",
|
||||||
|
labelTargets: {},
|
||||||
});
|
});
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
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
|
// 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
|
// by using CSS to hide Edit Software modal when Save changes modal is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -118,6 +144,7 @@ const EditSoftwareModal = ({
|
||||||
try {
|
try {
|
||||||
await softwareAPI.editSoftwarePackage({
|
await softwareAPI.editSoftwarePackage({
|
||||||
data: formData,
|
data: formData,
|
||||||
|
orignalPackage: software,
|
||||||
softwareId,
|
softwareId,
|
||||||
teamId,
|
teamId,
|
||||||
onUploadProgress: (progressEvent) => {
|
onUploadProgress: (progressEvent) => {
|
||||||
|
|
@ -147,7 +174,7 @@ const EditSoftwareModal = ({
|
||||||
if (isTimeout) {
|
if (isTimeout) {
|
||||||
renderFlash(
|
renderFlash(
|
||||||
"error",
|
"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")) {
|
} else if (reason.includes("Fleet couldn't read the version from")) {
|
||||||
renderFlash(
|
renderFlash(
|
||||||
|
|
@ -184,18 +211,21 @@ const EditSoftwareModal = ({
|
||||||
postInstallScript: software.post_install_script || "",
|
postInstallScript: software.post_install_script || "",
|
||||||
uninstallScript: software.uninstall_script || "",
|
uninstallScript: software.uninstall_script || "",
|
||||||
selfService: software.self_service || false,
|
selfService: software.self_service || false,
|
||||||
|
targetType: getTargetType(software),
|
||||||
|
customTarget: getCustomTarget(software),
|
||||||
|
labelTargets: generateSelectedLabels(software),
|
||||||
});
|
});
|
||||||
|
|
||||||
setPendingUpdates(formData);
|
setPendingUpdates(formData);
|
||||||
|
|
||||||
const onlySelfServiceUpdated =
|
const onlySelfServiceUpdated =
|
||||||
Object.keys(updates).length === 1 && "selfService" in updates;
|
Object.keys(updates).length === 1 && "selfService" in updates;
|
||||||
if (!onlySelfServiceUpdated) {
|
if (onlySelfServiceUpdated) {
|
||||||
// Open the confirm save changes modal
|
|
||||||
setShowConfirmSaveChangesModal(true);
|
|
||||||
} else {
|
|
||||||
// Proceed with saving changes (API expects only changes)
|
// Proceed with saving changes (API expects only changes)
|
||||||
onSaveSoftwareChanges(formData);
|
onSaveSoftwareChanges(formData);
|
||||||
|
} else {
|
||||||
|
// Open the confirm save changes modal
|
||||||
|
setShowConfirmSaveChangesModal(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -213,6 +243,7 @@ const EditSoftwareModal = ({
|
||||||
width="large"
|
width="large"
|
||||||
>
|
>
|
||||||
<PackageForm
|
<PackageForm
|
||||||
|
labels={labels ?? []}
|
||||||
className={`${baseClass}__package-form`}
|
className={`${baseClass}__package-form`}
|
||||||
isEditingSoftware
|
isEditingSoftware
|
||||||
onCancel={onExit}
|
onCancel={onExit}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import React, {
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { InjectedRouter } from "react-router";
|
|
||||||
|
|
||||||
import PATHS from "router/paths";
|
import PATHS from "router/paths";
|
||||||
import { AppContext } from "context/app";
|
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.
|
// NOTE: we will only have this if we are working with a software package.
|
||||||
softwarePackage?: ISoftwarePackage;
|
softwarePackage?: ISoftwarePackage;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
router: InjectedRouter;
|
|
||||||
refetchSoftwareTitle: () => void;
|
refetchSoftwareTitle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,7 +254,6 @@ const SoftwarePackageCard = ({
|
||||||
softwareId,
|
softwareId,
|
||||||
teamId,
|
teamId,
|
||||||
onDelete,
|
onDelete,
|
||||||
router,
|
|
||||||
refetchSoftwareTitle,
|
refetchSoftwareTitle,
|
||||||
}: ISoftwarePackageCardProps) => {
|
}: ISoftwarePackageCardProps) => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -393,13 +390,12 @@ const SoftwarePackageCard = ({
|
||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{showEditSoftwareModal && (
|
{showEditSoftwareModal && softwarePackage && (
|
||||||
<EditSoftwareModal
|
<EditSoftwareModal
|
||||||
softwareId={softwareId}
|
softwareId={softwareId}
|
||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
software={softwarePackage}
|
software={softwarePackage}
|
||||||
onExit={() => setShowEditSoftwareModal(false)}
|
onExit={() => setShowEditSoftwareModal(false)}
|
||||||
router={router}
|
|
||||||
refetchSoftwareTitle={refetchSoftwareTitle}
|
refetchSoftwareTitle={refetchSoftwareTitle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,6 @@ const SoftwareTitleDetailsPage = ({
|
||||||
softwareId={softwareId}
|
softwareId={softwareId}
|
||||||
teamId={currentTeamId ?? APP_CONTEXT_NO_TEAM_ID}
|
teamId={currentTeamId ?? APP_CONTEXT_NO_TEAM_ID}
|
||||||
onDelete={onDeleteInstaller}
|
onDelete={onDeleteInstaller}
|
||||||
router={router}
|
|
||||||
refetchSoftwareTitle={refetchSoftwareTitle}
|
refetchSoftwareTitle={refetchSoftwareTitle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ describe("SoftwareTitleDetailsPage helpers", () => {
|
||||||
name: "Test Software",
|
name: "Test Software",
|
||||||
versions: [{ id: 1, version: "1.0.0", vulnerabilities: [] }],
|
versions: [{ id: 1, version: "1.0.0", vulnerabilities: [] }],
|
||||||
software_package: {
|
software_package: {
|
||||||
|
labels_include_any: null,
|
||||||
|
labels_exclude_any: null,
|
||||||
name: "TestPackage.pkg",
|
name: "TestPackage.pkg",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
self_service: true,
|
self_service: true,
|
||||||
|
|
@ -21,6 +23,7 @@ describe("SoftwareTitleDetailsPage helpers", () => {
|
||||||
failed_uninstall: 1,
|
failed_uninstall: 1,
|
||||||
},
|
},
|
||||||
install_script: "echo foo",
|
install_script: "echo foo",
|
||||||
|
uninstall_script: "echo bar",
|
||||||
icon_url: "https://example.com/icon.png",
|
icon_url: "https://example.com/icon.png",
|
||||||
automatic_install_policies: [],
|
automatic_install_policies: [],
|
||||||
last_install: null,
|
last_install: null,
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,23 @@ import { NotificationContext } from "context/notification";
|
||||||
import { getFileDetails } from "utilities/file/fileUtils";
|
import { getFileDetails } from "utilities/file/fileUtils";
|
||||||
import getDefaultInstallScript from "utilities/software_install_scripts";
|
import getDefaultInstallScript from "utilities/software_install_scripts";
|
||||||
import getDefaultUninstallScript from "utilities/software_uninstall_scripts";
|
import getDefaultUninstallScript from "utilities/software_uninstall_scripts";
|
||||||
|
import { ILabelSummary } from "interfaces/label";
|
||||||
|
|
||||||
import Button from "components/buttons/Button";
|
import Button from "components/buttons/Button";
|
||||||
import Checkbox from "components/forms/fields/Checkbox";
|
import Checkbox from "components/forms/fields/Checkbox";
|
||||||
import FileUploader from "components/FileUploader";
|
import FileUploader from "components/FileUploader";
|
||||||
import TooltipWrapper from "components/TooltipWrapper";
|
import TooltipWrapper from "components/TooltipWrapper";
|
||||||
|
import TargetLabelSelector from "components/TargetLabelSelector";
|
||||||
|
|
||||||
import PackageAdvancedOptions from "../PackageAdvancedOptions";
|
import PackageAdvancedOptions from "../PackageAdvancedOptions";
|
||||||
|
|
||||||
import { generateFormValidation } from "./helpers";
|
import {
|
||||||
|
CUSTOM_TARGET_OPTIONS,
|
||||||
|
generateFormValidation,
|
||||||
|
generateSelectedLabels,
|
||||||
|
getCustomTarget,
|
||||||
|
getTargetType,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
export const baseClass = "package-form";
|
export const baseClass = "package-form";
|
||||||
|
|
||||||
|
|
@ -25,18 +33,20 @@ export interface IPackageFormData {
|
||||||
postInstallScript?: string;
|
postInstallScript?: string;
|
||||||
uninstallScript?: string;
|
uninstallScript?: string;
|
||||||
selfService: boolean;
|
selfService: boolean;
|
||||||
|
targetType: string;
|
||||||
|
customTarget: string;
|
||||||
|
labelTargets: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFormValidation {
|
export interface IFormValidation {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
software: { isValid: boolean };
|
software: { isValid: boolean };
|
||||||
preInstallQuery?: { isValid: boolean; message?: string };
|
preInstallQuery?: { isValid: boolean; message?: string };
|
||||||
postInstallScript?: { isValid: boolean; message?: string };
|
customTarget?: { isValid: boolean };
|
||||||
uninstallScript?: { isValid: boolean; message?: string };
|
|
||||||
selfService?: { isValid: boolean };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IPackageFormProps {
|
interface IPackageFormProps {
|
||||||
|
labels: ILabelSummary[];
|
||||||
showSchemaButton?: boolean;
|
showSchemaButton?: boolean;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (formData: IPackageFormData) => void;
|
onSubmit: (formData: IPackageFormData) => void;
|
||||||
|
|
@ -54,6 +64,7 @@ interface IPackageFormProps {
|
||||||
const ACCEPTED_EXTENSIONS = ".pkg,.msi,.exe,.deb,.rpm";
|
const ACCEPTED_EXTENSIONS = ".pkg,.msi,.exe,.deb,.rpm";
|
||||||
|
|
||||||
const PackageForm = ({
|
const PackageForm = ({
|
||||||
|
labels,
|
||||||
showSchemaButton = false,
|
showSchemaButton = false,
|
||||||
onClickShowSchema,
|
onClickShowSchema,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
|
@ -69,15 +80,17 @@ const PackageForm = ({
|
||||||
}: IPackageFormProps) => {
|
}: IPackageFormProps) => {
|
||||||
const { renderFlash } = useContext(NotificationContext);
|
const { renderFlash } = useContext(NotificationContext);
|
||||||
|
|
||||||
const initialFormData = {
|
const [formData, setFormData] = useState<IPackageFormData>({
|
||||||
software: defaultSoftware || null,
|
software: defaultSoftware || null,
|
||||||
installScript: defaultInstallScript || "",
|
installScript: defaultInstallScript || "",
|
||||||
preInstallQuery: defaultPreInstallQuery || "",
|
preInstallQuery: defaultPreInstallQuery || "",
|
||||||
postInstallScript: defaultPostInstallScript || "",
|
postInstallScript: defaultPostInstallScript || "",
|
||||||
uninstallScript: defaultUninstallScript || "",
|
uninstallScript: defaultUninstallScript || "",
|
||||||
selfService: defaultSelfService || false,
|
selfService: defaultSelfService || false,
|
||||||
};
|
targetType: getTargetType(defaultSoftware),
|
||||||
const [formData, setFormData] = useState<IPackageFormData>(initialFormData);
|
customTarget: getCustomTarget(defaultSoftware),
|
||||||
|
labelTargets: generateSelectedLabels(defaultSoftware),
|
||||||
|
});
|
||||||
const [formValidation, setFormValidation] = useState<IFormValidation>({
|
const [formValidation, setFormValidation] = useState<IFormValidation>({
|
||||||
isValid: false,
|
isValid: false,
|
||||||
software: { isValid: false },
|
software: { isValid: false },
|
||||||
|
|
@ -156,6 +169,27 @@ const PackageForm = ({
|
||||||
setFormValidation(generateFormValidation(newData));
|
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 isSubmitDisabled = !formValidation.isValid;
|
||||||
|
|
||||||
const classNames = classnames(baseClass, className);
|
const classNames = classnames(baseClass, className);
|
||||||
|
|
@ -176,6 +210,17 @@ const PackageForm = ({
|
||||||
formData.software ? getFileDetails(formData.software) : undefined
|
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
|
<Checkbox
|
||||||
value={formData.selfService}
|
value={formData.selfService}
|
||||||
onChange={onToggleSelfServiceCheckbox}
|
onChange={onToggleSelfServiceCheckbox}
|
||||||
|
|
@ -196,7 +241,6 @@ const PackageForm = ({
|
||||||
selectedPackage={formData.software}
|
selectedPackage={formData.software}
|
||||||
errors={{
|
errors={{
|
||||||
preInstallQuery: formValidation.preInstallQuery?.message,
|
preInstallQuery: formValidation.preInstallQuery?.message,
|
||||||
postInstallScript: formValidation.postInstallScript?.message,
|
|
||||||
}}
|
}}
|
||||||
preInstallQuery={formData.preInstallQuery}
|
preInstallQuery={formData.preInstallQuery}
|
||||||
installScript={formData.installScript}
|
installScript={formData.installScript}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
|
import { IDropdownOption } from "interfaces/dropdownOption";
|
||||||
|
import { ISoftwarePackage } from "interfaces/software";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import validateQuery from "components/forms/validators/validate_query";
|
import validateQuery from "components/forms/validators/validate_query";
|
||||||
|
|
||||||
import { IPackageFormData, IFormValidation } from "./PackageForm";
|
import { IPackageFormData, IFormValidation } from "./PackageForm";
|
||||||
|
|
||||||
type IPackageFormValidatorKey = Exclude<
|
|
||||||
keyof IPackageFormData,
|
|
||||||
"installScript" | "uninstallScript"
|
|
||||||
>;
|
|
||||||
|
|
||||||
type IMessageFunc = (formData: IPackageFormData) => string;
|
type IMessageFunc = (formData: IPackageFormData) => string;
|
||||||
type IValidationMessage = string | IMessageFunc;
|
type IValidationMessage = string | IMessageFunc;
|
||||||
|
type IFormValidationKey = keyof Omit<IFormValidation, "isValid">;
|
||||||
|
|
||||||
interface IValidation {
|
interface IValidation {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -17,11 +16,11 @@ interface IValidation {
|
||||||
message?: IValidationMessage;
|
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.
|
* to determine if a field is valid, and rules for generating an error message.
|
||||||
*/
|
*/
|
||||||
const FORM_VALIDATION_CONFIG: Record<
|
const FORM_VALIDATION_CONFIG: Record<
|
||||||
IPackageFormValidatorKey,
|
IFormValidationKey,
|
||||||
{ validations: IValidation[] }
|
{ validations: IValidation[] }
|
||||||
> = {
|
> = {
|
||||||
software: {
|
software: {
|
||||||
|
|
@ -46,13 +45,21 @@ const FORM_VALIDATION_CONFIG: Record<
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
postInstallScript: {
|
customTarget: {
|
||||||
// no validations related to postInstallScript
|
validations: [
|
||||||
validations: [],
|
{
|
||||||
},
|
name: "requiredLabelTargets",
|
||||||
selfService: {
|
isValid: (formData) => {
|
||||||
// no validations related to self service
|
if (formData.targetType === "All hosts") return true;
|
||||||
validations: [],
|
// 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 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_FILTER_KEYS,
|
||||||
MANAGE_HOSTS_PAGE_LABEL_INCOMPATIBLE_QUERY_PARAMS,
|
MANAGE_HOSTS_PAGE_LABEL_INCOMPATIBLE_QUERY_PARAMS,
|
||||||
} from "./HostsPageConfig";
|
} from "./HostsPageConfig";
|
||||||
import { isAcceptableStatus } from "./helpers";
|
import { getDeleteLabelErrorMessages, isAcceptableStatus } from "./helpers";
|
||||||
|
|
||||||
import DeleteSecretModal from "../../../components/EnrollSecrets/DeleteSecretModal";
|
import DeleteSecretModal from "../../../components/EnrollSecrets/DeleteSecretModal";
|
||||||
import SecretEditorModal from "../../../components/EnrollSecrets/SecretEditorModal";
|
import SecretEditorModal from "../../../components/EnrollSecrets/SecretEditorModal";
|
||||||
|
|
@ -1061,13 +1061,7 @@ const ManageHostsPage = ({
|
||||||
);
|
);
|
||||||
renderFlash("success", "Successfully deleted label.");
|
renderFlash("success", "Successfully deleted label.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
renderFlash("error", getDeleteLabelErrorMessages(error));
|
||||||
renderFlash(
|
|
||||||
"error",
|
|
||||||
getErrorReason(error).includes("built-in")
|
|
||||||
? "Built-in labels can’t be modified or deleted."
|
|
||||||
: "Could not delete label. Please try again."
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsUpdatingLabel(false);
|
setIsUpdatingLabel(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,14 @@ const DeleteLabelModal = ({
|
||||||
className={baseClass}
|
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">
|
<div className="modal-cta-wrap">
|
||||||
<Button
|
<Button
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { getErrorReason } from "interfaces/errors";
|
||||||
|
|
||||||
export const isAcceptableStatus = (filter: string): boolean => {
|
export const isAcceptableStatus = (filter: string): boolean => {
|
||||||
return (
|
return (
|
||||||
filter === "new" ||
|
filter === "new" ||
|
||||||
|
|
@ -21,3 +23,25 @@ export const isValidPemCertificate = (cert: string): boolean => {
|
||||||
|
|
||||||
return regexPemHeader.test(cert) && regexPemFooter.test(cert);
|
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,
|
ISoftwareTitleDetails,
|
||||||
IFleetMaintainedApp,
|
IFleetMaintainedApp,
|
||||||
IFleetMaintainedAppDetails,
|
IFleetMaintainedAppDetails,
|
||||||
|
ISoftwarePackage,
|
||||||
} from "interfaces/software";
|
} from "interfaces/software";
|
||||||
import {
|
import {
|
||||||
buildQueryStringFromParams,
|
buildQueryStringFromParams,
|
||||||
|
|
@ -17,6 +18,8 @@ import {
|
||||||
} from "utilities/url";
|
} from "utilities/url";
|
||||||
import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm";
|
import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm";
|
||||||
import { IAddFleetMaintainedData } from "pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage";
|
import { IAddFleetMaintainedData } from "pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage";
|
||||||
|
import { listNamesFromSelectedLabels } from "components/TargetLabelSelector/TargetLabelSelector";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
export interface ISoftwareApiParams {
|
export interface ISoftwareApiParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
@ -138,6 +141,8 @@ interface IAddFleetMaintainedAppPostBody {
|
||||||
post_install_script?: string;
|
post_install_script?: string;
|
||||||
uninstall_script?: string;
|
uninstall_script?: string;
|
||||||
self_service?: boolean;
|
self_service?: boolean;
|
||||||
|
labels_include_any?: string[];
|
||||||
|
labels_exclude_any?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ORDER_KEY = "name";
|
const ORDER_KEY = "name";
|
||||||
|
|
@ -276,6 +281,19 @@ export default {
|
||||||
formData.append("post_install_script", data.postInstallScript);
|
formData.append("post_install_script", data.postInstallScript);
|
||||||
teamId && formData.append("team_id", teamId.toString());
|
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({
|
return sendRequestWithProgress({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: SOFTWARE_PACKAGE_ADD,
|
path: SOFTWARE_PACKAGE_ADD,
|
||||||
|
|
@ -289,6 +307,7 @@ export default {
|
||||||
|
|
||||||
editSoftwarePackage: ({
|
editSoftwarePackage: ({
|
||||||
data,
|
data,
|
||||||
|
orignalPackage,
|
||||||
softwareId,
|
softwareId,
|
||||||
teamId,
|
teamId,
|
||||||
timeout,
|
timeout,
|
||||||
|
|
@ -296,6 +315,7 @@ export default {
|
||||||
signal,
|
signal,
|
||||||
}: {
|
}: {
|
||||||
data: IPackageFormData;
|
data: IPackageFormData;
|
||||||
|
orignalPackage: ISoftwarePackage;
|
||||||
softwareId: number;
|
softwareId: number;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
|
@ -313,6 +333,29 @@ export default {
|
||||||
formData.append("post_install_script", data.postInstallScript || "");
|
formData.append("post_install_script", data.postInstallScript || "");
|
||||||
formData.append("uninstall_script", data.uninstallScript || "");
|
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({
|
return sendRequestWithProgress({
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
path: EDIT_SOFTWARE_PACKAGE(softwareId),
|
path: EDIT_SOFTWARE_PACKAGE(softwareId),
|
||||||
|
|
@ -380,6 +423,15 @@ export default {
|
||||||
self_service: formData.selfService,
|
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);
|
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))
|
multiError = multierror.Append(multiError, fmt.Errorf("software URL %q is too long, must be %d characters or less", softwarePackageSpec.URL, fleet.SoftwareInstallerURLMaxLength))
|
||||||
continue
|
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)
|
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)
|
installer1, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
sw1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
sw1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
InstallScript: "install foo",
|
InstallScript: "install foo",
|
||||||
InstallerFile: installer1,
|
InstallerFile: installer1,
|
||||||
StorageID: uuid.NewString(),
|
StorageID: uuid.NewString(),
|
||||||
Filename: "foo.pkg",
|
Filename: "foo.pkg",
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
Version: "0.0.1",
|
Version: "0.0.1",
|
||||||
UserID: u.ID,
|
UserID: u.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
installer2, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
|
installer2, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
sw2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
sw2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
InstallScript: "install bar",
|
InstallScript: "install bar",
|
||||||
InstallerFile: installer2,
|
InstallerFile: installer2,
|
||||||
StorageID: uuid.NewString(),
|
StorageID: uuid.NewString(),
|
||||||
Filename: "bar.pkg",
|
Filename: "bar.pkg",
|
||||||
Title: "bar",
|
Title: "bar",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
Version: "0.0.2",
|
Version: "0.0.2",
|
||||||
UserID: u.ID,
|
UserID: u.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
installer3, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
|
installer3, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
sw3, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
sw3, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
InstallScript: "install to delete",
|
InstallScript: "install to delete",
|
||||||
InstallerFile: installer3,
|
InstallerFile: installer3,
|
||||||
StorageID: uuid.NewString(),
|
StorageID: uuid.NewString(),
|
||||||
Filename: "todelete.pkg",
|
Filename: "todelete.pkg",
|
||||||
Title: "todelete",
|
Title: "todelete",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
Version: "0.0.3",
|
Version: "0.0.3",
|
||||||
UserID: u.ID,
|
UserID: u.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
sw1Meta, err := ds.GetSoftwareInstallerMetadataByID(ctx, sw1)
|
sw1Meta, err := ds.GetSoftwareInstallerMetadataByID(ctx, sw1)
|
||||||
|
|
|
||||||
|
|
@ -6940,6 +6940,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
|
||||||
PreInstallQuery: "",
|
PreInstallQuery: "",
|
||||||
Title: "ChocolateRain",
|
Title: "ChocolateRain",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = ds.InsertSoftwareInstallRequest(context.Background(), host.ID, softwareInstaller, false, nil)
|
_, 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)
|
_, err = tx.ExecContext(ctx, `DELETE FROM labels WHERE id = ?`, labelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isMySQLForeignKey(err) {
|
||||||
|
return ctxerr.Wrap(ctx, foreignKey("labels", name), "delete label")
|
||||||
|
}
|
||||||
return ctxerr.Wrapf(ctx, err, "delete label")
|
return ctxerr.Wrapf(ctx, err, "delete label")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -918,25 +919,65 @@ func testLabelsRecordNonexistentQueryLabelExecution(t *testing.T, db *Datastore)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDeleteLabel(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(),
|
Name: t.Name(),
|
||||||
Query: "query1",
|
Query: "query1",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
p, err := db.NewPack(context.Background(), &fleet.Pack{
|
p, err := db.NewPack(ctx, &fleet.Pack{
|
||||||
Name: t.Name(),
|
Name: t.Name(),
|
||||||
LabelIDs: []uint{l.ID},
|
LabelIDs: []uint{l.ID},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
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.NoError(t, err)
|
||||||
require.Empty(t, newP.Labels)
|
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) {
|
func testLabelsSummary(t *testing.T, db *Datastore) {
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) {
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Platform: string(fleet.MacOSPlatform),
|
Platform: string(fleet.MacOSPlatform),
|
||||||
BundleIdentifier: "irrelevant_1",
|
BundleIdentifier: "irrelevant_1",
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -233,6 +234,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) {
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Platform: string(fleet.MacOSPlatform),
|
Platform: string(fleet.MacOSPlatform),
|
||||||
BundleIdentifier: "fleet.maintained1",
|
BundleIdentifier: "fleet.maintained1",
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -252,6 +254,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) {
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Platform: string(fleet.IOSPlatform),
|
Platform: string(fleet.IOSPlatform),
|
||||||
BundleIdentifier: "fleet.maintained1",
|
BundleIdentifier: "fleet.maintained1",
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
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",
|
Version: "1.0",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
policy2, err := ds.NewGlobalPolicy(ctx, &user.ID, fleet.PolicyPayload{
|
policy2, err := ds.NewGlobalPolicy(ctx, &user.ID, fleet.PolicyPayload{
|
||||||
|
|
@ -885,6 +886,7 @@ func testTeamPolicyPendingScriptsAndInstalls(t *testing.T, ds *Datastore) {
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
TeamID: &team2.ID,
|
TeamID: &team2.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
policy2, err := ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{
|
policy2, err := ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{
|
||||||
|
|
@ -1444,6 +1446,7 @@ func testPoliciesByID(t *testing.T, ds *Datastore) {
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
policy2.SoftwareInstallerID = ptr.Uint(installerID)
|
policy2.SoftwareInstallerID = ptr.Uint(installerID)
|
||||||
|
|
@ -4192,6 +4195,7 @@ func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) {
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Nil(t, p1.SoftwareInstallerID)
|
require.Nil(t, p1.SoftwareInstallerID)
|
||||||
|
|
@ -4230,6 +4234,7 @@ func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) {
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: ptr.Uint(fleet.PolicyNoTeamID),
|
TeamID: ptr.Uint(fleet.PolicyNoTeamID),
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
p4, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{
|
p4, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{
|
||||||
|
|
@ -4451,6 +4456,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
installer1, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer1ID)
|
installer1, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer1ID)
|
||||||
|
|
@ -4470,6 +4476,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
|
||||||
Source: "deb_packages",
|
Source: "deb_packages",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team2.ID,
|
TeamID: &team2.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
installer2, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer2ID)
|
installer2, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer2ID)
|
||||||
|
|
@ -4489,6 +4496,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
|
||||||
Source: "rpm_packages",
|
Source: "rpm_packages",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: nil,
|
TeamID: nil,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
installer3, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer3ID)
|
installer3, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer3ID)
|
||||||
|
|
@ -4509,6 +4517,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
|
||||||
Source: "programs",
|
Source: "programs",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
installer5, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer5ID)
|
installer5, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer5ID)
|
||||||
|
|
@ -4700,6 +4709,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team2.ID,
|
TeamID: &team2.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
installer4, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer4ID)
|
installer4, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer4ID)
|
||||||
|
|
@ -5229,6 +5239,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
policy1.SoftwareInstallerID = ptr.Uint(installer1ID)
|
policy1.SoftwareInstallerID = ptr.Uint(installer1ID)
|
||||||
|
|
@ -5248,6 +5259,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team2.ID,
|
TeamID: &team2.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
policy2.SoftwareInstallerID = ptr.Uint(installer2ID)
|
policy2.SoftwareInstallerID = ptr.Uint(installer2ID)
|
||||||
|
|
@ -5304,6 +5316,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: nil,
|
TeamID: nil,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -5319,6 +5332,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: nil,
|
TeamID: nil,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
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",
|
Version: "1.0",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -1358,6 +1359,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
|
||||||
Version: "1.0",
|
Version: "1.0",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
Platform: string(fleet.MacOSPlatform),
|
Platform: string(fleet.MacOSPlatform),
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -91,6 +92,7 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team2.ID,
|
TeamID: &team2.ID,
|
||||||
Platform: string(fleet.MacOSPlatform),
|
Platform: string(fleet.MacOSPlatform),
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -330,6 +332,7 @@ func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
Platform: string(fleet.MacOSPlatform),
|
Platform: string(fleet.MacOSPlatform),
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -349,6 +352,7 @@ func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team2.ID,
|
TeamID: &team2.ID,
|
||||||
Platform: string(fleet.MacOSPlatform),
|
Platform: string(fleet.MacOSPlatform),
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -368,6 +372,7 @@ func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team2.ID,
|
TeamID: &team2.ID,
|
||||||
Platform: string(fleet.IOSPlatform),
|
Platform: string(fleet.IOSPlatform),
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -464,6 +469,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
Platform: string(fleet.MacOSPlatform),
|
Platform: string(fleet.MacOSPlatform),
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
_ = installerID1
|
_ = installerID1
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -483,6 +489,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
Platform: string(fleet.MacOSPlatform),
|
Platform: string(fleet.MacOSPlatform),
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
_ = installerID2
|
_ = installerID2
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -503,6 +510,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team2.ID,
|
TeamID: &team2.ID,
|
||||||
Platform: string(fleet.MacOSPlatform),
|
Platform: string(fleet.MacOSPlatform),
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
_ = installerID3
|
_ = installerID3
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -523,6 +531,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team2.ID,
|
TeamID: &team2.ID,
|
||||||
Platform: string(fleet.IOSPlatform),
|
Platform: string(fleet.IOSPlatform),
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
_ = installerID4
|
_ = installerID4
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -678,7 +687,7 @@ func testSetupExperienceStatusResults(t *testing.T, ds *Datastore) {
|
||||||
// We need a new user first
|
// 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!")})
|
user, err := ds.NewUser(ctx, &fleet.User{Name: "Foo", Email: "foo@example.com", GlobalRole: ptr.String("admin"), Password: []byte("12characterslong!")})
|
||||||
require.NoError(t, err)
|
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)
|
require.NoError(t, err)
|
||||||
installer, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
installer, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
||||||
require.NoError(t, err)
|
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
|
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
|
LEFT OUTER JOIN
|
||||||
nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid
|
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
|
WHERE
|
||||||
-- use the latest VPP install attempt only
|
-- use the latest VPP install attempt only
|
||||||
( hvsi.id IS NULL OR hvsi.id = (
|
( 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
|
-- on host (via installer or VPP app). If only available for install is
|
||||||
-- requested, then the software installed on host clause is empty.
|
-- requested, then the software installed on host clause is empty.
|
||||||
( %s hsi.host_id IS NOT NULL OR hvsi.host_id IS NOT NULL )
|
( %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
|
%s
|
||||||
`, status, softwareIsInstalledOnHostClause, onlySelfServiceClause)
|
`, status, softwareIsInstalledOnHostClause, onlySelfServiceClause)
|
||||||
|
|
||||||
|
|
@ -2375,7 +2438,66 @@ INNER JOIN software_cve scve ON scve.software_id = s.id
|
||||||
hvsi.removed = 0
|
hvsi.removed = 0
|
||||||
) AND
|
) AND
|
||||||
-- either the software installer or the vpp app exists for the host's team
|
-- 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
|
%s %s
|
||||||
`, onlySelfServiceClause, excludeVPPAppsClause)
|
`, onlySelfServiceClause, excludeVPPAppsClause)
|
||||||
|
|
||||||
|
|
@ -2415,6 +2537,7 @@ INNER JOIN software_cve scve ON scve.software_id = s.id
|
||||||
"mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError,
|
"mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError,
|
||||||
"global_or_team_id": globalOrTeamID,
|
"global_or_team_id": globalOrTeamID,
|
||||||
"is_mdm_enrolled": opts.IsMDMEnrolled,
|
"is_mdm_enrolled": opts.IsMDMEnrolled,
|
||||||
|
"host_label_updated_at": host.LabelUpdatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
stmt := stmtInstalled
|
stmt := stmtInstalled
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"github.com/fleetdm/fleet/v4/server/authz"
|
"github.com/fleetdm/fleet/v4/server/authz"
|
||||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||||
|
"github.com/go-kit/kit/log/level"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jmoiron/sqlx"
|
"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) {
|
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)
|
titleID, err = ds.getOrGenerateSoftwareInstallerTitleID(ctx, payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, ctxerr.Wrap(ctx, err, "get or generate software installer title ID")
|
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 (
|
INSERT INTO software_installers (
|
||||||
team_id,
|
team_id,
|
||||||
global_or_team_id,
|
global_or_team_id,
|
||||||
|
|
@ -152,39 +160,49 @@ INSERT INTO software_installers (
|
||||||
fleet_library_app_id
|
fleet_library_app_id
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?)`
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?)`
|
||||||
|
|
||||||
args := []interface{}{
|
args := []interface{}{
|
||||||
tid,
|
tid,
|
||||||
globalOrTeamID,
|
globalOrTeamID,
|
||||||
titleID,
|
titleID,
|
||||||
payload.StorageID,
|
payload.StorageID,
|
||||||
payload.Filename,
|
payload.Filename,
|
||||||
payload.Extension,
|
payload.Extension,
|
||||||
payload.Version,
|
payload.Version,
|
||||||
strings.Join(payload.PackageIDs, ","),
|
strings.Join(payload.PackageIDs, ","),
|
||||||
installScriptID,
|
installScriptID,
|
||||||
payload.PreInstallQuery,
|
payload.PreInstallQuery,
|
||||||
postInstallScriptID,
|
postInstallScriptID,
|
||||||
uninstallScriptID,
|
uninstallScriptID,
|
||||||
payload.Platform,
|
payload.Platform,
|
||||||
payload.SelfService,
|
payload.SelfService,
|
||||||
payload.UserID,
|
payload.UserID,
|
||||||
payload.UserID,
|
payload.UserID,
|
||||||
payload.UserID,
|
payload.UserID,
|
||||||
payload.FleetLibraryAppID,
|
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
return 0, 0, ctxerr.Wrap(ctx, err, "insert software installer")
|
||||||
}
|
}
|
||||||
|
|
||||||
id, _ := res.LastInsertId()
|
return installerID, titleID, nil
|
||||||
|
|
||||||
return uint(id), titleID, nil //nolint:gosec // dismiss G115
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ds *Datastore) getOrGenerateSoftwareInstallerTitleID(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) {
|
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")
|
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 {
|
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)
|
_, err := ds.writer(ctx).ExecContext(ctx, `UPDATE software_installers SET self_service = ? WHERE id = ?`, selfService, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -275,7 +348,8 @@ func (ds *Datastore) SaveInstallerUpdates(ctx context.Context, payload *fleet.Up
|
||||||
touchUploaded = ", uploaded_at = NOW()"
|
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 = ?,
|
storage_id = ?,
|
||||||
filename = ?,
|
filename = ?,
|
||||||
version = ?,
|
version = ?,
|
||||||
|
|
@ -290,23 +364,34 @@ func (ds *Datastore) SaveInstallerUpdates(ctx context.Context, payload *fleet.Up
|
||||||
user_email = (SELECT email FROM users WHERE id = ?) %s
|
user_email = (SELECT email FROM users WHERE id = ?) %s
|
||||||
WHERE id = ?`, touchUploaded)
|
WHERE id = ?`, touchUploaded)
|
||||||
|
|
||||||
args := []interface{}{
|
args := []interface{}{
|
||||||
payload.StorageID,
|
payload.StorageID,
|
||||||
payload.Filename,
|
payload.Filename,
|
||||||
payload.Version,
|
payload.Version,
|
||||||
strings.Join(payload.PackageIDs, ","),
|
strings.Join(payload.PackageIDs, ","),
|
||||||
installScriptID,
|
installScriptID,
|
||||||
*payload.PreInstallQuery,
|
*payload.PreInstallQuery,
|
||||||
postInstallScriptID,
|
postInstallScriptID,
|
||||||
uninstallScriptID,
|
uninstallScriptID,
|
||||||
*payload.SelfService,
|
*payload.SelfService,
|
||||||
payload.UserID,
|
payload.UserID,
|
||||||
payload.UserID,
|
payload.UserID,
|
||||||
payload.UserID,
|
payload.UserID,
|
||||||
payload.InstallerID,
|
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 {
|
if err != nil {
|
||||||
return ctxerr.Wrap(ctx, err, "update software installer")
|
return ctxerr.Wrap(ctx, err, "update software installer")
|
||||||
}
|
}
|
||||||
|
|
@ -423,6 +508,28 @@ WHERE
|
||||||
return nil, ctxerr.Wrap(ctx, err, "get software installer metadata")
|
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)
|
policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{titleID}, teamID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ctxerr.Wrap(ctx, err, "get policies by software title ID")
|
return nil, ctxerr.Wrap(ctx, err, "get policies by software title ID")
|
||||||
|
|
@ -432,6 +539,28 @@ WHERE
|
||||||
return &dest, nil
|
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 (
|
var (
|
||||||
errDeleteInstallerWithAssociatedPolicy = &fleet.ConflictError{Message: "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."}
|
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."}
|
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_name = VALUES(user_name),
|
||||||
user_email = VALUES(user_email),
|
user_email = VALUES(user_email),
|
||||||
url = VALUES(url),
|
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
|
// use a team id of 0 if no-team
|
||||||
|
|
@ -1058,7 +1236,7 @@ ON DUPLICATE KEY UPDATE
|
||||||
replacingInstallDuringSetup = true
|
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
|
// if no installers are provided, just delete whatever was in
|
||||||
// the table
|
// the table
|
||||||
if len(installers) == 0 {
|
if len(installers) == 0 {
|
||||||
|
|
@ -1159,6 +1337,10 @@ ON DUPLICATE KEY UPDATE
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, installer := range installers {
|
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)
|
isRes, err := insertScriptContents(ctx, tx, installer.InstallScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ctxerr.Wrapf(ctx, err, "inserting install script contents for software installer with name %q", installer.Filename)
|
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)
|
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 len(existing) > 0 {
|
||||||
if err := ds.runInstallerUpdateSideEffectsInTransaction(
|
if err := ds.runInstallerUpdateSideEffectsInTransaction(
|
||||||
ctx,
|
ctx,
|
||||||
|
|
@ -1259,10 +1518,7 @@ ON DUPLICATE KEY UPDATE
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
})
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ds *Datastore) HasSelfServiceSoftwareInstallers(ctx context.Context, hostPlatform string, hostTeamID *uint) (bool, error) {
|
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
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -37,6 +38,7 @@ func TestSoftwareInstallers(t *testing.T) {
|
||||||
{"testDeletePendingSoftwareInstallsForPolicy", testDeletePendingSoftwareInstallsForPolicy},
|
{"testDeletePendingSoftwareInstallsForPolicy", testDeletePendingSoftwareInstallsForPolicy},
|
||||||
{"GetHostLastInstallData", testGetHostLastInstallData},
|
{"GetHostLastInstallData", testGetHostLastInstallData},
|
||||||
{"GetOrGenerateSoftwareInstallerTitleID", testGetOrGenerateSoftwareInstallerTitleID},
|
{"GetOrGenerateSoftwareInstallerTitleID", testGetOrGenerateSoftwareInstallerTitleID},
|
||||||
|
{"BatchSetSoftwareInstallersScopedViaLabels", testBatchSetSoftwareInstallersScopedViaLabels},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
|
|
@ -84,6 +86,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
|
||||||
Version: "1.0",
|
Version: "1.0",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -100,6 +103,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
|
||||||
Version: "2.0",
|
Version: "2.0",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -117,6 +121,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
SelfService: true,
|
SelfService: true,
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -219,12 +224,13 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
|
||||||
require.Nil(t, si)
|
require.Nil(t, si)
|
||||||
|
|
||||||
installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
Source: "bar",
|
Source: "bar",
|
||||||
InstallScript: "echo",
|
InstallScript: "echo",
|
||||||
TeamID: teamID,
|
TeamID: teamID,
|
||||||
Filename: "foo.pkg",
|
Filename: "foo.pkg",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
||||||
|
|
@ -519,13 +525,14 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
|
||||||
// create a host and software installer
|
// create a host and software installer
|
||||||
swFilename := "file_" + tc.name + ".pkg"
|
swFilename := "file_" + tc.name + ".pkg"
|
||||||
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
Title: "foo" + tc.name,
|
Title: "foo" + tc.name,
|
||||||
Source: "bar" + tc.name,
|
Source: "bar" + tc.name,
|
||||||
InstallScript: "echo " + tc.name,
|
InstallScript: "echo " + tc.name,
|
||||||
Version: "1.11",
|
Version: "1.11",
|
||||||
TeamID: &teamID,
|
TeamID: &teamID,
|
||||||
Filename: swFilename,
|
Filename: swFilename,
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
host, err := ds.NewHost(ctx, &fleet.Host{
|
host, err := ds.NewHost(ctx, &fleet.Host{
|
||||||
|
|
@ -650,13 +657,14 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) {
|
||||||
assertExisting([]string{ins0})
|
assertExisting([]string{ins0})
|
||||||
|
|
||||||
swi, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
swi, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
InstallScript: "install",
|
InstallScript: "install",
|
||||||
InstallerFile: tfr0,
|
InstallerFile: tfr0,
|
||||||
StorageID: ins0,
|
StorageID: ins0,
|
||||||
Filename: "installer0",
|
Filename: "installer0",
|
||||||
Title: "ins0",
|
Title: "ins0",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -739,6 +747,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
URL: "https://example.com",
|
URL: "https://example.com",
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
}})
|
}})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
softwareInstallers, err = ds.GetSoftwareInstallers(ctx, team.ID)
|
softwareInstallers, err = ds.GetSoftwareInstallers(ctx, team.ID)
|
||||||
|
|
@ -772,6 +781,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
URL: "https://example.com",
|
URL: "https://example.com",
|
||||||
InstallDuringSetup: ptr.Bool(true),
|
InstallDuringSetup: ptr.Bool(true),
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
InstallScript: "install",
|
InstallScript: "install",
|
||||||
|
|
@ -786,6 +796,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
URL: "https://example2.com",
|
URL: "https://example2.com",
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -818,6 +829,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
||||||
Version: "2",
|
Version: "2",
|
||||||
PreInstallQuery: "select 1 from bar;",
|
PreInstallQuery: "select 1 from bar;",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
|
@ -839,6 +851,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
URL: "https://example.com",
|
URL: "https://example.com",
|
||||||
InstallDuringSetup: nil,
|
InstallDuringSetup: nil,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
InstallScript: "install",
|
InstallScript: "install",
|
||||||
|
|
@ -853,6 +866,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
URL: "https://example2.com",
|
URL: "https://example2.com",
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -872,6 +886,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
URL: "https://example.com",
|
URL: "https://example.com",
|
||||||
InstallDuringSetup: ptr.Bool(false),
|
InstallDuringSetup: ptr.Bool(false),
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
InstallScript: "install",
|
InstallScript: "install",
|
||||||
|
|
@ -886,6 +901,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
URL: "https://example2.com",
|
URL: "https://example2.com",
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -903,6 +919,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
|
||||||
Version: "2",
|
Version: "2",
|
||||||
PreInstallQuery: "select 1 from bar;",
|
PreInstallQuery: "select 1 from bar;",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -941,6 +958,7 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor
|
||||||
Filename: "foo.pkg",
|
Filename: "foo.pkg",
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
||||||
|
|
@ -955,12 +973,13 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor
|
||||||
require.Equal(t, "SELECT 1", metaByTeamAndTitle.PreInstallQuery)
|
require.Equal(t, "SELECT 1", metaByTeamAndTitle.PreInstallQuery)
|
||||||
|
|
||||||
installerID, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installerID, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
Title: "bar",
|
Title: "bar",
|
||||||
Source: "bar",
|
Source: "bar",
|
||||||
InstallScript: "echo install",
|
InstallScript: "echo install",
|
||||||
TeamID: &team.ID,
|
TeamID: &team.ID,
|
||||||
Filename: "foo.pkg",
|
Filename: "foo.pkg",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
installerMeta, err = ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
installerMeta, err = ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
|
||||||
|
|
@ -994,14 +1013,15 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
|
||||||
|
|
||||||
// Create a non-self service installer
|
// Create a non-self service installer
|
||||||
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
Source: "bar",
|
Source: "bar",
|
||||||
InstallScript: "echo install",
|
InstallScript: "echo install",
|
||||||
TeamID: &team.ID,
|
TeamID: &team.ID,
|
||||||
Filename: "foo.pkg",
|
Filename: "foo.pkg",
|
||||||
Platform: platform,
|
Platform: platform,
|
||||||
SelfService: false,
|
SelfService: false,
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil)
|
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
|
// Create a self-service installer for team
|
||||||
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
Title: "foo2",
|
Title: "foo2",
|
||||||
Source: "bar2",
|
Source: "bar2",
|
||||||
InstallScript: "echo install",
|
InstallScript: "echo install",
|
||||||
TeamID: &team.ID,
|
TeamID: &team.ID,
|
||||||
Filename: "foo2.pkg",
|
Filename: "foo2.pkg",
|
||||||
Platform: platform,
|
Platform: platform,
|
||||||
SelfService: true,
|
SelfService: true,
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil)
|
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil)
|
||||||
|
|
@ -1052,14 +1073,15 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
|
||||||
|
|
||||||
// Create a global self-service installer
|
// Create a global self-service installer
|
||||||
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
Title: "foo global",
|
Title: "foo global",
|
||||||
Source: "bar",
|
Source: "bar",
|
||||||
InstallScript: "echo install",
|
InstallScript: "echo install",
|
||||||
TeamID: nil,
|
TeamID: nil,
|
||||||
Filename: "foo global.pkg",
|
Filename: "foo global.pkg",
|
||||||
Platform: platform,
|
Platform: platform,
|
||||||
SelfService: true,
|
SelfService: true,
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "ubuntu", nil)
|
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "ubuntu", nil)
|
||||||
|
|
@ -1103,15 +1125,16 @@ func testDeleteSoftwareInstallers(t *testing.T, ds *Datastore) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
softwareInstallerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
softwareInstallerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
InstallScript: "install",
|
InstallScript: "install",
|
||||||
InstallerFile: tfr0,
|
InstallerFile: tfr0,
|
||||||
StorageID: ins0,
|
StorageID: ins0,
|
||||||
Filename: "installer.pkg",
|
Filename: "installer.pkg",
|
||||||
Title: "ins0",
|
Title: "ins0",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -1177,15 +1200,16 @@ func testDeletePendingSoftwareInstallsForPolicy(t *testing.T, ds *Datastore) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
InstallScript: "install",
|
InstallScript: "install",
|
||||||
InstallerFile: tfr0,
|
InstallerFile: tfr0,
|
||||||
StorageID: ins0,
|
StorageID: ins0,
|
||||||
Filename: "installer.pkg",
|
Filename: "installer.pkg",
|
||||||
Title: "ins0",
|
Title: "ins0",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -1197,15 +1221,16 @@ func testDeletePendingSoftwareInstallsForPolicy(t *testing.T, ds *Datastore) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
InstallScript: "install",
|
InstallScript: "install",
|
||||||
InstallerFile: tfr0,
|
InstallerFile: tfr0,
|
||||||
StorageID: ins0,
|
StorageID: ins0,
|
||||||
Filename: "installer.pkg",
|
Filename: "installer.pkg",
|
||||||
Title: "ins1",
|
Title: "ins1",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -1292,27 +1317,29 @@ func testGetHostLastInstallData(t *testing.T, ds *Datastore) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
softwareInstallerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
softwareInstallerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
InstallScript: "install",
|
InstallScript: "install",
|
||||||
InstallerFile: tfr0,
|
InstallerFile: tfr0,
|
||||||
StorageID: ins0,
|
StorageID: ins0,
|
||||||
Filename: "installer.pkg",
|
Filename: "installer.pkg",
|
||||||
Title: "ins1",
|
Title: "ins1",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
softwareInstallerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
softwareInstallerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
InstallScript: "install2",
|
InstallScript: "install2",
|
||||||
InstallerFile: tfr0,
|
InstallerFile: tfr0,
|
||||||
StorageID: ins0,
|
StorageID: ins0,
|
||||||
Filename: "installer2.pkg",
|
Filename: "installer2.pkg",
|
||||||
Title: "ins2",
|
Title: "ins2",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
Platform: "darwin",
|
Platform: "darwin",
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
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},
|
{"ListHostSoftwareInstallThenTransferTeam", testListHostSoftwareInstallThenTransferTeam},
|
||||||
{"ListHostSoftwareInstallThenDeleteInstallers", testListHostSoftwareInstallThenDeleteInstallers},
|
{"ListHostSoftwareInstallThenDeleteInstallers", testListHostSoftwareInstallThenDeleteInstallers},
|
||||||
{"ListSoftwareVersionsVulnerabilityFilters", testListSoftwareVersionsVulnerabilityFilters},
|
{"ListSoftwareVersionsVulnerabilityFilters", testListSoftwareVersionsVulnerabilityFilters},
|
||||||
|
{"TestListHostSoftwareWithLabelScoping", testListHostSoftwareWithLabelScoping},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
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)
|
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
installerTm1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installerTm1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
InstallScript: "hello",
|
InstallScript: "hello",
|
||||||
InstallerFile: tfr1,
|
InstallerFile: tfr1,
|
||||||
StorageID: "storage1",
|
StorageID: "storage1",
|
||||||
Filename: "file1",
|
Filename: "file1",
|
||||||
Title: "file1",
|
Title: "file1",
|
||||||
Version: "1.0",
|
Version: "1.0",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -4727,15 +4729,16 @@ func testListHostSoftwareInstallThenDeleteInstallers(t *testing.T, ds *Datastore
|
||||||
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
installerTm1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installerTm1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
InstallScript: "hello",
|
InstallScript: "hello",
|
||||||
InstallerFile: tfr1,
|
InstallerFile: tfr1,
|
||||||
StorageID: "storage1",
|
StorageID: "storage1",
|
||||||
Filename: "file1",
|
Filename: "file1",
|
||||||
Title: "file1",
|
Title: "file1",
|
||||||
Version: "1.0",
|
Version: "1.0",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
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
|
// create a software installer not installed on any host
|
||||||
installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
Title: "installer1",
|
Title: "installer1",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
InstallScript: "echo",
|
InstallScript: "echo",
|
||||||
Filename: "installer1.pkg",
|
Filename: "installer1.pkg",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotZero(t, installer1)
|
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
|
// create a software installer with an install request on host1
|
||||||
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
Title: "installer2",
|
Title: "installer2",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
InstallScript: "echo",
|
InstallScript: "echo",
|
||||||
Filename: "installer2.pkg",
|
Filename: "installer2.pkg",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2, false, nil)
|
_, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2, false, nil)
|
||||||
|
|
@ -639,6 +641,7 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) {
|
||||||
BundleIdentifier: "foo.bar",
|
BundleIdentifier: "foo.bar",
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotZero(t, installer1)
|
require.NotZero(t, installer1)
|
||||||
|
|
@ -649,12 +652,13 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) {
|
||||||
})
|
})
|
||||||
// create a software installer for team2
|
// create a software installer for team2
|
||||||
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
Title: "installer2",
|
Title: "installer2",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
InstallScript: "echo",
|
InstallScript: "echo",
|
||||||
Filename: "installer2.pkg",
|
Filename: "installer2.pkg",
|
||||||
TeamID: &team2.ID,
|
TeamID: &team2.ID,
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotZero(t, installer2)
|
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
|
// create a couple software installers not installed on any host
|
||||||
installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
Title: "installer1",
|
Title: "installer1",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
InstallScript: "echo",
|
InstallScript: "echo",
|
||||||
Filename: "installer1.pkg",
|
Filename: "installer1.pkg",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotZero(t, installer1)
|
require.NotZero(t, installer1)
|
||||||
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
Title: "installer2",
|
Title: "installer2",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
InstallScript: "echo",
|
InstallScript: "echo",
|
||||||
Filename: "installer2.pkg",
|
Filename: "installer2.pkg",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotZero(t, installer2)
|
require.NotZero(t, installer2)
|
||||||
|
|
@ -981,20 +987,22 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore
|
||||||
|
|
||||||
// create 2 software installers
|
// create 2 software installers
|
||||||
installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
Title: "installer1",
|
Title: "installer1",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
InstallScript: "echo",
|
InstallScript: "echo",
|
||||||
Filename: "installer1.pkg",
|
Filename: "installer1.pkg",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotZero(t, installer1)
|
require.NotZero(t, installer1)
|
||||||
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
Title: "installer2",
|
Title: "installer2",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
InstallScript: "echo",
|
InstallScript: "echo",
|
||||||
Filename: "installer2.pkg",
|
Filename: "installer2.pkg",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotZero(t, installer2)
|
require.NotZero(t, installer2)
|
||||||
|
|
@ -1178,6 +1186,7 @@ func testListSoftwareTitlesAllTeams(t *testing.T, ds *Datastore) {
|
||||||
Filename: "foobar.pkg",
|
Filename: "foobar.pkg",
|
||||||
TeamID: nil,
|
TeamID: nil,
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -1361,6 +1370,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) {
|
||||||
Filename: "installer1.pkg",
|
Filename: "installer1.pkg",
|
||||||
BundleIdentifier: "com.foo.installer1",
|
BundleIdentifier: "com.foo.installer1",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotZero(t, installer1)
|
require.NotZero(t, installer1)
|
||||||
|
|
@ -1372,6 +1382,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) {
|
||||||
TeamID: &tm.ID,
|
TeamID: &tm.ID,
|
||||||
BundleIdentifier: "com.foo.installer2",
|
BundleIdentifier: "com.foo.installer2",
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotZero(t, installer2)
|
require.NotZero(t, installer2)
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,14 @@ func connectMySQL(t testing.TB, testName string, opts *DatastoreTestOptions) *Da
|
||||||
tc := config.TestConfig()
|
tc := config.TestConfig()
|
||||||
tc.Osquery.MinSoftwareLastOpenedAtDiff = defaultMinLastOpenedAtDiff
|
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:
|
// set SQL mode to ANSI, as it's a special mode equivalent to:
|
||||||
// REAL_AS_FLOAT, PIPES_AS_CONCAT, ANSI_QUOTES, IGNORE_SPACE, and
|
// REAL_AS_FLOAT, PIPES_AS_CONCAT, ANSI_QUOTES, IGNORE_SPACE, and
|
||||||
// ONLY_FULL_GROUP_BY
|
// ONLY_FULL_GROUP_BY
|
||||||
|
|
@ -76,7 +84,7 @@ func connectMySQL(t testing.TB, testName string, opts *DatastoreTestOptions) *Da
|
||||||
// standard SQL.
|
// standard SQL.
|
||||||
//
|
//
|
||||||
// https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_ansi
|
// 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)
|
require.Nil(t, err)
|
||||||
|
|
||||||
if opts.DummyReplica {
|
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 {
|
type ActivityTypeAddedSoftware struct {
|
||||||
SoftwareTitle string `json:"software_title"`
|
SoftwareTitle string `json:"software_title"`
|
||||||
SoftwarePackage string `json:"software_package"`
|
SoftwarePackage string `json:"software_package"`
|
||||||
TeamName *string `json:"team_name"`
|
TeamName *string `json:"team_name"`
|
||||||
TeamID *uint `json:"team_id"`
|
TeamID *uint `json:"team_id"`
|
||||||
SelfService bool `json:"self_service"`
|
SelfService bool `json:"self_service"`
|
||||||
SoftwareTitleID uint `json:"software_title_id"`
|
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 {
|
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_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.
|
- "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.
|
- "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_title": "Falcon.app",
|
||||||
"software_package": "FalconSensor-6.44.pkg",
|
"software_package": "FalconSensor-6.44.pkg",
|
||||||
"team_name": "Workstations",
|
"team_name": "Workstations",
|
||||||
"team_id": 123,
|
"team_id": 123,
|
||||||
"self_service": true,
|
"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 {
|
type ActivityTypeEditedSoftware struct {
|
||||||
SoftwareTitle string `json:"software_title"`
|
SoftwareTitle string `json:"software_title"`
|
||||||
SoftwarePackage *string `json:"software_package"`
|
SoftwarePackage *string `json:"software_package"`
|
||||||
TeamName *string `json:"team_name"`
|
TeamName *string `json:"team_name"`
|
||||||
TeamID *uint `json:"team_id"`
|
TeamID *uint `json:"team_id"`
|
||||||
SelfService bool `json:"self_service"`
|
SelfService bool `json:"self_service"`
|
||||||
|
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
|
||||||
|
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a ActivityTypeEditedSoftware) ActivityName() string {
|
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).
|
- "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_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.
|
- "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_title": "Falcon.app",
|
||||||
"software_package": "FalconSensor-6.44.pkg",
|
"software_package": "FalconSensor-6.44.pkg",
|
||||||
"team_name": "Workstations",
|
"team_name": "Workstations",
|
||||||
"team_id": 123,
|
"team_id": 123,
|
||||||
"self_service": true
|
"self_service": true,
|
||||||
|
"labels_include_any": [
|
||||||
|
{
|
||||||
|
"name": "Engineering",
|
||||||
|
"id": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Product",
|
||||||
|
"id": 17
|
||||||
|
}
|
||||||
|
]
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActivityTypeDeletedSoftware struct {
|
type ActivityTypeDeletedSoftware struct {
|
||||||
SoftwareTitle string `json:"software_title"`
|
SoftwareTitle string `json:"software_title"`
|
||||||
SoftwarePackage string `json:"software_package"`
|
SoftwarePackage string `json:"software_package"`
|
||||||
TeamName *string `json:"team_name"`
|
TeamName *string `json:"team_name"`
|
||||||
TeamID *uint `json:"team_id"`
|
TeamID *uint `json:"team_id"`
|
||||||
SelfService bool `json:"self_service"`
|
SelfService bool `json:"self_service"`
|
||||||
|
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
|
||||||
|
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a ActivityTypeDeletedSoftware) ActivityName() string {
|
func (a ActivityTypeDeletedSoftware) ActivityName() string {
|
||||||
|
|
@ -1738,12 +1773,24 @@ func (a ActivityTypeDeletedSoftware) Documentation() (string, string, string) {
|
||||||
- "software_package": Filename of the installer.
|
- "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_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.
|
- "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_title": "Falcon.app",
|
||||||
"software_package": "FalconSensor-6.44.pkg",
|
"software_package": "FalconSensor-6.44.pkg",
|
||||||
"team_name": "Workstations",
|
"team_name": "Workstations",
|
||||||
"team_id": 123,
|
"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)
|
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
|
// SetHostSoftwareInstallResult records the result of a software installation
|
||||||
// attempt on the host.
|
// attempt on the host.
|
||||||
SetHostSoftwareInstallResult(ctx context.Context, result *HostSoftwareInstallResultPayload) error
|
SetHostSoftwareInstallResult(ctx context.Context, result *HostSoftwareInstallResultPayload) error
|
||||||
|
|
|
||||||
|
|
@ -194,3 +194,30 @@ func DetectMissingLabels(validLabelMap map[string]uint, unvalidatedLabels []stri
|
||||||
|
|
||||||
return missingLabels
|
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 {
|
type SoftwareInstallerPayload struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
PreInstallQuery string `json:"pre_install_query"`
|
PreInstallQuery string `json:"pre_install_query"`
|
||||||
InstallScript string `json:"install_script"`
|
InstallScript string `json:"install_script"`
|
||||||
UninstallScript string `json:"uninstall_script"`
|
UninstallScript string `json:"uninstall_script"`
|
||||||
PostInstallScript string `json:"post_install_script"`
|
PostInstallScript string `json:"post_install_script"`
|
||||||
SelfService bool `json:"self_service"`
|
SelfService bool `json:"self_service"`
|
||||||
FleetMaintained bool `json:"-"`
|
FleetMaintained bool `json:"-"`
|
||||||
Filename string `json:"-"`
|
Filename string `json:"-"`
|
||||||
InstallDuringSetup *bool `json:"install_during_setup"` // if nil, do not change saved value, otherwise set it
|
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 {
|
type HostLockWipeStatus struct {
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,11 @@ type Service interface {
|
||||||
// ListHostsInLabel returns a slice of hosts in the label with the given ID.
|
// ListHostsInLabel returns a slice of hosts in the label with the given ID.
|
||||||
ListHostsInLabel(ctx context.Context, lid uint, opt HostListOptions) ([]*Host, error)
|
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
|
// QueryService
|
||||||
|
|
||||||
|
|
@ -649,7 +654,7 @@ type Service interface {
|
||||||
|
|
||||||
// BatchSetSoftwareInstallers asynchronously replaces the software installers for a specified team.
|
// 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).
|
// 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.
|
// GetBatchSetSoftwareInstallersResult polls for the status of a batch-apply started by BatchSetSoftwareInstallers.
|
||||||
// Return values:
|
// Return values:
|
||||||
// - 'status': status of the batch-apply which can be "processing", "completed" or "failed".
|
// - 'status': status of the batch-apply which can be "processing", "completed" or "failed".
|
||||||
|
|
@ -1163,7 +1168,7 @@ type Service interface {
|
||||||
// Fleet-maintained apps
|
// Fleet-maintained apps
|
||||||
|
|
||||||
// AddFleetMaintainedApp adds a Fleet-maintained app to the given team.
|
// 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 lists Fleet-maintained apps available to a specific team
|
||||||
ListFleetMaintainedApps(ctx context.Context, teamID *uint, opts ListOptions) ([]MaintainedApp, *PaginationMetadata, error)
|
ListFleetMaintainedApps(ctx context.Context, teamID *uint, opts ListOptions) ([]MaintainedApp, *PaginationMetadata, error)
|
||||||
// GetFleetMaintainedApp returns a Fleet-maintained app by ID
|
// GetFleetMaintainedApp returns a Fleet-maintained app by ID
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,10 @@ type SoftwareInstaller struct {
|
||||||
// AutomaticInstallPolicies is the list of policies that trigger automatic
|
// AutomaticInstallPolicies is the list of policies that trigger automatic
|
||||||
// installation of this software.
|
// installation of this software.
|
||||||
AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies" db:"-"`
|
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.
|
// SoftwarePackageResponse is the response type used when applying software by batch.
|
||||||
|
|
@ -331,7 +335,12 @@ type UploadSoftwareInstallerPayload struct {
|
||||||
PackageIDs []string
|
PackageIDs []string
|
||||||
UninstallScript string
|
UninstallScript string
|
||||||
Extension 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 {
|
type UpdateSoftwareInstallerPayload struct {
|
||||||
|
|
@ -356,6 +365,11 @@ type UpdateSoftwareInstallerPayload struct {
|
||||||
Filename string
|
Filename string
|
||||||
Version string
|
Version string
|
||||||
PackageIDs []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.
|
// DownloadSoftwareInstallerPayload is the payload for downloading a software installer.
|
||||||
|
|
@ -452,6 +466,8 @@ type SoftwarePackageSpec struct {
|
||||||
InstallScript TeamSpecSoftwareAsset `json:"install_script"`
|
InstallScript TeamSpecSoftwareAsset `json:"install_script"`
|
||||||
PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"`
|
PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"`
|
||||||
UninstallScript TeamSpecSoftwareAsset `json:"uninstall_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
|
// ReferencedYamlPath is the resolved path of the file used to fill the
|
||||||
// software package. Only present after parsing a GitOps file on the fleetctl
|
// 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
|
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 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 SetHostSoftwareInstallResultFunc func(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error
|
||||||
|
|
||||||
type UploadedSoftwareExistsFunc func(ctx context.Context, bundleIdentifier string, teamID *uint) (bool, error)
|
type UploadedSoftwareExistsFunc func(ctx context.Context, bundleIdentifier string, teamID *uint) (bool, error)
|
||||||
|
|
@ -1827,6 +1829,9 @@ type DataStore struct {
|
||||||
ListHostSoftwareFunc ListHostSoftwareFunc
|
ListHostSoftwareFunc ListHostSoftwareFunc
|
||||||
ListHostSoftwareFuncInvoked bool
|
ListHostSoftwareFuncInvoked bool
|
||||||
|
|
||||||
|
IsSoftwareInstallerLabelScopedFunc IsSoftwareInstallerLabelScopedFunc
|
||||||
|
IsSoftwareInstallerLabelScopedFuncInvoked bool
|
||||||
|
|
||||||
SetHostSoftwareInstallResultFunc SetHostSoftwareInstallResultFunc
|
SetHostSoftwareInstallResultFunc SetHostSoftwareInstallResultFunc
|
||||||
SetHostSoftwareInstallResultFuncInvoked bool
|
SetHostSoftwareInstallResultFuncInvoked bool
|
||||||
|
|
||||||
|
|
@ -4430,6 +4435,13 @@ func (s *DataStore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts
|
||||||
return s.ListHostSoftwareFunc(ctx, 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 {
|
func (s *DataStore) SetHostSoftwareInstallResult(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.SetHostSoftwareInstallResultFuncInvoked = true
|
s.SetHostSoftwareInstallResultFuncInvoked = true
|
||||||
|
|
|
||||||
|
|
@ -1007,6 +1007,8 @@ func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuri
|
||||||
PostInstallScript: string(pc),
|
PostInstallScript: string(pc),
|
||||||
UninstallScript: string(us),
|
UninstallScript: string(us),
|
||||||
InstallDuringSetup: installDuringSetup,
|
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
|
// Fleet-maintained apps
|
||||||
ue.POST("/api/_version_/fleet/software/fleet_maintained_apps", addFleetMaintainedAppEndpoint, addFleetMaintainedAppRequest{})
|
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", listFleetMaintainedAppsEndpoint, listFleetMaintainedAppsRequest{})
|
||||||
ue.GET("/api/_version_/fleet/software/fleet_maintained_apps/{app_id}", getFleetMaintainedApp, getFleetMaintainedAppRequest{})
|
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)
|
tfr1, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
sw1, _, err := s.ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
sw1, _, err := s.ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
|
||||||
InstallScript: "install foo",
|
InstallScript: "install foo",
|
||||||
InstallerFile: tfr1,
|
InstallerFile: tfr1,
|
||||||
StorageID: uuid.NewString(),
|
StorageID: uuid.NewString(),
|
||||||
Filename: "foo.pkg",
|
Filename: "foo.pkg",
|
||||||
Title: "foo",
|
Title: "foo",
|
||||||
Source: "apps",
|
Source: "apps",
|
||||||
Version: "0.0.1",
|
Version: "0.0.1",
|
||||||
UserID: adminUser.ID,
|
UserID: adminUser.ID,
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
s1Meta, err := s.ds.GetSoftwareInstallerMetadataByID(ctx, sw1)
|
s1Meta, err := s.ds.GetSoftwareInstallerMetadataByID(ctx, sw1)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -10461,6 +10462,69 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
|
||||||
require.Equal(t, payload.Filename, getDeviceSw.Software[0].SoftwarePackage.Name)
|
require.Equal(t, payload.Filename, getDeviceSw.Software[0].SoftwarePackage.Name)
|
||||||
require.Equal(t, payload.Version, getDeviceSw.Software[0].SoftwarePackage.Version)
|
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
|
// request installation on the host
|
||||||
var installResp installSoftwareResponse
|
var installResp installSoftwareResponse
|
||||||
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install",
|
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.Equal(t, checkSoftwareTitle(t, payload.Title, "deb_packages"), *meta.TitleID)
|
||||||
require.NotZero(t, meta.UploadedAt)
|
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
|
return meta.InstallerID, *meta.TitleID
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("upload no team software installer", func(t *testing.T) {
|
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{
|
payload := &fleet.UploadSoftwareInstallerPayload{
|
||||||
InstallScript: "some install script",
|
InstallScript: "some install script",
|
||||||
PreInstallQuery: "some pre install query",
|
PreInstallQuery: "some pre install query",
|
||||||
PostInstallScript: "some post install script",
|
PostInstallScript: "some post install script",
|
||||||
Filename: "ruby.deb",
|
Filename: "ruby.deb",
|
||||||
// additional fields below are pre-populated so we can re-use the payload later for the test assertions
|
// additional fields below are pre-populated so we can re-use the payload later for the test assertions
|
||||||
Title: "ruby",
|
Title: "ruby",
|
||||||
Version: "1:2.5.1",
|
Version: "1:2.5.1",
|
||||||
Source: "deb_packages",
|
Source: "deb_packages",
|
||||||
StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
|
StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
|
||||||
Platform: "linux",
|
Platform: "linux",
|
||||||
|
LabelsIncludeAny: []string{t.Name()},
|
||||||
}
|
}
|
||||||
|
|
||||||
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
|
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
|
||||||
|
|
@ -10676,12 +10782,75 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
|
||||||
_, titleID := checkSoftwareInstaller(t, payload)
|
_, titleID := checkSoftwareInstaller(t, payload)
|
||||||
|
|
||||||
// check activity
|
// 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)
|
activityData := fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null,
|
||||||
s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0)
|
"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
|
// upload again fails
|
||||||
s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already exists")
|
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
|
// orbit-downloading fails with invalid orbit node key
|
||||||
s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
|
s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
|
||||||
InstallerID: 123,
|
InstallerID: 123,
|
||||||
|
|
@ -10696,6 +10865,10 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
|
||||||
|
|
||||||
// delete from team 0 succeeds
|
// 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")
|
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) {
|
t.Run("create team software installer", func(t *testing.T) {
|
||||||
|
|
@ -10912,7 +11085,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
|
||||||
|
|
||||||
// check activity
|
// check activity
|
||||||
s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(),
|
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
|
// 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))
|
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() {
|
func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
|
||||||
t := s.T()
|
t := s.T()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
// non-existent team
|
// non-existent team
|
||||||
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{}, http.StatusNotFound, "team_name", "foo")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// software with a bad URL
|
// software with a bad URL
|
||||||
softwareToInstall := []fleet.SoftwareInstallerPayload{
|
softwareToInstall := []*fleet.SoftwareInstallerPayload{
|
||||||
{URL: "."},
|
{URL: "."},
|
||||||
}
|
}
|
||||||
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", tm.Name)
|
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", tm.Name)
|
||||||
|
|
||||||
// software with a too big URL
|
// software with a too big URL
|
||||||
softwareToInstall = []fleet.SoftwareInstallerPayload{
|
softwareToInstall = []*fleet.SoftwareInstallerPayload{
|
||||||
{URL: "https://ftp.mozilla.org/" + strings.Repeat("a", 4000-23)},
|
{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)
|
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)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
// do a request with a URL that returns a 404.
|
// do a request with a URL that returns a 404.
|
||||||
softwareToInstall = []fleet.SoftwareInstallerPayload{
|
softwareToInstall = []*fleet.SoftwareInstallerPayload{
|
||||||
{URL: srv.URL + "/not_found.pkg"},
|
{URL: srv.URL + "/not_found.pkg"},
|
||||||
}
|
}
|
||||||
var batchResponse batchSetSoftwareInstallersResponse
|
var batchResponse batchSetSoftwareInstallersResponse
|
||||||
|
|
@ -11295,7 +11469,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
|
||||||
|
|
||||||
// do a request with a valid URL
|
// do a request with a valid URL
|
||||||
rubyURL := srv.URL + "/ruby.deb"
|
rubyURL := srv.URL + "/ruby.deb"
|
||||||
softwareToInstall = []fleet.SoftwareInstallerPayload{
|
softwareToInstall = []*fleet.SoftwareInstallerPayload{
|
||||||
{URL: rubyURL},
|
{URL: rubyURL},
|
||||||
}
|
}
|
||||||
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
|
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.NotNil(t, packages[0].TeamID)
|
||||||
require.Equal(t, tm.ID, *packages[0].TeamID)
|
require.Equal(t, tm.ID, *packages[0].TeamID)
|
||||||
|
|
||||||
softwareToInstallBadSecret := []fleet.SoftwareInstallerPayload{
|
softwareToInstallBadSecret := []*fleet.SoftwareInstallerPayload{
|
||||||
{
|
{
|
||||||
URL: rubyURL,
|
URL: rubyURL,
|
||||||
InstallScript: "echo $FLEET_SECRET_INVALID",
|
InstallScript: "echo $FLEET_SECRET_INVALID",
|
||||||
|
|
@ -11379,7 +11553,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
|
||||||
require.Equal(t, titlesResp, newTitlesResp)
|
require.Equal(t, titlesResp, newTitlesResp)
|
||||||
|
|
||||||
// empty payload cleans the software items
|
// 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)
|
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)
|
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
|
||||||
require.Empty(t, packages)
|
require.Empty(t, packages)
|
||||||
|
|
@ -11392,7 +11566,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
|
||||||
//////////////////////////
|
//////////////////////////
|
||||||
// Do a request with a valid URL with no team
|
// Do a request with a valid URL with no team
|
||||||
//////////////////////////
|
//////////////////////////
|
||||||
softwareToInstall = []fleet.SoftwareInstallerPayload{
|
softwareToInstall = []*fleet.SoftwareInstallerPayload{
|
||||||
{URL: rubyURL},
|
{URL: rubyURL},
|
||||||
}
|
}
|
||||||
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
|
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)
|
titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = ptr.Bool(true)
|
||||||
require.Equal(t, titlesResp, newTitlesResp)
|
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
|
// 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)
|
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
|
||||||
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
|
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
|
||||||
require.Empty(t, packages)
|
require.Empty(t, packages)
|
||||||
|
|
@ -11505,7 +11716,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersSideEffec
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
// set up software to install
|
// set up software to install
|
||||||
softwareToInstall := []fleet.SoftwareInstallerPayload{
|
softwareToInstall := []*fleet.SoftwareInstallerPayload{
|
||||||
{URL: srv.URL},
|
{URL: srv.URL},
|
||||||
}
|
}
|
||||||
var batchResponse batchSetSoftwareInstallersResponse
|
var batchResponse batchSetSoftwareInstallersResponse
|
||||||
|
|
@ -11593,7 +11804,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersSideEffec
|
||||||
require.Equal(t, fleet.SoftwareInstallPending, *getHostSoftwareResp.Software[0].Status)
|
require.Equal(t, fleet.SoftwareInstallPending, *getHostSoftwareResp.Software[0].Status)
|
||||||
|
|
||||||
// update pre-install query
|
// update pre-install query
|
||||||
withUpdatedPreinstallQuery := []fleet.SoftwareInstallerPayload{
|
withUpdatedPreinstallQuery := []*fleet.SoftwareInstallerPayload{
|
||||||
{URL: srv.URL, PreInstallQuery: "SELECT * FROM os_version"},
|
{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)
|
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)
|
require.Equal(t, fleet.SoftwareInstalled, *hostResp.Software[0].Status)
|
||||||
|
|
||||||
// update install script
|
// update install script
|
||||||
withUpdatedInstallScript := []fleet.SoftwareInstallerPayload{
|
withUpdatedInstallScript := []*fleet.SoftwareInstallerPayload{
|
||||||
{URL: srv.URL, InstallScript: "apt install ruby"},
|
{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)
|
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)
|
require.Equal(t, fleet.SoftwareUninstallPending, *afterPreinstallHostResp.Software[0].Status)
|
||||||
|
|
||||||
// delete all installers
|
// 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)
|
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
|
||||||
require.Len(t, packages, 0)
|
require.Len(t, packages, 0)
|
||||||
|
|
||||||
|
|
@ -11766,7 +11977,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersWithPolic
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
// team1 has ruby.deb
|
// team1 has ruby.deb
|
||||||
softwareToInstall := []fleet.SoftwareInstallerPayload{
|
softwareToInstall := []*fleet.SoftwareInstallerPayload{
|
||||||
{
|
{
|
||||||
URL: srv.URL + "/ruby.deb",
|
URL: srv.URL + "/ruby.deb",
|
||||||
},
|
},
|
||||||
|
|
@ -11781,7 +11992,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersWithPolic
|
||||||
require.Equal(t, srv.URL+"/ruby.deb", packages[0].URL)
|
require.Equal(t, srv.URL+"/ruby.deb", packages[0].URL)
|
||||||
|
|
||||||
// team2 has dummy_installer.pkg and ruby.deb.
|
// team2 has dummy_installer.pkg and ruby.deb.
|
||||||
softwareToInstall = []fleet.SoftwareInstallerPayload{
|
softwareToInstall = []*fleet.SoftwareInstallerPayload{
|
||||||
{
|
{
|
||||||
URL: srv.URL + "/dummy_installer.pkg",
|
URL: srv.URL + "/dummy_installer.pkg",
|
||||||
},
|
},
|
||||||
|
|
@ -11831,7 +12042,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersWithPolic
|
||||||
}, http.StatusOK, &mtplr)
|
}, http.StatusOK, &mtplr)
|
||||||
|
|
||||||
// Get rid of all installers in team1.
|
// 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)
|
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)
|
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team1.Name, batchResponse.RequestUUID)
|
||||||
require.Len(t, packages, 0)
|
require.Len(t, packages, 0)
|
||||||
|
|
@ -12389,6 +12600,17 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
|
||||||
token := "secret_token"
|
token := "secret_token"
|
||||||
createDeviceTokenForHost(t, s.ds, host1.ID, 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{
|
payloadNoSS := &fleet.UploadSoftwareInstallerPayload{
|
||||||
PreInstallQuery: "SELECT 1",
|
PreInstallQuery: "SELECT 1",
|
||||||
InstallScript: "install",
|
InstallScript: "install",
|
||||||
|
|
@ -12407,6 +12629,7 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
|
||||||
Filename: "emacs.deb",
|
Filename: "emacs.deb",
|
||||||
Title: "emacs",
|
Title: "emacs",
|
||||||
SelfService: true,
|
SelfService: true,
|
||||||
|
LabelsIncludeAny: []string{labelResp.Label.Name},
|
||||||
}
|
}
|
||||||
s.uploadSoftwareInstaller(t, payloadSS, http.StatusOK, "")
|
s.uploadSoftwareInstaller(t, payloadSS, http.StatusOK, "")
|
||||||
titleIDSS := getSoftwareTitleID(t, s.ds, payloadSS.Title, "deb_packages")
|
titleIDSS := getSoftwareTitleID(t, s.ds, payloadSS.Title, "deb_packages")
|
||||||
|
|
@ -12416,7 +12639,23 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
|
||||||
errMsg := extractServerErrorText(res.Body)
|
errMsg := extractServerErrorText(res.Body)
|
||||||
require.Contains(t, errMsg, "Software title is not available through self-service")
|
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)
|
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
|
// 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)
|
require.Nil(t, listUpcomingAct.Activities[0].ActorID)
|
||||||
|
|
||||||
var details fleet.ActivityTypeInstalledSoftware
|
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.NoError(t, err)
|
||||||
require.Equal(t, host1.ID, details.HostID)
|
require.Equal(t, host1.ID, details.HostID)
|
||||||
require.Equal(t, details.SoftwareTitle, payloadSS.Title)
|
require.Equal(t, details.SoftwareTitle, payloadSS.Title)
|
||||||
|
|
@ -14814,6 +15053,184 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
|
||||||
require.Nil(t, hostVanillaOsquery5Team1LastInstall)
|
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() {
|
func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsScripts() {
|
||||||
t := s.T()
|
t := s.T()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -15695,6 +16112,83 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
|
||||||
require.NotNil(t, policies[0].InstallSoftware)
|
require.NotNil(t, policies[0].InstallSoftware)
|
||||||
require.Equal(t, tpResp.Policy.InstallSoftware.Name, policies[0].InstallSoftware.Name)
|
require.Equal(t, tpResp.Policy.InstallSoftware.Name, policies[0].InstallSoftware.Name)
|
||||||
require.Equal(t, tpResp.Policy.InstallSoftware.SoftwareTitleID, policies[0].InstallSoftware.SoftwareTitleID)
|
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() {
|
func (s *integrationEnterpriseTestSuite) TestWindowsMigrateMDMNotEnabled() {
|
||||||
|
|
@ -15706,3 +16200,158 @@ func (s *integrationEnterpriseTestSuite) TestWindowsMigrateMDMNotEnabled() {
|
||||||
errMsg := extractServerErrorText(res.Body)
|
errMsg := extractServerErrorText(res.Body)
|
||||||
require.Contains(t, errMsg, "Windows MDM is not enabled")
|
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)
|
writer := multipart.NewWriter(&body)
|
||||||
|
|
||||||
// add file content
|
// add file content
|
||||||
ff, err := writer.CreateFormFile(uploadFileField, fileName)
|
if fileName != "" || len(fileContent) > 0 {
|
||||||
require.NoError(t, err)
|
ff, err := writer.CreateFormFile(uploadFileField, fileName)
|
||||||
_, err = io.Copy(ff, bytes.NewReader(fileContent))
|
require.NoError(t, err)
|
||||||
require.NoError(t, err)
|
_, err = io.Copy(ff, bytes.NewReader(fileContent))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
// add extra fields
|
// add extra fields
|
||||||
for key, values := range extraFields {
|
for key, values := range extraFields {
|
||||||
|
|
@ -4852,7 +4854,7 @@ func generateMultipartRequest(t *testing.T,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = writer.Close()
|
err := writer.Close()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
headers := map[string]string{
|
headers := map[string]string{
|
||||||
|
|
@ -12179,6 +12181,7 @@ func (s *integrationMDMTestSuite) TestSetupExperience() {
|
||||||
UserID: user1.ID,
|
UserID: user1.ID,
|
||||||
TeamID: &team1.ID,
|
TeamID: &team1.ID,
|
||||||
Platform: string(fleet.MacOSPlatform),
|
Platform: string(fleet.MacOSPlatform),
|
||||||
|
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||||
})
|
})
|
||||||
_ = installerID1
|
_ = installerID1
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"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/ctxdb"
|
||||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||||
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
"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)
|
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"
|
"testing"
|
||||||
"time"
|
"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/contexts/viewer"
|
||||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||||
|
|
@ -312,3 +313,108 @@ func TestLabelsWithReplica(t *testing.T) {
|
||||||
require.ElementsMatch(t, []uint{h1.ID}, hostIDs)
|
require.ElementsMatch(t, []uint{h1.ID}, hostIDs)
|
||||||
require.Equal(t, 1, lbl.HostCount)
|
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 {
|
type addFleetMaintainedAppRequest struct {
|
||||||
TeamID *uint `json:"team_id"`
|
TeamID *uint `json:"team_id"`
|
||||||
AppID uint `json:"fleet_maintained_app_id"`
|
AppID uint `json:"fleet_maintained_app_id"`
|
||||||
InstallScript string `json:"install_script"`
|
InstallScript string `json:"install_script"`
|
||||||
PreInstallQuery string `json:"pre_install_query"`
|
PreInstallQuery string `json:"pre_install_query"`
|
||||||
PostInstallScript string `json:"post_install_script"`
|
PostInstallScript string `json:"post_install_script"`
|
||||||
SelfService bool `json:"self_service"`
|
SelfService bool `json:"self_service"`
|
||||||
UninstallScript string `json:"uninstall_script"`
|
UninstallScript string `json:"uninstall_script"`
|
||||||
|
LabelsIncludeAny []string `json:"labels_include_any"`
|
||||||
|
LabelsExcludeAny []string `json:"labels_exclude_any"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type addFleetMaintainedAppResponse struct {
|
type addFleetMaintainedAppResponse struct {
|
||||||
|
|
@ -39,6 +41,8 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc
|
||||||
req.PostInstallScript,
|
req.PostInstallScript,
|
||||||
req.UninstallScript,
|
req.UninstallScript,
|
||||||
req.SelfService,
|
req.SelfService,
|
||||||
|
req.LabelsIncludeAny,
|
||||||
|
req.LabelsExcludeAny,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
|
@ -50,7 +54,7 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc
|
||||||
return &addFleetMaintainedAppResponse{SoftwareTitleID: titleId}, nil
|
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
|
// skipauth: No authorization check needed due to implementation returning
|
||||||
// only license error.
|
// only license error.
|
||||||
svc.authz.SkipAuthorization(ctx)
|
svc.authz.SkipAuthorization(ctx)
|
||||||
|
|
@ -58,6 +62,24 @@ func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, app
|
||||||
return 0, fleet.ErrMissingLicense
|
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 {
|
type listFleetMaintainedAppsRequest struct {
|
||||||
fleet.ListOptions
|
fleet.ListOptions
|
||||||
TeamID *uint `query:"team_id,optional"`
|
TeamID *uint `query:"team_id,optional"`
|
||||||
|
|
|
||||||
|
|
@ -1010,6 +1010,8 @@ func (svc *Service) SubmitDistributedQueryResults(
|
||||||
logging.WithErr(ctx, err)
|
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 {
|
if err := svc.processSoftwareForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, host.OrbitNodeKey, policyResults); err != nil {
|
||||||
logging.WithErr(ctx, err)
|
logging.WithErr(ctx, err)
|
||||||
}
|
}
|
||||||
|
|
@ -1795,6 +1797,17 @@ func (svc *Service) processSoftwareForNewlyFailingPolicies(
|
||||||
level.Debug(logger).Log("msg", "installer platform does not match host platform")
|
level.Debug(logger).Log("msg", "installer platform does not match host platform")
|
||||||
continue
|
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)
|
hostLastInstall, err := svc.ds.GetHostLastInstallData(ctx, hostID, installerMetadata.InstallerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ctxerr.Wrap(ctx, err, "get host last install data")
|
return ctxerr.Wrap(ctx, err, "get host last install data")
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ type uploadSoftwareInstallerRequest struct {
|
||||||
PostInstallScript string
|
PostInstallScript string
|
||||||
SelfService bool
|
SelfService bool
|
||||||
UninstallScript string
|
UninstallScript string
|
||||||
|
LabelsIncludeAny []string
|
||||||
|
LabelsExcludeAny []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateSoftwareInstallerRequest struct {
|
type updateSoftwareInstallerRequest struct {
|
||||||
|
|
@ -41,6 +43,8 @@ type updateSoftwareInstallerRequest struct {
|
||||||
PostInstallScript *string
|
PostInstallScript *string
|
||||||
UninstallScript *string
|
UninstallScript *string
|
||||||
SelfService *bool
|
SelfService *bool
|
||||||
|
LabelsIncludeAny []string
|
||||||
|
LabelsExcludeAny []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type uploadSoftwareInstallerResponse struct {
|
type uploadSoftwareInstallerResponse struct {
|
||||||
|
|
@ -131,6 +135,30 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http
|
||||||
decoded.SelfService = &parsed
|
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
|
return &decoded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,6 +173,8 @@ func updateSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s
|
||||||
PostInstallScript: req.PostInstallScript,
|
PostInstallScript: req.PostInstallScript,
|
||||||
UninstallScript: req.UninstallScript,
|
UninstallScript: req.UninstallScript,
|
||||||
SelfService: req.SelfService,
|
SelfService: req.SelfService,
|
||||||
|
LabelsIncludeAny: req.LabelsIncludeAny,
|
||||||
|
LabelsExcludeAny: req.LabelsExcludeAny,
|
||||||
}
|
}
|
||||||
if req.File != nil {
|
if req.File != nil {
|
||||||
ff, err := req.File.Open()
|
ff, err := req.File.Open()
|
||||||
|
|
@ -261,6 +291,31 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http
|
||||||
decoded.SelfService = parsed
|
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
|
return &decoded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,6 +344,8 @@ func uploadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s
|
||||||
Filename: req.File.Filename,
|
Filename: req.File.Filename,
|
||||||
SelfService: req.SelfService,
|
SelfService: req.SelfService,
|
||||||
UninstallScript: req.UninstallScript,
|
UninstallScript: req.UninstallScript,
|
||||||
|
LabelsIncludeAny: req.LabelsIncludeAny,
|
||||||
|
LabelsExcludeAny: req.LabelsExcludeAny,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := svc.UploadSoftwareInstaller(ctx, payload); err != nil {
|
if err := svc.UploadSoftwareInstaller(ctx, payload); err != nil {
|
||||||
|
|
@ -549,9 +606,9 @@ func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID st
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
type batchSetSoftwareInstallersRequest struct {
|
type batchSetSoftwareInstallersRequest struct {
|
||||||
TeamName string `json:"-" query:"team_name,optional"`
|
TeamName string `json:"-" query:"team_name,optional"`
|
||||||
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
|
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
|
||||||
Software []fleet.SoftwareInstallerPayload `json:"software"`
|
Software []*fleet.SoftwareInstallerPayload `json:"software"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type batchSetSoftwareInstallersResponse struct {
|
type batchSetSoftwareInstallersResponse struct {
|
||||||
|
|
@ -571,7 +628,7 @@ func batchSetSoftwareInstallersEndpoint(ctx context.Context, request interface{}
|
||||||
return batchSetSoftwareInstallersResponse{RequestUUID: requestUUID}, nil
|
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
|
// skipauth: No authorization check needed due to implementation returning
|
||||||
// only license error.
|
// only license error.
|
||||||
svc.authz.SkipAuthorization(ctx)
|
svc.authz.SkipAuthorization(ctx)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/contexts/viewer"
|
||||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||||
"github.com/fleetdm/fleet/v4/server/mock"
|
"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,
|
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
|
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))
|
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{})
|
lbls, err := ts.ds.ListLabels(ctx, fleet.TeamFilter{}, fleet.ListOptions{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
for _, lbl := range lbls {
|
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).
|
// Clean scripts in "No team" (the others are deleted in ts.ds.DeleteTeam above).
|
||||||
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
||||||
_, err := q.ExecContext(ctx, `DELETE FROM scripts WHERE global_or_team_id = 0;`)
|
_, err := q.ExecContext(ctx, `DELETE FROM scripts WHERE global_or_team_id = 0;`)
|
||||||
|
|
@ -587,6 +587,16 @@ func (ts *withServer) uploadSoftwareInstaller(
|
||||||
if payload.SelfService {
|
if payload.SelfService {
|
||||||
require.NoError(t, w.WriteField("self_service", "true"))
|
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()
|
w.Close()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue