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:
George Karr 2024-12-23 11:38:39 -06:00 committed by GitHub
commit 38fcc30b5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 4528 additions and 646 deletions

View file

@ -0,0 +1 @@
- Added features to scope Fleet-maintained apps and custom packages via labels in UI, API, and CLI.

View file

@ -0,0 +1 @@
- Adds functionality for skipping automatic installs if the software is not scoped to the host via labels.

View file

@ -0,0 +1 @@
- Add functionality to filter host software based on label scoping.

View file

@ -0,0 +1 @@
* Added a validation to prevent label deletion if it is used to scope the hosts targeted by a software installer.

View file

@ -0,0 +1 @@
- add UI for scoping software via labels

View file

@ -0,0 +1 @@
* Added `fleetctl gitops` support to scope software installers by labels, with the `labels_include_any` or `labels_exclude_any` conditions.

View file

@ -0,0 +1 @@
* Added the `labels_include_any` and `labels_exclude_any` fields to the software installer activities.

View file

@ -1910,6 +1910,10 @@ func TestGitOpsTeamSofwareInstallers(t *testing.T) {
{"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"},
{"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"},
{"testdata/gitops/team_software_installer_invalid_both_include_exclude.yml", `only one of "labels_exclude_any" or "labels_include_any" can be specified`},
{"testdata/gitops/team_software_installer_valid_include.yml", ""},
{"testdata/gitops/team_software_installer_valid_exclude.yml", ""},
{"testdata/gitops/team_software_installer_invalid_unknown_label.yml", "some or all the labels provided don't exist"},
// team tests for setup experience software/script
{"testdata/gitops/team_setup_software_valid.yml", ""},
{"testdata/gitops/team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"},
@ -1939,6 +1943,22 @@ func TestGitOpsTeamSofwareInstallers(t *testing.T) {
Teams: nil,
}, nil
}
labelToIDs := map[string]uint{
fleet.BuiltinLabelMacOS14Plus: 1,
"a": 2,
"b": 3,
}
ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) {
// for this test, recognize labels a and b (as well as the built-in macos 14+ one)
ret := make(map[string]uint)
for _, lbl := range labels {
id, ok := labelToIDs[lbl]
if ok {
ret[lbl] = id
}
}
return ret, nil
}
_, err = runAppNoChecks([]string{"gitops", "-f", c.file})
if c.wantErr == "" {
@ -1992,6 +2012,10 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) {
{"testdata/gitops/no_team_software_installer_post_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/no_team_software_installer_no_url.yml", "software URL is required"},
{"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"},
{"testdata/gitops/no_team_software_installer_invalid_both_include_exclude.yml", `only one of "labels_exclude_any" or "labels_include_any" can be specified`},
{"testdata/gitops/no_team_software_installer_valid_include.yml", ""},
{"testdata/gitops/no_team_software_installer_valid_exclude.yml", ""},
{"testdata/gitops/no_team_software_installer_invalid_unknown_label.yml", "some or all the labels provided don't exist"},
// No team tests for setup experience software/script
{"testdata/gitops/no_team_setup_software_valid.yml", ""},
{"testdata/gitops/no_team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"},
@ -2021,6 +2045,22 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) {
Teams: nil,
}, nil
}
labelToIDs := map[string]uint{
fleet.BuiltinLabelMacOS14Plus: 1,
"a": 2,
"b": 3,
}
ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) {
// for this test, recognize labels a and b (as well as the built-in macos 14+ one)
ret := make(map[string]uint)
for _, lbl := range labels {
id, ok := labelToIDs[lbl]
if ok {
ret[lbl] = id
}
}
return ret, nil
}
t.Setenv("APPLE_BM_DEFAULT_TEAM", "")
globalFile := "./testdata/gitops/global_config_no_paths.yml"

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -1242,6 +1242,8 @@ This activity contains the following fields:
- "team_id": The ID of the team to which this software was added. `null` if it was added to no team.
- "self_service": Whether the software is available for installation by the end user.
- "software_title_id": ID of the added software title.
- "labels_include_any": Target hosts that have any label in the array.
- "labels_exclude_any": Target hosts that don't have any label in the array.
#### Example
@ -1252,7 +1254,17 @@ This activity contains the following fields:
"team_name": "Workstations",
"team_id": 123,
"self_service": true,
"software_title_id": 2234
"software_title_id": 2234,
"labels_include_any": [
{
"name": "Engineering",
"id": 12
},
{
"name": "Product",
"id": 17
}
]
}
```
@ -1266,6 +1278,8 @@ This activity contains the following fields:
- "team_name": Name of the team on which this software was updated. `null` if it was updated on no team.
- "team_id": The ID of the team on which this software was updated. `null` if it was updated on no team.
- "self_service": Whether the software is available for installation by the end user.
- "labels_include_any": Target hosts that have any label in the array.
- "labels_exclude_any": Target hosts that don't have any label in the array.
#### Example
@ -1275,7 +1289,17 @@ This activity contains the following fields:
"software_package": "FalconSensor-6.44.pkg",
"team_name": "Workstations",
"team_id": 123,
"self_service": true
"self_service": true,
"labels_include_any": [
{
"name": "Engineering",
"id": 12
},
{
"name": "Product",
"id": 17
}
]
}
```
@ -1289,6 +1313,8 @@ This activity contains the following fields:
- "team_name": Name of the team to which this software was added. `null` if it was added to no team.
- "team_id": The ID of the team to which this software was added. `null` if it was added to no team.
- "self_service": Whether the software was available for installation by the end user.
- "labels_include_any": Target hosts that have any label in the array.
- "labels_exclude_any": Target hosts that don't have any label in the array.
#### Example
@ -1298,7 +1324,17 @@ This activity contains the following fields:
"software_package": "FalconSensor-6.44.pkg",
"team_name": "Workstations",
"team_id": 123,
"self_service": true
"self_service": true,
"labels_include_any": [
{
"name": "Engineering",
"id": 12
},
{
"name": "Product",
"id": 17
}
]
}
```

View file

@ -26,6 +26,7 @@ func (svc *Service) AddFleetMaintainedApp(
appID uint,
installScript, preInstallQuery, postInstallScript, uninstallScript string,
selfService bool,
labelsIncludeAny, labelsExcludeAny []string,
) (titleID uint, err error) {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
return 0, err
@ -36,6 +37,12 @@ func (svc *Service) AddFleetMaintainedApp(
return 0, fleet.ErrNoContext
}
// validate labels before we do anything else
validatedLabels, err := ValidateSoftwareLabels(ctx, svc, labelsIncludeAny, labelsExcludeAny)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "validating software labels")
}
if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{installScript, postInstallScript, uninstallScript}); err != nil {
return 0, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error()))
}
@ -119,6 +126,7 @@ func (svc *Service) AddFleetMaintainedApp(
SelfService: selfService,
InstallScript: installScript,
UninstallScript: uninstallScript,
ValidatedLabels: validatedLabels,
}
// Create record in software installers table
@ -142,13 +150,16 @@ func (svc *Service) AddFleetMaintainedApp(
teamName = &t.Name
}
actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels)
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
TeamName: teamName,
TeamID: payload.TeamID,
SelfService: payload.SelfService,
SoftwareTitleID: titleID,
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
TeamName: teamName,
TeamID: payload.TeamID,
SelfService: payload.SelfService,
SoftwareTitleID: titleID,
LabelsIncludeAny: actLabelsIncl,
LabelsExcludeAny: actLabelsExcl,
}); err != nil {
return 0, ctxerr.Wrap(ctx, err, "creating activity for added software")
}

View file

@ -17,12 +17,12 @@ import (
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/authz"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/log"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
@ -37,6 +37,13 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
return err
}
// validate labels before we do anything else
validatedLabels, err := ValidateSoftwareLabels(ctx, svc, payload.LabelsIncludeAny, payload.LabelsExcludeAny)
if err != nil {
return ctxerr.Wrap(ctx, err, "validating software labels")
}
payload.ValidatedLabels = validatedLabels
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
@ -86,13 +93,16 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
}
// Create activity
actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels)
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
TeamName: teamName,
TeamID: payload.TeamID,
SelfService: payload.SelfService,
SoftwareTitleID: titleID,
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
TeamName: teamName,
TeamID: payload.TeamID,
SelfService: payload.SelfService,
SoftwareTitleID: titleID,
LabelsIncludeAny: actLabelsIncl,
LabelsExcludeAny: actLabelsExcl,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for added software")
}
@ -100,6 +110,42 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
return nil
}
func ValidateSoftwareLabels(ctx context.Context, svc fleet.Service, labelsIncludeAny, labelsExcludeAny []string) (*fleet.LabelIdentsWithScope, error) {
if authctx, ok := authz_ctx.FromContext(ctx); !ok {
return nil, fleet.NewAuthRequiredError("validate software labels: missing authorization context")
} else if !authctx.Checked() {
return nil, fleet.NewAuthRequiredError("validate software labels: method requires previous authorization")
}
var names []string
var scope fleet.LabelScope
switch {
case len(labelsIncludeAny) > 0 && len(labelsExcludeAny) > 0:
return nil, &fleet.BadRequestError{Message: `Only one of "labels_include_any" or "labels_exclude_any" can be included.`}
case len(labelsIncludeAny) > 0:
names = labelsIncludeAny
scope = fleet.LabelScopeIncludeAny
case len(labelsExcludeAny) > 0:
names = labelsExcludeAny
scope = fleet.LabelScopeExcludeAny
}
if len(names) == 0 {
// nothing to validate, return empty result
return &fleet.LabelIdentsWithScope{}, nil
}
byName, err := svc.BatchValidateLabels(ctx, names)
if err != nil {
return nil, err
}
return &fleet.LabelIdentsWithScope{
LabelScope: scope,
ByName: byName,
}, nil
}
var packageIDRegex = regexp.MustCompile(`((("\$PACKAGE_ID")|(\$PACKAGE_ID))(?P<suffix>\W|$))|(("\${PACKAGE_ID}")|(\${PACKAGE_ID}))`)
func preProcessUninstallScript(payload *fleet.UploadSoftwareInstallerPayload) {
@ -141,7 +187,7 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
var teamName *string
if *payload.TeamID != 0 {
t, err := svc.ds.Team(ctx, *payload.TeamID)
t, err := svc.ds.TeamWithoutExtras(ctx, *payload.TeamID)
if err != nil {
return nil, err
}
@ -186,7 +232,8 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
}
if payload.SelfService == nil && payload.InstallerFile == nil && payload.PreInstallQuery == nil &&
payload.InstallScript == nil && payload.PostInstallScript == nil && payload.UninstallScript == nil {
payload.InstallScript == nil && payload.PostInstallScript == nil && payload.UninstallScript == nil &&
payload.LabelsIncludeAny == nil && payload.LabelsExcludeAny == nil {
return existingInstaller, nil // no payload, noop
}
@ -197,10 +244,24 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
dirty["SelfService"] = true
}
shouldUpdateLabels, validatedLabels, err := ValidateSoftwareLabelsForUpdate(ctx, svc, existingInstaller, payload.LabelsIncludeAny, payload.LabelsExcludeAny)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating software labels for update")
}
if shouldUpdateLabels {
dirty["Labels"] = true
}
payload.ValidatedLabels = validatedLabels
// activity team ID must be null if no team, not zero
var actTeamID *uint
if payload.TeamID != nil && *payload.TeamID != 0 {
actTeamID = payload.TeamID
}
activity := fleet.ActivityTypeEditedSoftware{
SoftwareTitle: existingInstaller.SoftwareTitle,
TeamName: teamName,
TeamID: payload.TeamID,
TeamID: actTeamID,
SelfService: existingInstaller.SelfService,
SoftwarePackage: &existingInstaller.Name,
}
@ -343,6 +404,18 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
}
}
// now that the payload has been updated with any patches, we can set the
// final fields of the activity
actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromSoftwareScopeLabels(
existingInstaller.LabelsIncludeAny, existingInstaller.LabelsExcludeAny)
if payload.ValidatedLabels != nil {
actLabelsIncl, actLabelsExcl = activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels)
}
activity.LabelsIncludeAny = actLabelsIncl
activity.LabelsExcludeAny = actLabelsExcl
if payload.SelfService != nil {
activity.SelfService = *payload.SelfService
}
if err := svc.NewActivity(ctx, vc.User, activity); err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating activity for edited software")
}
@ -363,6 +436,69 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
return updatedInstaller, nil
}
func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, existingInstaller *fleet.SoftwareInstaller, includeAny, excludeAny []string) (shouldUpdate bool, validatedLabels *fleet.LabelIdentsWithScope, err error) {
if authctx, ok := authz_ctx.FromContext(ctx); !ok {
return false, nil, fleet.NewAuthRequiredError("batch validate labels: missing authorization context")
} else if !authctx.Checked() {
return false, nil, fleet.NewAuthRequiredError("batch validate labels: method requires previous authorization")
}
if existingInstaller == nil {
return false, nil, errors.New("existing installer must be provided")
}
if len(existingInstaller.LabelsIncludeAny) > 0 && len(existingInstaller.LabelsExcludeAny) > 0 {
return false, nil, errors.New("existing installer must have only one label scope")
}
if includeAny == nil && excludeAny == nil {
// nothing to do
return false, nil, nil
}
incoming, err := ValidateSoftwareLabels(ctx, svc, includeAny, excludeAny)
if err != nil {
return false, nil, err
}
var prevScope fleet.LabelScope
var prevLabels []fleet.SoftwareScopeLabel
switch {
case len(existingInstaller.LabelsIncludeAny) > 0:
prevScope = fleet.LabelScopeIncludeAny
prevLabels = existingInstaller.LabelsIncludeAny
case len(existingInstaller.LabelsExcludeAny) > 0:
prevScope = fleet.LabelScopeExcludeAny
prevLabels = existingInstaller.LabelsExcludeAny
}
prevByName := make(map[string]fleet.LabelIdent, len(prevLabels))
for _, pl := range prevLabels {
prevByName[pl.LabelName] = fleet.LabelIdent{
LabelID: pl.LabelID,
LabelName: pl.LabelName,
}
}
if prevScope != incoming.LabelScope {
return true, incoming, nil
}
if len(prevByName) != len(incoming.ByName) {
return true, incoming, nil
}
// compare labels by name
for n, il := range incoming.ByName {
pl, ok := prevByName[n]
if !ok || pl != il {
return true, incoming, nil
}
}
return false, nil, nil
}
func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error {
if teamID == nil {
return fleet.NewInvalidArgumentError("team_id", "is required")
@ -441,20 +577,15 @@ func (svc *Service) deleteSoftwareInstaller(ctx context.Context, meta *fleet.Sof
teamName = &t.Name
}
var teamID *uint
switch {
case meta.TeamID == nil:
teamID = ptr.Uint(0)
case meta.TeamID != nil:
teamID = meta.TeamID
}
actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromSoftwareScopeLabels(meta.LabelsIncludeAny, meta.LabelsExcludeAny)
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedSoftware{
SoftwareTitle: meta.SoftwareTitle,
SoftwarePackage: meta.Name,
TeamName: teamName,
TeamID: teamID,
SelfService: meta.SelfService,
SoftwareTitle: meta.SoftwareTitle,
SoftwarePackage: meta.Name,
TeamName: teamName,
TeamID: meta.TeamID,
SelfService: meta.SelfService,
LabelsIncludeAny: actLabelsIncl,
LabelsExcludeAny: actLabelsExcl,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for deleted software")
}
@ -670,6 +801,18 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
// if we found an installer, use that
if installer != nil {
// check the label scoping for this installer and host
scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, installer.InstallerID, hostID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking label scoping during software install attempt")
}
if !scoped {
return &fleet.BadRequestError{
Message: "Couldn't install. Host isn't member of the labels defined for this software title.",
}
}
lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID)
@ -1141,7 +1284,7 @@ const (
)
func (svc *Service) BatchSetSoftwareInstallers(
ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool,
ctx context.Context, tmName string, payloads []*fleet.SoftwareInstallerPayload, dryRun bool,
) (string, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return "", err
@ -1185,6 +1328,12 @@ func (svc *Service) BatchSetSoftwareInstallers(
fmt.Sprintf("Couldn't edit software. URL (%q) is invalid", payload.URL),
)
}
validatedLabels, err := ValidateSoftwareLabels(ctx, svc, payload.LabelsIncludeAny, payload.LabelsExcludeAny)
if err != nil {
return "", err
}
payload.ValidatedLabels = validatedLabels
allScripts = append(allScripts, payload.InstallScript, payload.PostInstallScript, payload.UninstallScript)
}
@ -1229,7 +1378,7 @@ func (svc *Service) softwareBatchUpload(
requestUUID string,
teamID *uint,
userID uint,
payloads []fleet.SoftwareInstallerPayload,
payloads []*fleet.SoftwareInstallerPayload,
dryRun bool,
) {
var batchErr error
@ -1341,6 +1490,9 @@ func (svc *Service) softwareBatchUpload(
UserID: userID,
URL: p.URL,
InstallDuringSetup: p.InstallDuringSetup,
LabelsIncludeAny: p.LabelsIncludeAny,
LabelsExcludeAny: p.LabelsExcludeAny,
ValidatedLabels: p.ValidatedLabels,
}
// set the filename before adding metadata, as it is used as fallback
@ -1497,6 +1649,17 @@ func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *f
}
}
scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, installer.InstallerID, host.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking label scoping during software install attempt")
}
if !scoped {
return &fleet.BadRequestError{
Message: "Couldn't install. Host isn't member of the labels defined for this software title.",
}
}
ext := filepath.Ext(installer.Name)
requiredPlatform := packageExtensionToPlatform(ext)
if requiredPlatform == "" {
@ -1642,3 +1805,40 @@ func UninstallSoftwareMigration(
return nil
}
func activitySoftwareLabelsFromValidatedLabels(validatedLabels *fleet.LabelIdentsWithScope) (include, exclude []fleet.ActivitySoftwareLabel) {
if validatedLabels == nil || len(validatedLabels.ByName) == 0 {
return nil, nil
}
excludeAny := validatedLabels.LabelScope == fleet.LabelScopeExcludeAny
labels := make([]fleet.ActivitySoftwareLabel, 0, len(validatedLabels.ByName))
for _, lbl := range validatedLabels.ByName {
labels = append(labels, fleet.ActivitySoftwareLabel{
ID: lbl.LabelID,
Name: lbl.LabelName,
})
}
if excludeAny {
exclude = labels
} else {
include = labels
}
return include, exclude
}
func activitySoftwareLabelsFromSoftwareScopeLabels(includeScopeLabels, excludeScopeLabels []fleet.SoftwareScopeLabel) (include, exclude []fleet.ActivitySoftwareLabel) {
for _, label := range includeScopeLabels {
include = append(include, fleet.ActivitySoftwareLabel{
ID: label.LabelID,
Name: label.LabelName,
})
}
for _, label := range excludeScopeLabels {
exclude = append(exclude, fleet.ActivitySoftwareLabel{
ID: label.LabelID,
Name: label.LabelName,
})
}
return include, exclude
}

View file

@ -15,7 +15,7 @@ import (
func TestPreProcessUninstallScript(t *testing.T) {
t.Parallel()
var input = `
input := `
blah$PACKAGE_IDS
pkgids=$PACKAGE_ID
they are $PACKAGE_ID, right $MY_SECRET?
@ -74,7 +74,6 @@ quotes and braces for (
"com.bar"
)`
assert.Equal(t, expected, payload.UninstallScript)
}
func TestInstallUninstallAuth(t *testing.T) {
@ -93,7 +92,8 @@ func TestInstallUninstallAuth(t *testing.T) {
}, nil
}
ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint,
withScriptContents bool) (*fleet.SoftwareInstaller, error) {
withScriptContents bool,
) (*fleet.SoftwareInstaller, error) {
return &fleet.SoftwareInstaller{
Name: "installer.pkg",
Platform: "darwin",
@ -104,14 +104,16 @@ func TestInstallUninstallAuth(t *testing.T) {
return nil, nil
}
ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool, policyID *uint) (string,
error) {
error,
) {
return "request_id", nil
}
ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) {
return []byte("script"), nil
}
ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult,
error) {
error,
) {
return &fleet.HostScriptResult{
ExecutionID: "execution_id",
}, nil
@ -120,6 +122,10 @@ func TestInstallUninstallAuth(t *testing.T) {
return nil
}
ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) {
return true, nil
}
testCases := []struct {
name string
user *fleet.User
@ -197,7 +203,6 @@ func TestUninstallSoftwareTitle(t *testing.T) {
// Host scripts disabled
host.ScriptsEnabled = ptr.Bool(false)
require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptsOrbitDisabledErrMsg)
}
func checkAuthErr(t *testing.T, shouldFail bool, err error) {

View file

@ -200,6 +200,7 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
version: "1.2.3",
uploaded_at: "2020-01-01T00:00:00.000Z",
install_script: "sudo installer -pkg /temp/FalconSensor-6.44.pkg -target /",
uninstall_script: "sudo rm -rf /Applications/Falcon.app",
pre_install_query: "SELECT 1 FROM macos_profiles WHERE uuid='abc123';",
post_install_script:
"sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123",
@ -216,6 +217,8 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
last_install: null,
last_uninstall: null,
package_url: "",
labels_include_any: null,
labels_exclude_any: null,
};
export const createMockSoftwarePackage = (

View file

@ -35,7 +35,7 @@ export const PlatformSelector = ({
return (
<div className={`${parentClass}__${baseClass} ${baseClass} form-field`}>
<span className={labelClasses}>Checks on:</span>
<span className={labelClasses}>Targets:</span>
<span className={`${baseClass}__checkboxes`}>
<Checkbox
value={checkDarwin}
@ -71,7 +71,8 @@ export const PlatformSelector = ({
</Checkbox>
</span>
<div className="form-field__help-text">
Your policy will only be checked on the selected platform(s).
To apply the profile to new hosts, you&apos;ll have to delete it and
upload a new profile.
</div>
</div>
);

View file

@ -11,4 +11,8 @@
.form-field__label--disabled {
color: $ui-fleet-black-50;
}
&__platform-checkbox-wrapper {
width: auto;
}
}

View file

@ -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();
});
});

View 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;

View 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;
}
}

View file

@ -0,0 +1 @@
export { default } from "./TargetLabelSelector";

View file

@ -126,6 +126,7 @@ const Checkbox = (props: ICheckboxProps) => {
/>
<div
role="checkbox"
aria-label={name}
aria-checked={indeterminate ? "mixed" : value || undefined}
aria-readonly={readOnly}
aria-disabled={disabled}

View file

@ -1,3 +1,4 @@
import { ILabelSoftwareTitle } from "./label";
import { Platform } from "./platform";
import { IPolicy } from "./policy";
import { IQuery } from "./query";
@ -184,4 +185,7 @@ export interface IActivityDetails {
app_store_id?: number;
location?: string; // name of location associated with VPP token
webhook_url?: string;
software_title_id?: number;
labels_include_any?: ILabelSoftwareTitle[];
labels_exclude_any?: ILabelSoftwareTitle[];
}

View file

@ -24,6 +24,11 @@ export interface ILabelSummary {
label_type: LabelType;
}
export interface ILabelSoftwareTitle {
id: number;
name: string;
}
export interface ILabel extends ILabelSummary {
created_at: string;
updated_at: string;

View file

@ -4,6 +4,7 @@ import PropTypes from "prop-types";
import { IconNames } from "components/icons";
import vulnerabilityInterface from "./vulnerability";
import { ILabelSoftwareTitle } from "./label";
export default PropTypes.shape({
type: PropTypes.string,
@ -68,6 +69,7 @@ export interface ISoftwarePackage {
version: string;
uploaded_at: string;
install_script: string;
uninstall_script: string;
pre_install_query?: string;
post_install_script?: string;
self_service: boolean;
@ -81,6 +83,8 @@ export interface ISoftwarePackage {
};
automatic_install_policies?: ISoftwarePackagePolicy[];
install_during_setup?: boolean;
labels_include_any: ILabelSoftwareTitle[] | null;
labels_exclude_any: ILabelSoftwareTitle[] | null;
}
export const isSoftwarePackage = (

View file

@ -23,6 +23,7 @@ import SoftwareUninstallDetailsModal from "components/ActivityDetails/InstallDet
import ActivityItem from "./ActivityItem";
import ActivityAutomationDetailsModal from "./components/ActivityAutomationDetailsModal";
import RunScriptDetailsModal from "./components/RunScriptDetailsModal/RunScriptDetailsModal";
import SoftwareDetailsModal from "./components/SoftwareDetailsModal";
const baseClass = "activity-feed";
interface IActvityCardProps {
@ -57,6 +58,11 @@ const ActivityFeed = ({
activityAutomationDetails,
setActivityAutomationDetails,
] = useState<IActivityDetails | null>(null);
const [
softwareDetails,
setSoftwareDetails,
] = useState<IActivityDetails | null>(null);
const queryShown = useRef("");
const queryImpact = useRef<string | undefined>(undefined);
const scriptExecutionId = useRef("");
@ -131,6 +137,11 @@ const ActivityFeed = ({
case ActivityType.EditedActivityAutomations:
setActivityAutomationDetails({ ...details });
break;
case ActivityType.AddedSoftware:
case ActivityType.EditedSoftware:
case ActivityType.DeletedSoftware:
setSoftwareDetails({ ...details });
break;
default:
break;
}
@ -245,6 +256,12 @@ const ActivityFeed = ({
onCancel={() => setActivityAutomationDetails(null)}
/>
)}
{softwareDetails && (
<SoftwareDetailsModal
details={softwareDetails}
onCancel={() => setSoftwareDetails(null)}
/>
)}
</div>
);
};

View file

@ -1160,9 +1160,7 @@ describe("Activity Feed", () => {
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText("added software ", { exact: false })
).toBeInTheDocument();
expect(screen.getByText("added", { exact: false })).toBeInTheDocument();
expect(
screen.getByText("foobar.pkg", { exact: false })
).toBeInTheDocument();
@ -1188,9 +1186,7 @@ describe("Activity Feed", () => {
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText("edited software", { exact: false })
).toBeInTheDocument();
expect(screen.getByText("edited", { exact: false })).toBeInTheDocument();
expect(
screen.getByText(" on the ", {
exact: false,
@ -1213,9 +1209,7 @@ describe("Activity Feed", () => {
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText("deleted software ", { exact: false })
).toBeInTheDocument();
expect(screen.getByText("deleted", { exact: false })).toBeInTheDocument();
expect(
screen.getByText("foobar.pkg", { exact: false })
).toBeInTheDocument();
@ -1237,9 +1231,7 @@ describe("Activity Feed", () => {
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText("added software ", { exact: false })
).toBeInTheDocument();
expect(screen.getByText("added", { exact: false })).toBeInTheDocument();
expect(
screen.getByText("foobar.pkg", { exact: false })
).toBeInTheDocument();
@ -1258,9 +1250,7 @@ describe("Activity Feed", () => {
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText("edited software", { exact: false })
).toBeInTheDocument();
expect(screen.getByText("edited", { exact: false })).toBeInTheDocument();
expect(
screen.getByText("on no team", { exact: false })
).toBeInTheDocument();
@ -1273,9 +1263,7 @@ describe("Activity Feed", () => {
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(
screen.getByText("deleted software ", { exact: false })
).toBeInTheDocument();
expect(screen.getByText("deleted", { exact: false })).toBeInTheDocument();
expect(
screen.getByText("foobar.pkg", { exact: false })
).toBeInTheDocument();

View file

@ -892,54 +892,129 @@ const TAGGED_TEMPLATES = {
</>
);
},
addedSoftware: (activity: IActivity) => {
addedSoftware: (
activity: IActivity,
onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void
) => {
const {
software_title,
software_package,
self_service,
labels_include_any,
labels_exclude_any,
} = activity.details || {};
return (
<>
{" "}
added software <b>{activity.details?.software_title}</b> (
{activity.details?.software_package}) to{" "}
added <b>{activity.details?.software_package}</b> to{" "}
{activity.details?.team_name ? (
<>
{" "}
the <b>{activity.details?.team_name}</b> team.
</>
) : (
"no team."
)}
)}{" "}
<Button
className={`${baseClass}__show-query-link`}
variant="text-link"
onClick={() =>
onDetailsClick?.(activity.type, {
software_title,
software_package,
self_service,
labels_include_any,
labels_exclude_any,
})
}
>
Show details{" "}
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
</Button>
</>
);
},
editedSoftware: (activity: IActivity) => {
editedSoftware: (
activity: IActivity,
onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void
) => {
const {
software_title,
software_package,
self_service,
labels_include_any,
labels_exclude_any,
} = activity.details || {};
return (
<>
{" "}
edited software <b>{activity.details?.software_title}</b> (
{activity.details?.software_package}) on{" "}
edited <b>{activity.details?.software_package}</b> on{" "}
{activity.details?.team_name ? (
<>
{" "}
the <b>{activity.details?.team_name}</b> team.
</>
) : (
"no team."
)}
)}{" "}
<Button
className={`${baseClass}__show-query-link`}
variant="text-link"
onClick={() =>
onDetailsClick?.(activity.type, {
software_title,
software_package,
self_service,
labels_include_any,
labels_exclude_any,
})
}
>
Show details{" "}
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
</Button>
</>
);
},
deletedSoftware: (activity: IActivity) => {
deletedSoftware: (
activity: IActivity,
onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void
) => {
const {
software_title,
software_package,
self_service,
labels_include_any,
labels_exclude_any,
} = activity.details || {};
return (
<>
{" "}
deleted software <b>{activity.details?.software_title}</b> (
{activity.details?.software_package}) from{" "}
deleted <b>{activity.details?.software_package}</b> from{" "}
{activity.details?.team_name ? (
<>
{" "}
the <b>{activity.details?.team_name}</b> team.
</>
) : (
"no team."
)}
)}{" "}
<Button
className={`${baseClass}__show-query-link`}
variant="text-link"
onClick={() =>
onDetailsClick?.(activity.type, {
software_title,
software_package,
self_service,
labels_include_any,
labels_exclude_any,
})
}
>
Show details{" "}
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
</Button>
</>
);
},
@ -1334,13 +1409,13 @@ const getDetail = (
return TAGGED_TEMPLATES.resentConfigProfile(activity);
}
case ActivityType.AddedSoftware: {
return TAGGED_TEMPLATES.addedSoftware(activity);
return TAGGED_TEMPLATES.addedSoftware(activity, onDetailsClick);
}
case ActivityType.EditedSoftware: {
return TAGGED_TEMPLATES.editedSoftware(activity);
return TAGGED_TEMPLATES.editedSoftware(activity, onDetailsClick);
}
case ActivityType.DeletedSoftware: {
return TAGGED_TEMPLATES.deletedSoftware(activity);
return TAGGED_TEMPLATES.deletedSoftware(activity, onDetailsClick);
}
case ActivityType.InstalledSoftware: {
return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick);

View file

@ -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;

View file

@ -0,0 +1,10 @@
.software-details-modal {
&__modal-content {
display: flex;
gap: $pad-xxlarge;
}
.react-tooltip {
min-width: 120px;
}
}

View file

@ -0,0 +1 @@
export { default } from "./SoftwareDetailsModal";

View file

@ -11,16 +11,13 @@ import labelsAPI, { getCustomLabels } from "services/entities/labels";
import mdmAPI from "services/entities/mdm";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import Button from "components/buttons/Button";
import Card from "components/Card";
import Checkbox from "components/forms/fields/Checkbox";
import DataError from "components/DataError";
import Icon from "components/Icon";
import Modal from "components/Modal";
import Radio from "components/forms/fields/Radio";
import Spinner from "components/Spinner";
import TargetLabelSelector from "components/TargetLabelSelector";
import ProfileGraphic from "../AddProfileGraphic";
import {
@ -30,9 +27,7 @@ import {
} from "../../helpers";
import {
CUSTOM_TARGET_OPTIONS,
CustomTargetOption,
generateLabelKey,
getDescriptionText,
listNamesFromSelectedLabels,
} from "./helpers";
@ -91,118 +86,6 @@ const FileDetails = ({ details: { name, platform } }: IFileDetailsProps) => (
</div>
);
interface ITargetChooserProps {
selectedTarget: string;
setSelectedTarget: React.Dispatch<React.SetStateAction<string>>;
}
const TargetChooser = ({
selectedTarget,
setSelectedTarget,
}: ITargetChooserProps) => {
return (
<div className={`form-field`}>
<div className="form-field__label">Target</div>
<Radio
className={`${baseClass}__radio-input`}
label="All hosts"
id="all-hosts-target-radio-btn"
checked={selectedTarget === "All hosts"}
value="All hosts"
name="target-type"
onChange={setSelectedTarget}
/>
<Radio
className={`${baseClass}__radio-input`}
label="Custom"
id="custom-target-radio-btn"
checked={selectedTarget === "Custom"}
value="Custom"
name="target-type"
onChange={setSelectedTarget}
/>
</div>
);
};
interface ILabelChooserProps {
isError: boolean;
isLoading: boolean;
labels: ILabelSummary[];
selectedLabels: Record<string, boolean>;
customTargetOption: CustomTargetOption;
setSelectedLabels: React.Dispatch<
React.SetStateAction<Record<string, boolean>>
>;
onSelectCustomTargetOption: (val: CustomTargetOption) => void;
}
const LabelChooser = ({
isError,
isLoading,
labels,
selectedLabels,
customTargetOption,
setSelectedLabels,
onSelectCustomTargetOption,
}: ILabelChooserProps) => {
const updateSelectedLabels = useCallback(
({ name, value }: { name: string; value: boolean }) => {
setSelectedLabels((prevItems) => ({ ...prevItems, [name]: value }));
},
[setSelectedLabels]
);
const renderLabels = () => {
if (isLoading) {
return <Spinner centered={false} />;
}
if (isError) {
return <DataError />;
}
if (!labels.length) {
return (
<div className={`${baseClass}__no-labels`}>
<b>No labels exist in Fleet</b>
<span>Add labels to target specific hosts.</span>
</div>
);
}
return labels.map((label) => {
return (
<div className={`${baseClass}__label`} key={label.name}>
<Checkbox
className={`${baseClass}__checkbox`}
name={label.name}
value={!!selectedLabels[label.name]}
onChange={updateSelectedLabels}
parseTarget
/>
<div className={`${baseClass}__label-name`}>{label.name}</div>
</div>
);
});
};
return (
<div className={`${baseClass}__custom-label-chooser`}>
<Dropdown
value={customTargetOption}
options={CUSTOM_TARGET_OPTIONS}
searchable={false}
onChange={onSelectCustomTargetOption}
/>
<div className={`${baseClass}__description`}>
{getDescriptionText(customTargetOption)}
</div>
<div className={`${baseClass}__checkboxes`}>{renderLabels()}</div>
</div>
);
};
interface IAddProfileModalProps {
currentTeamId: number;
isPremiumTier: boolean;
@ -223,14 +106,13 @@ const AddProfileModal = ({
name: string;
platform: string;
} | null>(null);
const [selectedTarget, setSelectedTarget] = useState("All hosts"); // "All hosts" | "Custom"
const [selectedTargetType, setSelectedTargetType] = useState("All hosts");
const [selectedLabels, setSelectedLabels] = useState<Record<string, boolean>>(
{}
);
const [
customTargetOption,
setCustomTargetOption,
] = useState<CustomTargetOption>("labelsIncludeAll");
const [selectedCustomTarget, setSelectedCustomTarget] = useState(
"labelsIncludeAll"
);
const fileRef = useRef<File | null>(null);
@ -242,7 +124,6 @@ const AddProfileModal = ({
} = useQuery<ILabelSummary[], Error>(
["custom_labels"],
() => labelsAPI.summary().then((res) => getCustomLabels(res.labels)),
{
enabled: isPremiumTier,
refetchOnWindowFocus: false,
@ -268,8 +149,8 @@ const AddProfileModal = ({
setIsLoading(true);
try {
const labelKey = generateLabelKey(
selectedTarget,
customTargetOption,
selectedTargetType,
selectedCustomTarget,
selectedLabels
);
await mdmAPI.uploadProfile({
@ -308,8 +189,16 @@ const AddProfileModal = ({
}
};
const onSelectCustomTargetOption = (val: CustomTargetOption) => {
setCustomTargetOption(val);
const onSelectTargetType = (val: string) => {
setSelectedTargetType(val);
};
const onSelectCustomTargetOption = (val: string) => {
setSelectedCustomTarget(val);
};
const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => {
setSelectedLabels((prevItems) => ({ ...prevItems, [name]: value }));
};
return (
@ -327,23 +216,19 @@ const AddProfileModal = ({
)}
</Card>
{isPremiumTier && (
<div className={`${baseClass}__target`}>
<TargetChooser
selectedTarget={selectedTarget}
setSelectedTarget={setSelectedTarget}
/>
{selectedTarget === "Custom" && (
<LabelChooser
customTargetOption={customTargetOption}
isError={isErrorLabels}
isLoading={isFetchingLabels}
labels={labels || []}
selectedLabels={selectedLabels}
setSelectedLabels={setSelectedLabels}
onSelectCustomTargetOption={onSelectCustomTargetOption}
/>
)}
</div>
<TargetLabelSelector
selectedTargetType={selectedTargetType}
selectedCustomTarget={selectedCustomTarget}
selectedLabels={selectedLabels}
customTargetOptions={CUSTOM_TARGET_OPTIONS}
className={`${baseClass}__target`}
onSelectTargetType={onSelectTargetType}
onSelectCustomTarget={onSelectCustomTargetOption}
onSelectLabel={onSelectLabel}
isErrorLabels={isErrorLabels}
isLoadingLabels={isFetchingLabels || isLoadingLabels}
labels={labels || []}
/>
)}
<div className={`${baseClass}__button-wrap`}>
<Button
@ -352,7 +237,7 @@ const AddProfileModal = ({
onClick={onFileUpload}
isLoading={isLoading}
disabled={
(selectedTarget === "Custom" &&
(selectedTargetType === "Custom" &&
!listNamesFromSelectedLabels(selectedLabels).length) ||
!fileDetails
}

View file

@ -47,14 +47,9 @@ export const listNamesFromSelectedLabels = (dict: Record<string, boolean>) => {
}, [] as string[]);
};
export type CustomTargetOption =
| "labelsIncludeAll"
| "labelsIncldeAny"
| "labelsExcludeAny";
export const generateLabelKey = (
target: string,
customTargetOption: CustomTargetOption,
customTargetOption: string,
selectedLabels: Record<string, boolean>
) => {
if (target !== "Custom") {
@ -65,8 +60,3 @@ export const generateLabelKey = (
[customTargetOption]: listNamesFromSelectedLabels(selectedLabels),
};
};
export const getDescriptionText = (value: string) => {
return CUSTOM_TARGET_OPTIONS.find((option) => option.value === value)
?.helpText;
};

View file

@ -1,23 +1,31 @@
import React, { useContext, useEffect } from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import { isAxiosError } from "axios";
import PATHS from "router/paths";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import {
DEFAULT_USE_QUERY_OPTIONS,
LEARN_MORE_ABOUT_BASE_LINK,
} from "utilities/constants";
import { getFileDetails, IFileDetails } from "utilities/file/fileUtils";
import { buildQueryStringFromParams, QueryParams } from "utilities/url";
import softwareAPI, {
MAX_FILE_SIZE_BYTES,
MAX_FILE_SIZE_MB,
} from "services/entities/software";
import labelsAPI, { getCustomLabels } from "services/entities/labels";
import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
import { getErrorReason } from "interfaces/errors";
import { ILabelSummary } from "interfaces/label";
import CustomLink from "components/CustomLink";
import FileProgressModal from "components/FileProgressModal";
import PremiumFeatureMessage from "components/PremiumFeatureMessage";
import Spinner from "components/Spinner";
import DataError from "components/DataError";
import PackageForm from "pages/SoftwarePage/components/PackageForm";
import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm";
@ -46,6 +54,19 @@ const SoftwareCustomPackage = ({
null
);
const {
data: labels,
isLoading: isLoadingLabels,
isError: isErrorLabels,
} = useQuery<ILabelSummary[], Error>(
["custom_labels"],
() => labelsAPI.summary().then((res) => getCustomLabels(res.labels)),
{
...DEFAULT_USE_QUERY_OPTIONS,
enabled: isPremiumTier,
}
);
useEffect(() => {
const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
e.preventDefault();
@ -159,29 +180,42 @@ const SoftwareCustomPackage = ({
setUploadDetails(null);
};
const renderContent = () => {
if (isLoadingLabels) {
return <Spinner />;
}
if (isErrorLabels) {
return <DataError className={`${baseClass}__data-error`} />;
}
return (
<>
<PackageForm
labels={labels || []}
showSchemaButton={!isSidePanelOpen}
onClickShowSchema={() => setSidePanelOpen(true)}
className={`${baseClass}__package-form`}
onCancel={onCancel}
onSubmit={onSubmit}
/>
{uploadDetails && (
<FileProgressModal
fileDetails={uploadDetails}
fileProgress={uploadProgress}
/>
)}
</>
);
};
if (!isPremiumTier) {
return (
<PremiumFeatureMessage className={`${baseClass}__premium-message`} />
);
}
return (
<div className={baseClass}>
<PackageForm
showSchemaButton={!isSidePanelOpen}
onClickShowSchema={() => setSidePanelOpen(true)}
className={`${baseClass}__package-form`}
onCancel={onCancel}
onSubmit={onSubmit}
/>
{uploadDetails && (
<FileProgressModal
fileDetails={uploadDetails}
fileProgress={uploadProgress}
/>
)}
</div>
);
return <div className={baseClass}>{renderContent()}</div>;
};
export default SoftwareCustomPackage;

View file

@ -3,6 +3,10 @@
margin-top: $pad-xxxlarge;
}
&__data-error {
margin-top: $pad-xxxlarge;
}
// formatting the form buttons to be on the left. Tshis is done here because
// this form can be used in other places where the buttons should be on
// the right.

View file

@ -1,14 +1,21 @@
import React, { useState } from "react";
import { ILabelSummary } from "interfaces/label";
import Checkbox from "components/forms/fields/Checkbox";
import TooltipWrapper from "components/TooltipWrapper";
import RevealButton from "components/buttons/RevealButton";
import Button from "components/buttons/Button";
import Radio from "components/forms/fields/Radio";
import TargetLabelSelector from "components/TargetLabelSelector";
import AdvancedOptionsFields from "pages/SoftwarePage/components/AdvancedOptionsFields";
import { generateFormValidation } from "./helpers";
import {
CUSTOM_TARGET_OPTIONS,
generateFormValidation,
generateHelpText,
} from "./helpers";
const baseClass = "fleet-app-details-form";
@ -19,14 +26,19 @@ export interface IFleetMaintainedAppFormData {
postInstallScript?: string;
uninstallScript?: string;
installType: string;
targetType: string;
customTarget: string;
labelTargets: Record<string, boolean>;
}
export interface IFormValidation {
isValid: boolean;
preInstallQuery?: { isValid: boolean; message?: string };
customTarget?: { isValid: boolean };
}
interface IFleetAppDetailsFormProps {
labels: ILabelSummary[] | null;
defaultInstallScript: string;
defaultPostInstallScript: string;
defaultUninstallScript: string;
@ -37,6 +49,7 @@ interface IFleetAppDetailsFormProps {
}
const FleetAppDetailsForm = ({
labels,
defaultInstallScript,
defaultPostInstallScript,
defaultUninstallScript,
@ -54,6 +67,9 @@ const FleetAppDetailsForm = ({
postInstallScript: defaultPostInstallScript,
uninstallScript: defaultUninstallScript,
installType: "manual",
targetType: "All hosts",
customTarget: "labelsIncludeAny",
labelTargets: {},
});
const [formValidation, setFormValidation] = useState<IFormValidation>({
isValid: true,
@ -95,6 +111,26 @@ const FleetAppDetailsForm = ({
setFormData(newData);
};
const onSelectTargetType = (value: string) => {
const newData = { ...formData, targetType: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onSelectCustomTargetOption = (value: string) => {
const newData = { ...formData, customTarget: value };
setFormData(newData);
};
const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => {
const newData = {
...formData,
labelTargets: { ...formData.labelTargets, [name]: value },
};
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onSubmitForm = (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
onSubmit(formData);
@ -136,6 +172,21 @@ const FleetAppDetailsForm = ({
/>
</div>
</fieldset>
<TargetLabelSelector
selectedTargetType={formData.targetType}
selectedCustomTarget={formData.customTarget}
selectedLabels={formData.labelTargets}
customTargetOptions={CUSTOM_TARGET_OPTIONS}
className={`${baseClass}__target`}
dropdownHelpText={
formData.targetType === "Custom" &&
generateHelpText(formData.installType, formData.customTarget)
}
onSelectTargetType={onSelectTargetType}
onSelectCustomTarget={onSelectCustomTargetOption}
onSelectLabel={onSelectLabel}
labels={labels || []}
/>
<Checkbox
value={formData.selfService}
onChange={onToggleSelfServiceCheckbox}

View file

@ -1,3 +1,7 @@
import React from "react";
import { IDropdownOption } from "interfaces/dropdownOption";
// @ts-ignore
import validateQuery from "components/forms/validators/validate_query";
@ -8,6 +12,7 @@ import {
type IMessageFunc = (formData: IFleetMaintainedAppFormData) => string;
type IValidationMessage = string | IMessageFunc;
type IFormValidationKey = keyof Omit<IFormValidation, "isValid">;
interface IValidation {
name: string;
@ -16,7 +21,7 @@ interface IValidation {
}
const FORM_VALIDATION_CONFIG: Record<
"preInstallQuery",
IFormValidationKey,
{ validations: IValidation[] }
> = {
preInstallQuery: {
@ -33,6 +38,22 @@ const FORM_VALIDATION_CONFIG: Record<
},
],
},
customTarget: {
validations: [
{
name: "requiredLabelTargets",
isValid: (formData) => {
if (formData.targetType === "All hosts") return true;
// there must be at least one label target selected
return (
Object.keys(formData.labelTargets).find(
(key) => formData.labelTargets[key]
) !== undefined
);
},
},
],
},
};
const getErrorMessage = (
@ -54,7 +75,7 @@ export const generateFormValidation = (
};
Object.keys(FORM_VALIDATION_CONFIG).forEach((key) => {
const objKey = key as keyof typeof FORM_VALIDATION_CONFIG;
const objKey = key as IFormValidationKey;
const failedValidation = FORM_VALIDATION_CONFIG[objKey].validations.find(
(validation) => !validation.isValid(formData)
);
@ -74,3 +95,45 @@ export const generateFormValidation = (
return formValidation;
};
export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [
{
value: "labelsIncludeAny",
label: "Include any",
disabled: false,
},
{
value: "labelsExcludeAny",
label: "Exclude any",
disabled: false,
},
];
export const generateHelpText = (installType: string, customTarget: string) => {
if (customTarget === "labelsIncludeAny") {
return installType === "manual" ? (
<>
Software will only be available for install on hosts that{" "}
<b>have any</b> of these labels:
</>
) : (
<>
Software will only be installed on hosts that <b>have any</b> of these
labels:
</>
);
}
// this is the case for labelsExcludeAny
return installType === "manual" ? (
<>
Software will only be available for install on hosts that{" "}
<b>don&apos;t have any</b> of these labels:
</>
) : (
<>
Software will only be installed on hosts that <b>don&apos;t have any</b>{" "}
of these labels:{" "}
</>
);
};

View file

@ -8,11 +8,13 @@ import { buildQueryStringFromParams } from "utilities/url";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import softwareAPI from "services/entities/software";
import teamPoliciesAPI from "services/entities/team_policies";
import labelsAPI, { getCustomLabels } from "services/entities/labels";
import { QueryContext } from "context/query";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { getErrorReason } from "interfaces/errors";
import { Platform, PLATFORM_DISPLAY_NAMES } from "interfaces/platform";
import { ILabelSummary } from "interfaces/label";
import useToggleSidePanel from "hooks/useToggleSidePanel";
import BackLink from "components/BackLink";
@ -113,7 +115,11 @@ const FleetMaintainedAppDetailsPage = ({
setShowAddFleetAppSoftwareModal,
] = useState(false);
const { data: fleetApp, isLoading, isError } = useQuery(
const {
data: fleetApp,
isLoading: isLoadingFleetApp,
isError: isErrorFleetApp,
} = useQuery(
["fleet-maintained-app", appId],
() => softwareAPI.getFleetMainainedApp(appId),
{
@ -123,6 +129,21 @@ const FleetMaintainedAppDetailsPage = ({
}
);
const {
data: labels,
isLoading: isLoadingLabels,
isError: isErrorLabels,
} = useQuery<ILabelSummary[], Error>(
["custom_labels"],
() => labelsAPI.summary().then((res) => getCustomLabels(res.labels)),
{
...DEFAULT_USE_QUERY_OPTIONS,
enabled: isPremiumTier,
staleTime: 10000,
}
);
const onOsqueryTableSelect = (tableName: string) => {
setSelectedOsqueryTable(tableName);
};
@ -221,12 +242,12 @@ const FleetMaintainedAppDetailsPage = ({
return <PremiumFeatureMessage />;
}
if (isLoading) {
if (isLoadingFleetApp || isLoadingLabels) {
return <Spinner />;
}
if (isError) {
return <DataError />;
if (isErrorFleetApp || isErrorLabels) {
return <DataError className={`${baseClass}__data-error`} />;
}
if (fleetApp) {
@ -245,6 +266,7 @@ const FleetMaintainedAppDetailsPage = ({
version={fleetApp.version}
/>
<FleetAppDetailsForm
labels={labels || []}
showSchemaButton={!isSidePanelOpen}
defaultInstallScript={fleetApp.install_script}
defaultPostInstallScript={fleetApp.post_install_script}

View file

@ -1,4 +1,8 @@
.fleet-maintained-app-details-page {
&__data-error {
margin-top: $pad-xxlarge;
}
&__back-to-add-software {
margin-bottom: $pad-medium;
}

View file

@ -1,17 +1,23 @@
import React, { useContext, useState, useEffect } from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import classnames from "classnames";
import { isAxiosError } from "axios";
import { getErrorReason } from "interfaces/errors";
import { ILabelSummary } from "interfaces/label";
import { ISoftwarePackage } from "interfaces/software";
import { NotificationContext } from "context/notification";
import softwareAPI, {
MAX_FILE_SIZE_BYTES,
MAX_FILE_SIZE_MB,
} from "services/entities/software";
import labelsAPI, { getCustomLabels } from "services/entities/labels";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import {
DEFAULT_USE_QUERY_OPTIONS,
LEARN_MORE_ABOUT_BASE_LINK,
} from "utilities/constants";
import deepDifference from "utilities/deep_difference";
import { getFileDetails } from "utilities/file/fileUtils";
@ -21,6 +27,12 @@ import Modal from "components/Modal";
import PackageForm from "pages/SoftwarePage/components/PackageForm";
import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm";
import {
generateSelectedLabels,
getCustomTarget,
getTargetType,
} from "pages/SoftwarePage/components/PackageForm/helpers";
import { getErrorMessage } from "./helpers";
import ConfirmSaveChangesModal from "../ConfirmSaveChangesModal";
@ -29,8 +41,7 @@ const baseClass = "edit-software-modal";
interface IEditSoftwareModalProps {
softwareId: number;
teamId: number;
router: InjectedRouter;
software?: any; // TODO
software: ISoftwarePackage; // TODO
refetchSoftwareTitle: () => void;
onExit: () => void;
}
@ -56,9 +67,24 @@ const EditSoftwareModal = ({
software: null,
installScript: "",
selfService: false,
targetType: "",
customTarget: "",
labelTargets: {},
});
const [uploadProgress, setUploadProgress] = useState(0);
const {
data: labels,
isLoading: isLoadingLabels,
isError: isErrorLabels,
} = useQuery<ILabelSummary[], Error>(
["custom_labels"],
() => labelsAPI.summary().then((res) => getCustomLabels(res.labels)),
{
...DEFAULT_USE_QUERY_OPTIONS,
}
);
// Work around to not lose Edit Software modal data when Save changes modal opens
// by using CSS to hide Edit Software modal when Save changes modal is open
useEffect(() => {
@ -118,6 +144,7 @@ const EditSoftwareModal = ({
try {
await softwareAPI.editSoftwarePackage({
data: formData,
orignalPackage: software,
softwareId,
teamId,
onUploadProgress: (progressEvent) => {
@ -147,7 +174,7 @@ const EditSoftwareModal = ({
if (isTimeout) {
renderFlash(
"error",
`Couldnt upload. Request timeout. Please make sure your server and load balancer timeout is long enough.`
`Couldn't upload. Request timeout. Please make sure your server and load balancer timeout is long enough.`
);
} else if (reason.includes("Fleet couldn't read the version from")) {
renderFlash(
@ -184,18 +211,21 @@ const EditSoftwareModal = ({
postInstallScript: software.post_install_script || "",
uninstallScript: software.uninstall_script || "",
selfService: software.self_service || false,
targetType: getTargetType(software),
customTarget: getCustomTarget(software),
labelTargets: generateSelectedLabels(software),
});
setPendingUpdates(formData);
const onlySelfServiceUpdated =
Object.keys(updates).length === 1 && "selfService" in updates;
if (!onlySelfServiceUpdated) {
// Open the confirm save changes modal
setShowConfirmSaveChangesModal(true);
} else {
if (onlySelfServiceUpdated) {
// Proceed with saving changes (API expects only changes)
onSaveSoftwareChanges(formData);
} else {
// Open the confirm save changes modal
setShowConfirmSaveChangesModal(true);
}
};
@ -213,6 +243,7 @@ const EditSoftwareModal = ({
width="large"
>
<PackageForm
labels={labels ?? []}
className={`${baseClass}__package-form`}
isEditingSoftware
onCancel={onExit}

View file

@ -4,7 +4,6 @@ import React, {
useLayoutEffect,
useState,
} from "react";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import { AppContext } from "context/app";
@ -239,7 +238,6 @@ interface ISoftwarePackageCardProps {
// NOTE: we will only have this if we are working with a software package.
softwarePackage?: ISoftwarePackage;
onDelete: () => void;
router: InjectedRouter;
refetchSoftwareTitle: () => void;
}
@ -256,7 +254,6 @@ const SoftwarePackageCard = ({
softwareId,
teamId,
onDelete,
router,
refetchSoftwareTitle,
}: ISoftwarePackageCardProps) => {
const {
@ -393,13 +390,12 @@ const SoftwarePackageCard = ({
teamId={teamId}
/>
</div>
{showEditSoftwareModal && (
{showEditSoftwareModal && softwarePackage && (
<EditSoftwareModal
softwareId={softwareId}
teamId={teamId}
software={softwarePackage}
onExit={() => setShowEditSoftwareModal(false)}
router={router}
refetchSoftwareTitle={refetchSoftwareTitle}
/>
)}

View file

@ -148,7 +148,6 @@ const SoftwareTitleDetailsPage = ({
softwareId={softwareId}
teamId={currentTeamId ?? APP_CONTEXT_NO_TEAM_ID}
onDelete={onDeleteInstaller}
router={router}
refetchSoftwareTitle={refetchSoftwareTitle}
/>
);

View file

@ -9,6 +9,8 @@ describe("SoftwareTitleDetailsPage helpers", () => {
name: "Test Software",
versions: [{ id: 1, version: "1.0.0", vulnerabilities: [] }],
software_package: {
labels_include_any: null,
labels_exclude_any: null,
name: "TestPackage.pkg",
version: "1.0.0",
self_service: true,
@ -21,6 +23,7 @@ describe("SoftwareTitleDetailsPage helpers", () => {
failed_uninstall: 1,
},
install_script: "echo foo",
uninstall_script: "echo bar",
icon_url: "https://example.com/icon.png",
automatic_install_policies: [],
last_install: null,

View file

@ -6,15 +6,23 @@ import { NotificationContext } from "context/notification";
import { getFileDetails } from "utilities/file/fileUtils";
import getDefaultInstallScript from "utilities/software_install_scripts";
import getDefaultUninstallScript from "utilities/software_uninstall_scripts";
import { ILabelSummary } from "interfaces/label";
import Button from "components/buttons/Button";
import Checkbox from "components/forms/fields/Checkbox";
import FileUploader from "components/FileUploader";
import TooltipWrapper from "components/TooltipWrapper";
import TargetLabelSelector from "components/TargetLabelSelector";
import PackageAdvancedOptions from "../PackageAdvancedOptions";
import { generateFormValidation } from "./helpers";
import {
CUSTOM_TARGET_OPTIONS,
generateFormValidation,
generateSelectedLabels,
getCustomTarget,
getTargetType,
} from "./helpers";
export const baseClass = "package-form";
@ -25,18 +33,20 @@ export interface IPackageFormData {
postInstallScript?: string;
uninstallScript?: string;
selfService: boolean;
targetType: string;
customTarget: string;
labelTargets: Record<string, boolean>;
}
export interface IFormValidation {
isValid: boolean;
software: { isValid: boolean };
preInstallQuery?: { isValid: boolean; message?: string };
postInstallScript?: { isValid: boolean; message?: string };
uninstallScript?: { isValid: boolean; message?: string };
selfService?: { isValid: boolean };
customTarget?: { isValid: boolean };
}
interface IPackageFormProps {
labels: ILabelSummary[];
showSchemaButton?: boolean;
onCancel: () => void;
onSubmit: (formData: IPackageFormData) => void;
@ -54,6 +64,7 @@ interface IPackageFormProps {
const ACCEPTED_EXTENSIONS = ".pkg,.msi,.exe,.deb,.rpm";
const PackageForm = ({
labels,
showSchemaButton = false,
onClickShowSchema,
onCancel,
@ -69,15 +80,17 @@ const PackageForm = ({
}: IPackageFormProps) => {
const { renderFlash } = useContext(NotificationContext);
const initialFormData = {
const [formData, setFormData] = useState<IPackageFormData>({
software: defaultSoftware || null,
installScript: defaultInstallScript || "",
preInstallQuery: defaultPreInstallQuery || "",
postInstallScript: defaultPostInstallScript || "",
uninstallScript: defaultUninstallScript || "",
selfService: defaultSelfService || false,
};
const [formData, setFormData] = useState<IPackageFormData>(initialFormData);
targetType: getTargetType(defaultSoftware),
customTarget: getCustomTarget(defaultSoftware),
labelTargets: generateSelectedLabels(defaultSoftware),
});
const [formValidation, setFormValidation] = useState<IFormValidation>({
isValid: false,
software: { isValid: false },
@ -156,6 +169,27 @@ const PackageForm = ({
setFormValidation(generateFormValidation(newData));
};
const onSelectTargetType = (value: string) => {
const newData = { ...formData, targetType: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onSelectCustomTarget = (value: string) => {
const newData = { ...formData, customTarget: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onSelectLabel = ({ name, value }: { name: string; value: boolean }) => {
const newData = {
...formData,
labelTargets: { ...formData.labelTargets, [name]: value },
};
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const isSubmitDisabled = !formValidation.isValid;
const classNames = classnames(baseClass, className);
@ -176,6 +210,17 @@ const PackageForm = ({
formData.software ? getFileDetails(formData.software) : undefined
}
/>
<TargetLabelSelector
selectedTargetType={formData.targetType}
selectedCustomTarget={formData.customTarget}
selectedLabels={formData.labelTargets}
customTargetOptions={CUSTOM_TARGET_OPTIONS}
className={`${baseClass}__target`}
onSelectTargetType={onSelectTargetType}
onSelectCustomTarget={onSelectCustomTarget}
onSelectLabel={onSelectLabel}
labels={labels || []}
/>
<Checkbox
value={formData.selfService}
onChange={onToggleSelfServiceCheckbox}
@ -196,7 +241,6 @@ const PackageForm = ({
selectedPackage={formData.software}
errors={{
preInstallQuery: formValidation.preInstallQuery?.message,
postInstallScript: formValidation.postInstallScript?.message,
}}
preInstallQuery={formData.preInstallQuery}
installScript={formData.installScript}

View file

@ -1,15 +1,14 @@
import { IDropdownOption } from "interfaces/dropdownOption";
import { ISoftwarePackage } from "interfaces/software";
// @ts-ignore
import validateQuery from "components/forms/validators/validate_query";
import { IPackageFormData, IFormValidation } from "./PackageForm";
type IPackageFormValidatorKey = Exclude<
keyof IPackageFormData,
"installScript" | "uninstallScript"
>;
type IMessageFunc = (formData: IPackageFormData) => string;
type IValidationMessage = string | IMessageFunc;
type IFormValidationKey = keyof Omit<IFormValidation, "isValid">;
interface IValidation {
name: string;
@ -17,11 +16,11 @@ interface IValidation {
message?: IValidationMessage;
}
/** configuration defines validations for each filed in the form. It defines rules
/** configuration defines validations for each field in the form. It defines rules
* to determine if a field is valid, and rules for generating an error message.
*/
const FORM_VALIDATION_CONFIG: Record<
IPackageFormValidatorKey,
IFormValidationKey,
{ validations: IValidation[] }
> = {
software: {
@ -46,13 +45,21 @@ const FORM_VALIDATION_CONFIG: Record<
},
],
},
postInstallScript: {
// no validations related to postInstallScript
validations: [],
},
selfService: {
// no validations related to self service
validations: [],
customTarget: {
validations: [
{
name: "requiredLabelTargets",
isValid: (formData) => {
if (formData.targetType === "All hosts") return true;
// there must be at least one label target selected
return (
Object.keys(formData.labelTargets).find(
(key) => formData.labelTargets[key]
) !== undefined
);
},
},
],
},
};
@ -97,3 +104,56 @@ export const generateFormValidation = (formData: IPackageFormData) => {
};
export default generateFormValidation;
export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [
{
value: "labelsIncludeAny",
label: "Include any",
disabled: false,
},
{
value: "labelsExcludeAny",
label: "Exclude any",
disabled: false,
},
];
export const getTargetType = (softwarePackage: ISoftwarePackage) => {
if (!softwarePackage) return "All hosts";
return !softwarePackage.labels_include_any &&
!softwarePackage.labels_exclude_any
? "All hosts"
: "Custom";
};
export const getCustomTarget = (softwarePackage: ISoftwarePackage) => {
if (!softwarePackage) return "labelsIncludeAny";
return softwarePackage.labels_include_any
? "labelsIncludeAny"
: "labelsExcludeAny";
};
export const generateSelectedLabels = (softwarePackage: ISoftwarePackage) => {
if (
!softwarePackage ||
(!softwarePackage.labels_include_any && !softwarePackage.labels_exclude_any)
) {
return {};
}
const customTypeKey = softwarePackage.labels_include_any
? "labels_include_any"
: "labels_exclude_any";
return (
softwarePackage[customTypeKey]?.reduce<Record<string, boolean>>(
(acc, label) => {
acc[label.name] = true;
return acc;
},
{}
) ?? {}
);
};

View file

@ -99,7 +99,7 @@ import {
MANAGE_HOSTS_PAGE_FILTER_KEYS,
MANAGE_HOSTS_PAGE_LABEL_INCOMPATIBLE_QUERY_PARAMS,
} from "./HostsPageConfig";
import { isAcceptableStatus } from "./helpers";
import { getDeleteLabelErrorMessages, isAcceptableStatus } from "./helpers";
import DeleteSecretModal from "../../../components/EnrollSecrets/DeleteSecretModal";
import SecretEditorModal from "../../../components/EnrollSecrets/SecretEditorModal";
@ -1061,13 +1061,7 @@ const ManageHostsPage = ({
);
renderFlash("success", "Successfully deleted label.");
} catch (error) {
console.error(error);
renderFlash(
"error",
getErrorReason(error).includes("built-in")
? "Built-in labels cant be modified or deleted."
: "Could not delete label. Please try again."
);
renderFlash("error", getDeleteLabelErrorMessages(error));
} finally {
setIsUpdatingLabel(false);
}

View file

@ -24,7 +24,14 @@ const DeleteLabelModal = ({
className={baseClass}
>
<>
<p>Are you sure you wish to delete this label?</p>
<p>
If a configuration profile uses this label as a custom target, the
profile will break: it won&apos;t be applied to new hosts.
</p>
<p>
To apply the profile to new hosts, you&apos;ll have to delete it and
upload a new profile.
</p>
<div className="modal-cta-wrap">
<Button
onClick={onSubmit}

View file

@ -1,3 +1,5 @@
import { getErrorReason } from "interfaces/errors";
export const isAcceptableStatus = (filter: string): boolean => {
return (
filter === "new" ||
@ -21,3 +23,25 @@ export const isValidPemCertificate = (cert: string): boolean => {
return regexPemHeader.test(cert) && regexPemFooter.test(cert);
};
const hasStatusKey = (value: unknown): value is { status: number } => {
return (
typeof value === "object" &&
value !== null &&
"status" in value &&
typeof (value as any).status === "number"
);
};
export const getDeleteLabelErrorMessages = (error: unknown): string => {
// unprocessable content status. Label is used in a custom profile
// or software target. we have to check that status exists on the error object
// before we can access it.
if (hasStatusKey(error) && error.status === 422) {
return getErrorReason(error).includes("built-in")
? "Built-in labels can't be modified or deleted."
: "Couldn't delete. Software uses this label as a custom target. Please delete the software and try again.";
}
return "Could not delete label. Please try again.";
};

View file

@ -10,6 +10,7 @@ import {
ISoftwareTitleDetails,
IFleetMaintainedApp,
IFleetMaintainedAppDetails,
ISoftwarePackage,
} from "interfaces/software";
import {
buildQueryStringFromParams,
@ -17,6 +18,8 @@ import {
} from "utilities/url";
import { IPackageFormData } from "pages/SoftwarePage/components/PackageForm/PackageForm";
import { IAddFleetMaintainedData } from "pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage";
import { listNamesFromSelectedLabels } from "components/TargetLabelSelector/TargetLabelSelector";
import { join } from "path";
export interface ISoftwareApiParams {
page?: number;
@ -138,6 +141,8 @@ interface IAddFleetMaintainedAppPostBody {
post_install_script?: string;
uninstall_script?: string;
self_service?: boolean;
labels_include_any?: string[];
labels_exclude_any?: string[];
}
const ORDER_KEY = "name";
@ -276,6 +281,19 @@ export default {
formData.append("post_install_script", data.postInstallScript);
teamId && formData.append("team_id", teamId.toString());
if (data.targetType === "Custom") {
const selectedLabels = listNamesFromSelectedLabels(data.labelTargets);
let labelKey = "";
if (data.customTarget === "labelsIncludeAny") {
labelKey = "labels_include_any";
} else {
labelKey = "labels_exclude_any";
}
selectedLabels?.forEach((label) => {
formData.append(labelKey, label);
});
}
return sendRequestWithProgress({
method: "POST",
path: SOFTWARE_PACKAGE_ADD,
@ -289,6 +307,7 @@ export default {
editSoftwarePackage: ({
data,
orignalPackage,
softwareId,
teamId,
timeout,
@ -296,6 +315,7 @@ export default {
signal,
}: {
data: IPackageFormData;
orignalPackage: ISoftwarePackage;
softwareId: number;
teamId: number;
timeout?: number;
@ -313,6 +333,29 @@ export default {
formData.append("post_install_script", data.postInstallScript || "");
formData.append("uninstall_script", data.uninstallScript || "");
// clear out labels if targetType is "All hosts"
if (data.targetType === "All hosts") {
if (orignalPackage.labels_include_any) {
formData.append("labels_include_any", "");
} else {
formData.append("labels_exclude_any", "");
}
}
// add custom labels if targetType is "Custom"
if (data.targetType === "Custom") {
const selectedLabels = listNamesFromSelectedLabels(data.labelTargets);
let labelKey = "";
if (data.customTarget === "labelsIncludeAny") {
labelKey = "labels_include_any";
} else {
labelKey = "labels_exclude_any";
}
selectedLabels?.forEach((label) => {
formData.append(labelKey, label);
});
}
return sendRequestWithProgress({
method: "PATCH",
path: EDIT_SOFTWARE_PACKAGE(softwareId),
@ -380,6 +423,15 @@ export default {
self_service: formData.selfService,
};
if (formData.targetType === "Custom") {
const selectedLabels = listNamesFromSelectedLabels(formData.labelTargets);
if (formData.customTarget === "labelsIncludeAny") {
body.labels_include_any = selectedLabels;
} else {
body.labels_exclude_any = selectedLabels;
}
}
return sendRequest("POST", SOFTWARE_FLEET_MAINTAINED_APPS, body);
},
};

View file

@ -879,6 +879,10 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
multiError = multierror.Append(multiError, fmt.Errorf("software URL %q is too long, must be %d characters or less", softwarePackageSpec.URL, fleet.SoftwareInstallerURLMaxLength))
continue
}
if len(softwarePackageSpec.LabelsExcludeAny) > 0 && len(softwarePackageSpec.LabelsIncludeAny) > 0 {
multiError = multierror.Append(multiError, fmt.Errorf(`only one of "labels_exclude_any" or "labels_include_any" can be specified for software URL %q`, softwarePackageSpec.URL))
continue
}
result.Software.Packages = append(result.Software.Packages, &softwarePackageSpec)
}

View file

@ -397,40 +397,43 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
installer1, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
require.NoError(t, err)
sw1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install foo",
InstallerFile: installer1,
StorageID: uuid.NewString(),
Filename: "foo.pkg",
Title: "foo",
Source: "apps",
Version: "0.0.1",
UserID: u.ID,
InstallScript: "install foo",
InstallerFile: installer1,
StorageID: uuid.NewString(),
Filename: "foo.pkg",
Title: "foo",
Source: "apps",
Version: "0.0.1",
UserID: u.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
installer2, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
require.NoError(t, err)
sw2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install bar",
InstallerFile: installer2,
StorageID: uuid.NewString(),
Filename: "bar.pkg",
Title: "bar",
Source: "apps",
Version: "0.0.2",
UserID: u.ID,
InstallScript: "install bar",
InstallerFile: installer2,
StorageID: uuid.NewString(),
Filename: "bar.pkg",
Title: "bar",
Source: "apps",
Version: "0.0.2",
UserID: u.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
installer3, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
require.NoError(t, err)
sw3, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install to delete",
InstallerFile: installer3,
StorageID: uuid.NewString(),
Filename: "todelete.pkg",
Title: "todelete",
Source: "apps",
Version: "0.0.3",
UserID: u.ID,
InstallScript: "install to delete",
InstallerFile: installer3,
StorageID: uuid.NewString(),
Filename: "todelete.pkg",
Title: "todelete",
Source: "apps",
Version: "0.0.3",
UserID: u.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
sw1Meta, err := ds.GetSoftwareInstallerMetadataByID(ctx, sw1)

View file

@ -6940,6 +6940,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
PreInstallQuery: "",
Title: "ChocolateRain",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
_, err = ds.InsertSoftwareInstallRequest(context.Background(), host.ID, softwareInstaller, false, nil)

View file

@ -252,6 +252,9 @@ func (ds *Datastore) DeleteLabel(ctx context.Context, name string) error {
_, err = tx.ExecContext(ctx, `DELETE FROM labels WHERE id = ?`, labelID)
if err != nil {
if isMySQLForeignKey(err) {
return ctxerr.Wrap(ctx, foreignKey("labels", name), "delete label")
}
return ctxerr.Wrapf(ctx, err, "delete label")
}

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strconv"
"strings"
"testing"
"time"
@ -918,25 +919,65 @@ func testLabelsRecordNonexistentQueryLabelExecution(t *testing.T, db *Datastore)
}
func testDeleteLabel(t *testing.T, db *Datastore) {
l, err := db.NewLabel(context.Background(), &fleet.Label{
ctx := context.Background()
l, err := db.NewLabel(ctx, &fleet.Label{
Name: t.Name(),
Query: "query1",
})
require.NoError(t, err)
p, err := db.NewPack(context.Background(), &fleet.Pack{
p, err := db.NewPack(ctx, &fleet.Pack{
Name: t.Name(),
LabelIDs: []uint{l.ID},
})
require.NoError(t, err)
require.NoError(t, db.DeleteLabel(context.Background(), l.Name))
require.NoError(t, db.DeleteLabel(ctx, l.Name))
newP, err := db.Pack(context.Background(), p.ID)
newP, err := db.Pack(ctx, p.ID)
require.NoError(t, err)
require.Empty(t, newP.Labels)
require.NoError(t, db.DeletePack(context.Background(), newP.Name))
require.NoError(t, db.DeletePack(ctx, newP.Name))
// delete a non-existing label
err = db.DeleteLabel(ctx, "no-such-label")
require.Error(t, err)
var nfe fleet.NotFoundError
require.ErrorAs(t, err, &nfe)
// create a software installer and scope it via a label
u := test.NewUser(t, db, "user1", "user1@example.com", false)
installer, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
require.NoError(t, err)
installerID, _, err := db.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install foo",
InstallerFile: installer,
StorageID: uuid.NewString(),
Filename: "foo.pkg",
Title: "foo",
Source: "apps",
Version: "0.0.1",
UserID: u.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
l2, err := db.NewLabel(ctx, &fleet.Label{
Name: t.Name() + "2",
Query: "query2",
})
require.NoError(t, err)
ExecAdhocSQL(t, db, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO software_installer_labels (software_installer_id, label_id) VALUES (?, ?)`, installerID, l2.ID)
return err
})
// try to delete that label referenced by software installer
err = db.DeleteLabel(ctx, l2.Name)
require.Error(t, err)
require.True(t, fleet.IsForeignKey(err))
}
func testLabelsSummary(t *testing.T, db *Datastore) {

View file

@ -214,6 +214,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) {
UserID: user.ID,
Platform: string(fleet.MacOSPlatform),
BundleIdentifier: "irrelevant_1",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -233,6 +234,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) {
UserID: user.ID,
Platform: string(fleet.MacOSPlatform),
BundleIdentifier: "fleet.maintained1",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -252,6 +254,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) {
UserID: user.ID,
Platform: string(fleet.IOSPlatform),
BundleIdentifier: "fleet.maintained1",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)

View file

@ -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
}

View file

@ -287,6 +287,7 @@ func testGlobalPolicyPendingScriptsAndInstalls(t *testing.T, ds *Datastore) {
Version: "1.0",
Source: "apps",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
policy2, err := ds.NewGlobalPolicy(ctx, &user.ID, fleet.PolicyPayload{
@ -885,6 +886,7 @@ func testTeamPolicyPendingScriptsAndInstalls(t *testing.T, ds *Datastore) {
Source: "apps",
UserID: user.ID,
TeamID: &team2.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
policy2, err := ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{
@ -1444,6 +1446,7 @@ func testPoliciesByID(t *testing.T, ds *Datastore) {
Source: "apps",
UserID: user1.ID,
TeamID: &team1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
policy2.SoftwareInstallerID = ptr.Uint(installerID)
@ -4192,6 +4195,7 @@ func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) {
Source: "apps",
UserID: user1.ID,
TeamID: &team1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.Nil(t, p1.SoftwareInstallerID)
@ -4230,6 +4234,7 @@ func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) {
Source: "apps",
UserID: user1.ID,
TeamID: ptr.Uint(fleet.PolicyNoTeamID),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
p4, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{
@ -4451,6 +4456,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
Source: "apps",
UserID: user1.ID,
TeamID: &team1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
installer1, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer1ID)
@ -4470,6 +4476,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
Source: "deb_packages",
UserID: user1.ID,
TeamID: &team2.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
installer2, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer2ID)
@ -4489,6 +4496,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
Source: "rpm_packages",
UserID: user1.ID,
TeamID: nil,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
installer3, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer3ID)
@ -4509,6 +4517,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
Source: "programs",
UserID: user1.ID,
TeamID: &team1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
installer5, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer5ID)
@ -4700,6 +4709,7 @@ func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) {
Source: "apps",
UserID: user1.ID,
TeamID: &team2.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
installer4, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer4ID)
@ -5229,6 +5239,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
Source: "apps",
UserID: user1.ID,
TeamID: &team1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
policy1.SoftwareInstallerID = ptr.Uint(installer1ID)
@ -5248,6 +5259,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
Source: "apps",
UserID: user1.ID,
TeamID: &team2.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
policy2.SoftwareInstallerID = ptr.Uint(installer2ID)
@ -5304,6 +5316,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
Source: "apps",
UserID: user1.ID,
TeamID: nil,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -5319,6 +5332,7 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
Source: "apps",
UserID: user1.ID,
TeamID: nil,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)

File diff suppressed because one or more lines are too long

View file

@ -1298,6 +1298,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
Version: "1.0",
Source: "apps",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -1358,6 +1359,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
Version: "1.0",
Source: "apps",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)

View file

@ -72,6 +72,7 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
UserID: user1.ID,
TeamID: &team1.ID,
Platform: string(fleet.MacOSPlatform),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -91,6 +92,7 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
UserID: user1.ID,
TeamID: &team2.ID,
Platform: string(fleet.MacOSPlatform),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -330,6 +332,7 @@ func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) {
UserID: user1.ID,
TeamID: &team1.ID,
Platform: string(fleet.MacOSPlatform),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -349,6 +352,7 @@ func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) {
UserID: user1.ID,
TeamID: &team2.ID,
Platform: string(fleet.MacOSPlatform),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -368,6 +372,7 @@ func testGetSetupExperienceTitles(t *testing.T, ds *Datastore) {
UserID: user1.ID,
TeamID: &team2.ID,
Platform: string(fleet.IOSPlatform),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -464,6 +469,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
UserID: user1.ID,
TeamID: &team1.ID,
Platform: string(fleet.MacOSPlatform),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
_ = installerID1
require.NoError(t, err)
@ -483,6 +489,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
UserID: user1.ID,
TeamID: &team1.ID,
Platform: string(fleet.MacOSPlatform),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
_ = installerID2
require.NoError(t, err)
@ -503,6 +510,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
UserID: user1.ID,
TeamID: &team2.ID,
Platform: string(fleet.MacOSPlatform),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
_ = installerID3
require.NoError(t, err)
@ -523,6 +531,7 @@ func testSetSetupExperienceTitles(t *testing.T, ds *Datastore) {
UserID: user1.ID,
TeamID: &team2.ID,
Platform: string(fleet.IOSPlatform),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
_ = installerID4
require.NoError(t, err)
@ -678,7 +687,7 @@ func testSetupExperienceStatusResults(t *testing.T, ds *Datastore) {
// We need a new user first
user, err := ds.NewUser(ctx, &fleet.User{Name: "Foo", Email: "foo@example.com", GlobalRole: ptr.String("admin"), Password: []byte("12characterslong!")})
require.NoError(t, err)
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{Filename: "test.app", Version: "1.0.0", UserID: user.ID})
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{Filename: "test.app", Version: "1.0.0", UserID: user.ID, ValidatedLabels: &fleet.LabelIdentsWithScope{}})
require.NoError(t, err)
installer, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
require.NoError(t, err)

View file

@ -2296,6 +2296,8 @@ INNER JOIN software_cve scve ON scve.software_id = s.id
host_vpp_software_installs hvsi ON vat.adam_id = hvsi.adam_id AND hvsi.host_id = :host_id AND hvsi.removed = 0
LEFT OUTER JOIN
nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid
LEFT OUTER JOIN
host_script_results hsr ON hsr.host_id = :host_id AND hsr.execution_id = hsi.last_uninstall_execution_id
WHERE
-- use the latest VPP install attempt only
( hvsi.id IS NULL OR hvsi.id = (
@ -2309,6 +2311,67 @@ INNER JOIN software_cve scve ON scve.software_id = s.id
-- on host (via installer or VPP app). If only available for install is
-- requested, then the software installed on host clause is empty.
( %s hsi.host_id IS NOT NULL OR hvsi.host_id IS NOT NULL )
AND
-- label membership check
(
-- do the label membership check only for software installers
CASE WHEN (si.ID IS NOT NULL AND hsi.last_uninstalled_at IS NOT NULL AND hsr.exit_code = 0) THEN
(
EXISTS (
SELECT 1 FROM (
-- no labels
SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels
WHERE NOT EXISTS (
SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = si.id
)
UNION
-- include any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
0 as count_host_updated_after_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
AND lm.host_id = :host_id
WHERE
sil.software_installer_id = si.id
AND sil.exclude = 0
HAVING
count_installer_labels > 0 AND count_host_labels > 0
UNION
-- exclude any, ignore software that depends on labels created
-- _after_ the label_updated_at timestamp of the host (because
-- we don't have results for that label yet, the host may or may
-- not be a member).
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
SUM(CASE WHEN lbl.created_at IS NOT NULL AND :host_label_updated_at >= lbl.created_at THEN 1 ELSE 0 END) as count_host_updated_after_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN labels lbl
ON lbl.id = sil.label_id
LEFT OUTER JOIN label_membership lm
ON lm.label_id = sil.label_id AND lm.host_id = :host_id
WHERE
sil.software_installer_id = si.id
AND sil.exclude = 1
HAVING
count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0
) t
)
)
-- it's some other type of software that has been checked above
ELSE true END
)
%s
`, status, softwareIsInstalledOnHostClause, onlySelfServiceClause)
@ -2375,7 +2438,66 @@ INNER JOIN software_cve scve ON scve.software_id = s.id
hvsi.removed = 0
) AND
-- either the software installer or the vpp app exists for the host's team
( si.id IS NOT NULL OR vat.platform = :host_platform )
( si.id IS NOT NULL OR vat.platform = :host_platform ) AND
-- label membership check
(
-- do the label membership check only for software installers
CASE WHEN si.ID IS NOT NULL THEN
(
EXISTS (
SELECT 1 FROM (
-- no labels
SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels
WHERE NOT EXISTS (
SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = si.id
)
UNION
-- include any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
0 as count_host_updated_after_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
AND lm.host_id = :host_id
WHERE
sil.software_installer_id = si.id
AND sil.exclude = 0
HAVING
count_installer_labels > 0 AND count_host_labels > 0
UNION
-- exclude any, ignore software that depends on labels created
-- _after_ the label_updated_at timestamp of the host (because
-- we don't have results for that label yet, the host may or may
-- not be a member).
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
SUM(CASE WHEN lbl.created_at IS NOT NULL AND :host_label_updated_at >= lbl.created_at THEN 1 ELSE 0 END) as count_host_updated_after_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN labels lbl
ON lbl.id = sil.label_id
LEFT OUTER JOIN label_membership lm
ON lm.label_id = sil.label_id AND lm.host_id = :host_id
WHERE
sil.software_installer_id = si.id
AND sil.exclude = 1
HAVING
count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0
) t
)
)
-- it's some other type of software that has been checked above
ELSE true END
)
%s %s
`, onlySelfServiceClause, excludeVPPAppsClause)
@ -2415,6 +2537,7 @@ INNER JOIN software_cve scve ON scve.software_id = s.id
"mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError,
"global_or_team_id": globalOrTeamID,
"is_mdm_enrolled": opts.IsMDMEnrolled,
"host_label_updated_at": host.LabelUpdatedAt,
}
stmt := stmtInstalled

View file

@ -11,6 +11,7 @@ import (
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/go-kit/kit/log/level"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
@ -92,6 +93,12 @@ func (ds *Datastore) GetSoftwareInstallDetails(ctx context.Context, executionId
}
func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (installerID, titleID uint, err error) {
if payload.ValidatedLabels == nil {
// caller must ensure this is not nil; if caller intends no labels to be created,
// payload.ValidatedLabels should point to an empty struct.
return 0, 0, errors.New("validated labels must not be nil")
}
titleID, err = ds.getOrGenerateSoftwareInstallerTitleID(ctx, payload)
if err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "get or generate software installer title ID")
@ -130,7 +137,8 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload
}
}
stmt := `
if err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
stmt := `
INSERT INTO software_installers (
team_id,
global_or_team_id,
@ -152,39 +160,49 @@ INSERT INTO software_installers (
fleet_library_app_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?)`
args := []interface{}{
tid,
globalOrTeamID,
titleID,
payload.StorageID,
payload.Filename,
payload.Extension,
payload.Version,
strings.Join(payload.PackageIDs, ","),
installScriptID,
payload.PreInstallQuery,
postInstallScriptID,
uninstallScriptID,
payload.Platform,
payload.SelfService,
payload.UserID,
payload.UserID,
payload.UserID,
payload.FleetLibraryAppID,
}
res, err := ds.writer(ctx).ExecContext(ctx, stmt, args...)
if err != nil {
if IsDuplicate(err) {
// already exists for this team/no team
err = alreadyExists("SoftwareInstaller", payload.Title)
args := []interface{}{
tid,
globalOrTeamID,
titleID,
payload.StorageID,
payload.Filename,
payload.Extension,
payload.Version,
strings.Join(payload.PackageIDs, ","),
installScriptID,
payload.PreInstallQuery,
postInstallScriptID,
uninstallScriptID,
payload.Platform,
payload.SelfService,
payload.UserID,
payload.UserID,
payload.UserID,
payload.FleetLibraryAppID,
}
res, err := tx.ExecContext(ctx, stmt, args...)
if err != nil {
if IsDuplicate(err) {
// already exists for this team/no team
err = alreadyExists("SoftwareInstaller", payload.Title)
}
return err
}
id, _ := res.LastInsertId()
installerID = uint(id) //nolint:gosec // dismiss G115
if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, installerID, *payload.ValidatedLabels); err != nil {
return ctxerr.Wrap(ctx, err, "upsert software installer labels")
}
return nil
}); err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "insert software installer")
}
id, _ := res.LastInsertId()
return uint(id), titleID, nil //nolint:gosec // dismiss G115
return installerID, titleID, nil
}
func (ds *Datastore) getOrGenerateSoftwareInstallerTitleID(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) {
@ -237,6 +255,61 @@ func (ds *Datastore) addSoftwareTitleToMatchingSoftware(ctx context.Context, tit
return ctxerr.Wrap(ctx, err, "adding fk reference in software to software_titles")
}
// setOrUpdateSoftwareInstallerLabelsDB sets or updates the label associations for the specified software
// installer. If no labels are provided, it will remove all label associations with the software installer.
func setOrUpdateSoftwareInstallerLabelsDB(ctx context.Context, tx sqlx.ExtContext, installerID uint, labels fleet.LabelIdentsWithScope) error {
labelIds := make([]uint, 0, len(labels.ByName))
for _, label := range labels.ByName {
labelIds = append(labelIds, label.LabelID)
}
// remove existing labels
delArgs := []interface{}{installerID}
delStmt := `DELETE FROM software_installer_labels WHERE software_installer_id = ?`
if len(labelIds) > 0 {
inStmt, args, err := sqlx.In(` AND label_id NOT IN (?)`, labelIds)
if err != nil {
return ctxerr.Wrap(ctx, err, "build delete existing software installer labels query")
}
delArgs = append(delArgs, args...)
delStmt += inStmt
}
_, err := tx.ExecContext(ctx, delStmt, delArgs...)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete existing software installer labels")
}
// insert new labels
if len(labelIds) > 0 {
var exclude bool
switch labels.LabelScope {
case fleet.LabelScopeIncludeAny:
exclude = false
case fleet.LabelScopeExcludeAny:
exclude = true
default:
// this should never happen
return ctxerr.New(ctx, "invalid label scope")
}
stmt := `INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES %s ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)`
var placeholders string
var insertArgs []interface{}
for _, lid := range labelIds {
placeholders += "(?, ?, ?),"
insertArgs = append(insertArgs, installerID, lid, exclude)
}
placeholders = strings.TrimSuffix(placeholders, ",")
_, err = tx.ExecContext(ctx, fmt.Sprintf(stmt, placeholders), insertArgs...)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert software installer label")
}
}
return nil
}
func (ds *Datastore) UpdateInstallerSelfServiceFlag(ctx context.Context, selfService bool, id uint) error {
_, err := ds.writer(ctx).ExecContext(ctx, `UPDATE software_installers SET self_service = ? WHERE id = ?`, selfService, id)
if err != nil {
@ -275,7 +348,8 @@ func (ds *Datastore) SaveInstallerUpdates(ctx context.Context, payload *fleet.Up
touchUploaded = ", uploaded_at = NOW()"
}
stmt := fmt.Sprintf(`UPDATE software_installers SET
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
stmt := fmt.Sprintf(`UPDATE software_installers SET
storage_id = ?,
filename = ?,
version = ?,
@ -290,23 +364,34 @@ func (ds *Datastore) SaveInstallerUpdates(ctx context.Context, payload *fleet.Up
user_email = (SELECT email FROM users WHERE id = ?) %s
WHERE id = ?`, touchUploaded)
args := []interface{}{
payload.StorageID,
payload.Filename,
payload.Version,
strings.Join(payload.PackageIDs, ","),
installScriptID,
*payload.PreInstallQuery,
postInstallScriptID,
uninstallScriptID,
*payload.SelfService,
payload.UserID,
payload.UserID,
payload.UserID,
payload.InstallerID,
}
args := []interface{}{
payload.StorageID,
payload.Filename,
payload.Version,
strings.Join(payload.PackageIDs, ","),
installScriptID,
*payload.PreInstallQuery,
postInstallScriptID,
uninstallScriptID,
*payload.SelfService,
payload.UserID,
payload.UserID,
payload.UserID,
payload.InstallerID,
}
_, err = ds.writer(ctx).ExecContext(ctx, stmt, args...)
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "update software installer")
}
if payload.ValidatedLabels != nil {
if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, payload.InstallerID, *payload.ValidatedLabels); err != nil {
return ctxerr.Wrap(ctx, err, "upsert software installer labels")
}
}
return nil
})
if err != nil {
return ctxerr.Wrap(ctx, err, "update software installer")
}
@ -423,6 +508,28 @@ WHERE
return nil, ctxerr.Wrap(ctx, err, "get software installer metadata")
}
// TODO: do we want to include labels on other queries that return software installer metadata
// (e.g., GetSoftwareInstallerMetadataByID)?
labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get software installer labels")
}
var exclAny, inclAny []fleet.SoftwareScopeLabel
for _, l := range labels {
if l.Exclude {
exclAny = append(exclAny, l)
} else {
inclAny = append(inclAny, l)
}
}
if len(inclAny) > 0 && len(exclAny) > 0 {
// there's a bug somewhere
level.Debug(ds.logger).Log("msg", "software installer has both include and exclude labels", "installer_id", dest.InstallerID, "include", fmt.Sprintf("%v", inclAny), "exclude", fmt.Sprintf("%v", exclAny))
}
dest.LabelsExcludeAny = exclAny
dest.LabelsIncludeAny = inclAny
policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{titleID}, teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get policies by software title ID")
@ -432,6 +539,28 @@ WHERE
return &dest, nil
}
func (ds *Datastore) getSoftwareInstallerLabels(ctx context.Context, installerID uint) ([]fleet.SoftwareScopeLabel, error) {
query := `
SELECT
label_id,
exclude,
l.name as label_name,
si.title_id
FROM
software_installer_labels sil
JOIN software_installers si ON si.id = sil.software_installer_id
JOIN labels l ON l.id = sil.label_id
WHERE
software_installer_id = ?`
var labels []fleet.SoftwareScopeLabel
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &labels, query, installerID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get software installer labels")
}
return labels, nil
}
var (
errDeleteInstallerWithAssociatedPolicy = &fleet.ConflictError{Message: "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."}
errDeleteInstallerInstalledDuringSetup = &fleet.ConflictError{Message: "Couldn't delete. This software is installed when new Macs boot. Please remove software in Controls > Setup experience and try again."}
@ -1040,7 +1169,56 @@ ON DUPLICATE KEY UPDATE
user_name = VALUES(user_name),
user_email = VALUES(user_email),
url = VALUES(url),
install_during_setup = COALESCE(?, install_during_setup)
install_during_setup = COALESCE(?, install_during_setup)
`
const loadSoftwareInstallerID = `
SELECT
id
FROM
software_installers
WHERE
global_or_team_id = ? AND
-- this is guaranteed to select a single title_id, due to unique index
title_id IN (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = '')
`
const deleteInstallerLabelsNotInList = `
DELETE FROM
software_installer_labels
WHERE
software_installer_id = ? AND
label_id NOT IN (?)
`
const deleteAllInstallerLabels = `
DELETE FROM
software_installer_labels
WHERE
software_installer_id = ?
`
const upsertInstallerLabels = `
INSERT INTO
software_installer_labels (
software_installer_id,
label_id,
exclude
)
VALUES
%s
ON DUPLICATE KEY UPDATE
exclude = VALUES(exclude)
`
const loadExistingInstallerLabels = `
SELECT
label_id,
exclude
FROM
software_installer_labels
WHERE
software_installer_id = ?
`
// use a team id of 0 if no-team
@ -1058,7 +1236,7 @@ ON DUPLICATE KEY UPDATE
replacingInstallDuringSetup = true
}
if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// if no installers are provided, just delete whatever was in
// the table
if len(installers) == 0 {
@ -1159,6 +1337,10 @@ ON DUPLICATE KEY UPDATE
}
for _, installer := range installers {
if installer.ValidatedLabels == nil {
return ctxerr.Errorf(ctx, "labels have not been validated for installer with name %s", installer.Filename)
}
isRes, err := insertScriptContents(ctx, tx, installer.InstallScript)
if err != nil {
return ctxerr.Wrapf(ctx, err, "inserting install script contents for software installer with name %q", installer.Filename)
@ -1244,7 +1426,84 @@ ON DUPLICATE KEY UPDATE
return ctxerr.Wrapf(ctx, err, "insert new/edited installer with name %q", installer.Filename)
}
// perform side effects if this was an update
// now that the software installer is created/updated, load its installer
// ID (cannot use res.LastInsertID due to the upsert statement, won't
// give the id in case of update)
var installerID uint
if err := sqlx.GetContext(ctx, tx, &installerID, loadSoftwareInstallerID, globalOrTeamID, installer.Title, installer.Source); err != nil {
return ctxerr.Wrapf(ctx, err, "load id of new/edited installer with name %q", installer.Filename)
}
// process the labels associated with that software installer
if len(installer.ValidatedLabels.ByName) == 0 {
// no label to apply, so just delete all existing labels if any
res, err := tx.ExecContext(ctx, deleteAllInstallerLabels, installerID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "delete installer labels for %s", installer.Filename)
}
if n, _ := res.RowsAffected(); n > 0 && len(existing) > 0 {
// if it did delete a row, then the target changed so pending
// installs/uninstalls must be deleted
existing[0].IsMetadataModified = true
}
} else {
// there are new labels to apply, delete only the obsolete ones
labelIDs := make([]uint, 0, len(installer.ValidatedLabels.ByName))
for _, lbl := range installer.ValidatedLabels.ByName {
labelIDs = append(labelIDs, lbl.LabelID)
}
stmt, args, err := sqlx.In(deleteInstallerLabelsNotInList, installerID, labelIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to delete installer labels not in list")
}
res, err := tx.ExecContext(ctx, stmt, args...)
if err != nil {
return ctxerr.Wrapf(ctx, err, "delete installer labels not in list for %s", installer.Filename)
}
if n, _ := res.RowsAffected(); n > 0 && len(existing) > 0 {
// if it did delete a row, then the target changed so pending
// installs/uninstalls must be deleted
existing[0].IsMetadataModified = true
}
excludeLabels := installer.ValidatedLabels.LabelScope == fleet.LabelScopeExcludeAny
if len(existing) > 0 && !existing[0].IsMetadataModified {
// load the remaining labels for that installer, so that we can detect
// if any label changed (if the counts differ, then labels did change,
// otherwise if the exclude bool changed, the target did change).
var existingLabels []struct {
LabelID uint `db:"label_id"`
Exclude bool `db:"exclude"`
}
if err := sqlx.SelectContext(ctx, tx, &existingLabels, loadExistingInstallerLabels, installerID); err != nil {
return ctxerr.Wrapf(ctx, err, "load existing labels for installer with name %q", installer.Filename)
}
if len(existingLabels) != len(labelIDs) {
existing[0].IsMetadataModified = true
}
if len(existingLabels) > 0 && existingLabels[0].Exclude != excludeLabels {
// same labels are provided, but the include <-> exclude changed
existing[0].IsMetadataModified = true
}
}
// upsert the new labels now that obsolete ones have been deleted
var upsertLabelArgs []any
for _, lblID := range labelIDs {
upsertLabelArgs = append(upsertLabelArgs, installerID, lblID, excludeLabels)
}
upsertLabelValues := strings.TrimSuffix(strings.Repeat("(?,?,?),", len(installer.ValidatedLabels.ByName)), ",")
_, err = tx.ExecContext(ctx, fmt.Sprintf(upsertInstallerLabels, upsertLabelValues), upsertLabelArgs...)
if err != nil {
return ctxerr.Wrapf(ctx, err, "insert new/edited labels for installer with name %q", installer.Filename)
}
}
// perform side effects if this was an update (related to pending (un)install requests)
if len(existing) > 0 {
if err := ds.runInstallerUpdateSideEffectsInTransaction(
ctx,
@ -1259,10 +1518,7 @@ ON DUPLICATE KEY UPDATE
}
return nil
}); err != nil {
return err
}
return nil
})
}
func (ds *Datastore) HasSelfServiceSoftwareInstallers(ctx context.Context, hostPlatform string, hostTeamID *uint) (bool, error) {
@ -1367,3 +1623,79 @@ WHERE global_or_team_id = ?
}
return softwarePackages, nil
}
func (ds *Datastore) IsSoftwareInstallerLabelScoped(ctx context.Context, installerID, hostID uint) (bool, error) {
stmt := `
SELECT 1 FROM (
-- no labels
SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels
WHERE NOT EXISTS (
SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = :installer_id
)
UNION
-- include any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
0 as count_host_updated_after_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
AND lm.host_id = :host_id
WHERE
sil.software_installer_id = :installer_id
AND sil.exclude = 0
HAVING
count_installer_labels > 0 AND count_host_labels > 0
UNION
-- exclude any, ignore software that depends on labels created
-- _after_ the label_updated_at timestamp of the host (because
-- we don't have results for that label yet, the host may or may
-- not be a member).
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
SUM(CASE
WHEN
lbl.created_at IS NOT NULL AND (SELECT label_updated_at FROM hosts WHERE id = :host_id) >= lbl.created_at THEN 1
ELSE
0
END) as count_host_updated_after_labels
FROM
software_installer_labels sil
LEFT OUTER JOIN labels lbl
ON lbl.id = sil.label_id
LEFT OUTER JOIN label_membership lm
ON lm.label_id = sil.label_id AND lm.host_id = :host_id
WHERE
sil.software_installer_id = :installer_id
AND sil.exclude = 1
HAVING
count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0
) t
`
namedArgs := map[string]any{
"host_id": hostID,
"installer_id": installerID,
}
stmt, args, err := sqlx.Named(stmt, namedArgs)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "build named query for is software installer label scoped")
}
var res bool
if err := sqlx.GetContext(ctx, ds.reader(ctx), &res, stmt, args...); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, ctxerr.Wrap(ctx, err, "is software installer label scoped")
}
return res, nil
}

View file

@ -3,6 +3,7 @@ package mysql
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
@ -37,6 +38,7 @@ func TestSoftwareInstallers(t *testing.T) {
{"testDeletePendingSoftwareInstallsForPolicy", testDeletePendingSoftwareInstallsForPolicy},
{"GetHostLastInstallData", testGetHostLastInstallData},
{"GetOrGenerateSoftwareInstallerTitleID", testGetOrGenerateSoftwareInstallerTitleID},
{"BatchSetSoftwareInstallersScopedViaLabels", testBatchSetSoftwareInstallersScopedViaLabels},
}
for _, c := range cases {
@ -84,6 +86,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
Version: "1.0",
Source: "apps",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -100,6 +103,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
Version: "2.0",
Source: "apps",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -117,6 +121,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
Source: "apps",
SelfService: true,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -219,12 +224,13 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
require.Nil(t, si)
installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "foo",
Source: "bar",
InstallScript: "echo",
TeamID: teamID,
Filename: "foo.pkg",
UserID: user1.ID,
Title: "foo",
Source: "bar",
InstallScript: "echo",
TeamID: teamID,
Filename: "foo.pkg",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
@ -519,13 +525,14 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
// create a host and software installer
swFilename := "file_" + tc.name + ".pkg"
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "foo" + tc.name,
Source: "bar" + tc.name,
InstallScript: "echo " + tc.name,
Version: "1.11",
TeamID: &teamID,
Filename: swFilename,
UserID: user1.ID,
Title: "foo" + tc.name,
Source: "bar" + tc.name,
InstallScript: "echo " + tc.name,
Version: "1.11",
TeamID: &teamID,
Filename: swFilename,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
host, err := ds.NewHost(ctx, &fleet.Host{
@ -650,13 +657,14 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) {
assertExisting([]string{ins0})
swi, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
InstallerFile: tfr0,
StorageID: ins0,
Filename: "installer0",
Title: "ins0",
Source: "apps",
UserID: user1.ID,
InstallScript: "install",
InstallerFile: tfr0,
StorageID: ins0,
Filename: "installer0",
Title: "ins0",
Source: "apps",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -739,6 +747,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
UserID: user1.ID,
Platform: "darwin",
URL: "https://example.com",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}})
require.NoError(t, err)
softwareInstallers, err = ds.GetSoftwareInstallers(ctx, team.ID)
@ -772,6 +781,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
Platform: "darwin",
URL: "https://example.com",
InstallDuringSetup: ptr.Bool(true),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
},
{
InstallScript: "install",
@ -786,6 +796,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
UserID: user1.ID,
Platform: "darwin",
URL: "https://example2.com",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
},
})
require.NoError(t, err)
@ -818,6 +829,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
Version: "2",
PreInstallQuery: "select 1 from bar;",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
},
})
require.Error(t, err)
@ -839,6 +851,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
Platform: "darwin",
URL: "https://example.com",
InstallDuringSetup: nil,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
},
{
InstallScript: "install",
@ -853,6 +866,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
UserID: user1.ID,
Platform: "darwin",
URL: "https://example2.com",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
},
})
require.NoError(t, err)
@ -872,6 +886,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
Platform: "darwin",
URL: "https://example.com",
InstallDuringSetup: ptr.Bool(false),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
},
{
InstallScript: "install",
@ -886,6 +901,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
UserID: user1.ID,
Platform: "darwin",
URL: "https://example2.com",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
},
})
require.NoError(t, err)
@ -903,6 +919,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) {
Version: "2",
PreInstallQuery: "select 1 from bar;",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
},
})
require.NoError(t, err)
@ -941,6 +958,7 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor
Filename: "foo.pkg",
Platform: "darwin",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
@ -955,12 +973,13 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor
require.Equal(t, "SELECT 1", metaByTeamAndTitle.PreInstallQuery)
installerID, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "bar",
Source: "bar",
InstallScript: "echo install",
TeamID: &team.ID,
Filename: "foo.pkg",
UserID: user1.ID,
Title: "bar",
Source: "bar",
InstallScript: "echo install",
TeamID: &team.ID,
Filename: "foo.pkg",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
installerMeta, err = ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
@ -994,14 +1013,15 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
// Create a non-self service installer
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "foo",
Source: "bar",
InstallScript: "echo install",
TeamID: &team.ID,
Filename: "foo.pkg",
Platform: platform,
SelfService: false,
UserID: user1.ID,
Title: "foo",
Source: "bar",
InstallScript: "echo install",
TeamID: &team.ID,
Filename: "foo.pkg",
Platform: platform,
SelfService: false,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil)
@ -1013,14 +1033,15 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
// Create a self-service installer for team
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "foo2",
Source: "bar2",
InstallScript: "echo install",
TeamID: &team.ID,
Filename: "foo2.pkg",
Platform: platform,
SelfService: true,
UserID: user1.ID,
Title: "foo2",
Source: "bar2",
InstallScript: "echo install",
TeamID: &team.ID,
Filename: "foo2.pkg",
Platform: platform,
SelfService: true,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil)
@ -1052,14 +1073,15 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) {
// Create a global self-service installer
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "foo global",
Source: "bar",
InstallScript: "echo install",
TeamID: nil,
Filename: "foo global.pkg",
Platform: platform,
SelfService: true,
UserID: user1.ID,
Title: "foo global",
Source: "bar",
InstallScript: "echo install",
TeamID: nil,
Filename: "foo global.pkg",
Platform: platform,
SelfService: true,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "ubuntu", nil)
@ -1103,15 +1125,16 @@ func testDeleteSoftwareInstallers(t *testing.T, ds *Datastore) {
require.NoError(t, err)
softwareInstallerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
InstallerFile: tfr0,
StorageID: ins0,
Filename: "installer.pkg",
Title: "ins0",
Source: "apps",
Platform: "darwin",
TeamID: &team1.ID,
UserID: user1.ID,
InstallScript: "install",
InstallerFile: tfr0,
StorageID: ins0,
Filename: "installer.pkg",
Title: "ins0",
Source: "apps",
Platform: "darwin",
TeamID: &team1.ID,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -1177,15 +1200,16 @@ func testDeletePendingSoftwareInstallsForPolicy(t *testing.T, ds *Datastore) {
require.NoError(t, err)
installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
InstallerFile: tfr0,
StorageID: ins0,
Filename: "installer.pkg",
Title: "ins0",
Source: "apps",
Platform: "darwin",
TeamID: &team1.ID,
UserID: user1.ID,
InstallScript: "install",
InstallerFile: tfr0,
StorageID: ins0,
Filename: "installer.pkg",
Title: "ins0",
Source: "apps",
Platform: "darwin",
TeamID: &team1.ID,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -1197,15 +1221,16 @@ func testDeletePendingSoftwareInstallsForPolicy(t *testing.T, ds *Datastore) {
require.NoError(t, err)
installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
InstallerFile: tfr0,
StorageID: ins0,
Filename: "installer.pkg",
Title: "ins1",
Source: "apps",
Platform: "darwin",
TeamID: &team1.ID,
UserID: user1.ID,
InstallScript: "install",
InstallerFile: tfr0,
StorageID: ins0,
Filename: "installer.pkg",
Title: "ins1",
Source: "apps",
Platform: "darwin",
TeamID: &team1.ID,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -1292,27 +1317,29 @@ func testGetHostLastInstallData(t *testing.T, ds *Datastore) {
require.NoError(t, err)
softwareInstallerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
InstallerFile: tfr0,
StorageID: ins0,
Filename: "installer.pkg",
Title: "ins1",
Source: "apps",
Platform: "darwin",
TeamID: &team1.ID,
UserID: user1.ID,
InstallScript: "install",
InstallerFile: tfr0,
StorageID: ins0,
Filename: "installer.pkg",
Title: "ins1",
Source: "apps",
Platform: "darwin",
TeamID: &team1.ID,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
softwareInstallerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install2",
InstallerFile: tfr0,
StorageID: ins0,
Filename: "installer2.pkg",
Title: "ins2",
Source: "apps",
Platform: "darwin",
TeamID: &team1.ID,
UserID: user1.ID,
InstallScript: "install2",
InstallerFile: tfr0,
StorageID: ins0,
Filename: "installer2.pkg",
Title: "ins2",
Source: "apps",
Platform: "darwin",
TeamID: &team1.ID,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -1492,3 +1519,260 @@ func testGetOrGenerateSoftwareInstallerTitleID(t *testing.T, ds *Datastore) {
})
}
}
func testBatchSetSoftwareInstallersScopedViaLabels(t *testing.T, ds *Datastore) {
ctx := context.Background()
// create a host to have a pending install request
host := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now())
// create a couple teams and a user
tm1, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "1"})
require.NoError(t, err)
tm2, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "2"})
require.NoError(t, err)
user := test.NewUser(t, ds, "Alice", "alice@example.com", true)
// create some installer payloads to be used by test cases
installers := make([]*fleet.UploadSoftwareInstallerPayload, 3)
for i := range installers {
file := bytes.NewReader([]byte("installer" + fmt.Sprint(i)))
tfr, err := fleet.NewTempFileReader(file, t.TempDir)
require.NoError(t, err)
installers[i] = &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
InstallerFile: tfr,
StorageID: "installer" + fmt.Sprint(i),
Filename: "installer" + fmt.Sprint(i),
Title: "ins" + fmt.Sprint(i),
Source: "apps",
Version: "1",
PreInstallQuery: "foo",
UserID: user.ID,
Platform: "darwin",
URL: "https://example.com",
}
}
// create some labels to be used by test cases
labels := make([]*fleet.Label, 4)
for i := range labels {
lbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "label" + fmt.Sprint(i)})
require.NoError(t, err)
labels[i] = lbl
}
type testPayload struct {
Installer *fleet.UploadSoftwareInstallerPayload
Labels []*fleet.Label
Exclude bool
ShouldCancelPending *bool // nil if the installer is new (could not have pending), otherwise true/false if it was edited
}
// test scenarios - note that subtests must NOT be used as the sequence of
// tests matters - they cannot be run in isolation.
cases := []struct {
desc string
team *fleet.Team
payload []testPayload
}{
{
desc: "empty payload",
payload: nil,
},
{
desc: "no team, installer0, no label",
payload: []testPayload{
{Installer: installers[0]},
},
},
{
desc: "team 1, installer0, include label0",
team: tm1,
payload: []testPayload{
{Installer: installers[0], Labels: []*fleet.Label{labels[0]}},
},
},
{
desc: "no team, installer0 no change, add installer1 with exclude label1",
payload: []testPayload{
{Installer: installers[0], ShouldCancelPending: ptr.Bool(false)},
{Installer: installers[1], Labels: []*fleet.Label{labels[1]}, Exclude: true},
},
},
{
desc: "no team, installer0 no change, installer1 change to include label1",
payload: []testPayload{
{Installer: installers[0], ShouldCancelPending: ptr.Bool(false)},
{Installer: installers[1], Labels: []*fleet.Label{labels[1]}, Exclude: false, ShouldCancelPending: ptr.Bool(true)},
},
},
{
desc: "team 1, installer0, include label0 and add label1",
team: tm1,
payload: []testPayload{
{Installer: installers[0], Labels: []*fleet.Label{labels[0], labels[1]}, ShouldCancelPending: ptr.Bool(true)},
},
},
{
desc: "team 1, installer0, remove label0 and keep label1",
team: tm1,
payload: []testPayload{
{Installer: installers[0], Labels: []*fleet.Label{labels[1]}, ShouldCancelPending: ptr.Bool(true)},
},
},
{
desc: "team 1, installer0, switch to label0 and label2",
team: tm1,
payload: []testPayload{
{Installer: installers[0], Labels: []*fleet.Label{labels[0], labels[2]}, ShouldCancelPending: ptr.Bool(true)},
},
},
{
desc: "team 2, 3 installers, mix of labels",
team: tm2,
payload: []testPayload{
{Installer: installers[0], Labels: []*fleet.Label{labels[0]}, Exclude: false},
{Installer: installers[1], Labels: []*fleet.Label{labels[0], labels[1], labels[2]}, Exclude: true},
{Installer: installers[2], Labels: []*fleet.Label{labels[1], labels[2]}, Exclude: false},
},
},
{
desc: "team 1, installer0 no change and add installer2",
team: tm1,
payload: []testPayload{
{Installer: installers[0], Labels: []*fleet.Label{labels[0], labels[2]}, ShouldCancelPending: ptr.Bool(false)},
{Installer: installers[2]},
},
},
{
desc: "team 1, installer0 switch to labels 1 and 3, installer2 no change",
team: tm1,
payload: []testPayload{
{Installer: installers[0], Labels: []*fleet.Label{labels[1], labels[3]}, ShouldCancelPending: ptr.Bool(true)},
{Installer: installers[2], ShouldCancelPending: ptr.Bool(false)},
},
},
{
desc: "team 2, remove installer0, labels of install1 and no change installer2",
team: tm2,
payload: []testPayload{
{Installer: installers[1], ShouldCancelPending: ptr.Bool(true)},
{Installer: installers[2], Labels: []*fleet.Label{labels[1], labels[2]}, Exclude: false, ShouldCancelPending: ptr.Bool(false)},
},
},
{
desc: "no team, remove all",
payload: []testPayload{},
},
}
for _, c := range cases {
t.Log("Running test case ", c.desc)
var teamID *uint
var globalOrTeamID uint
if c.team != nil {
teamID = &c.team.ID
globalOrTeamID = c.team.ID
}
// cleanup any existing install requests for the host
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM host_software_installs WHERE host_id = ?`, host.ID)
return err
})
installerIDs := make([]uint, len(c.payload))
if len(c.payload) > 0 {
// create pending install requests for each updated installer, to see if
// it cancels it or not as expected.
err := ds.AddHostsToTeam(ctx, teamID, []uint{host.ID})
require.NoError(t, err)
for i, payload := range c.payload {
if payload.ShouldCancelPending != nil {
// the installer must exist
var swID uint
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
err := sqlx.GetContext(ctx, q, &swID, `SELECT id FROM software_installers WHERE global_or_team_id = ?
AND title_id IN (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = '')`,
globalOrTeamID, payload.Installer.Title, payload.Installer.Source)
return err
})
_, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, swID, false, nil)
require.NoError(t, err)
installerIDs[i] = swID
}
}
}
// create the payload by copying the test one, so that the original installers
// structs are not modified
payload := make([]*fleet.UploadSoftwareInstallerPayload, len(c.payload))
for i, p := range c.payload {
installer := *p.Installer
installer.ValidatedLabels = &fleet.LabelIdentsWithScope{LabelScope: fleet.LabelScopeIncludeAny}
if p.Exclude {
installer.ValidatedLabels.LabelScope = fleet.LabelScopeExcludeAny
}
byName := make(map[string]fleet.LabelIdent, len(p.Labels))
for _, lbl := range p.Labels {
byName[lbl.Name] = fleet.LabelIdent{LabelName: lbl.Name, LabelID: lbl.ID}
}
installer.ValidatedLabels.ByName = byName
payload[i] = &installer
}
err := ds.BatchSetSoftwareInstallers(ctx, teamID, payload)
require.NoError(t, err)
installers, err := ds.GetSoftwareInstallers(ctx, globalOrTeamID)
require.NoError(t, err)
require.Len(t, installers, len(c.payload))
// get the metadata for each installer to assert the batch did set the
// expected ones.
installersByFilename := make(map[string]*fleet.SoftwareInstaller, len(installers))
for _, ins := range installers {
meta, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, *ins.TitleID, false)
require.NoError(t, err)
installersByFilename[meta.Name] = meta
}
// validate that the inserted software is as expected
for i, payload := range c.payload {
meta, ok := installersByFilename[payload.Installer.Filename]
require.True(t, ok, "installer %s was not created", payload.Installer.Filename)
require.Equal(t, meta.SoftwareTitle, payload.Installer.Title)
wantLabelIDs := make([]uint, len(payload.Labels))
for j, lbl := range payload.Labels {
wantLabelIDs[j] = lbl.ID
}
if payload.Exclude {
require.Empty(t, meta.LabelsIncludeAny)
gotLabelIDs := make([]uint, len(meta.LabelsExcludeAny))
for i, lbl := range meta.LabelsExcludeAny {
gotLabelIDs[i] = lbl.LabelID
}
require.ElementsMatch(t, wantLabelIDs, gotLabelIDs)
} else {
require.Empty(t, meta.LabelsExcludeAny)
gotLabelIDs := make([]uint, len(meta.LabelsIncludeAny))
for j, lbl := range meta.LabelsIncludeAny {
gotLabelIDs[j] = lbl.LabelID
}
require.ElementsMatch(t, wantLabelIDs, gotLabelIDs)
}
// check if it deleted pending installs or not
if payload.ShouldCancelPending != nil {
lastInstall, err := ds.GetHostLastInstallData(ctx, host.ID, installerIDs[i])
require.NoError(t, err)
if *payload.ShouldCancelPending {
require.Nil(t, lastInstall, "should have cancelled pending installs")
} else {
require.NotNil(t, lastInstall, "should not have cancelled pending installs")
}
}
}
}
}

View file

@ -69,6 +69,7 @@ func TestSoftware(t *testing.T) {
{"ListHostSoftwareInstallThenTransferTeam", testListHostSoftwareInstallThenTransferTeam},
{"ListHostSoftwareInstallThenDeleteInstallers", testListHostSoftwareInstallThenDeleteInstallers},
{"ListSoftwareVersionsVulnerabilityFilters", testListSoftwareVersionsVulnerabilityFilters},
{"TestListHostSoftwareWithLabelScoping", testListHostSoftwareWithLabelScoping},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@ -4615,15 +4616,16 @@ func testListHostSoftwareInstallThenTransferTeam(t *testing.T, ds *Datastore) {
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installerTm1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
TeamID: &team1.ID,
UserID: user.ID,
InstallScript: "hello",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
TeamID: &team1.ID,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -4727,15 +4729,16 @@ func testListHostSoftwareInstallThenDeleteInstallers(t *testing.T, ds *Datastore
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installerTm1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
TeamID: &team1.ID,
UserID: user.ID,
InstallScript: "hello",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
TeamID: &team1.ID,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -5246,3 +5249,283 @@ func testListSoftwareVersionsVulnerabilityFilters(t *testing.T, ds *Datastore) {
})
}
}
func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) {
ctx := context.Background()
// create a host
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("darwin"))
nanoEnroll(t, ds, host, false)
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
time.Sleep(time.Second) // ensure the labels_updated_at timestamp is before labels creation
// create a software installer
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installer1 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
UninstallScript: "goodbye",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "file1",
Title: "file1",
Version: "1.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi1",
Platform: "darwin",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, installer1)
require.NoError(t, err)
// we should see installer1, since it has no label associated yet
opts := fleet.HostSoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
PerPage: 11,
IncludeMetadata: true,
OrderKey: "name",
TestSecondaryOrderKey: "source",
},
IncludeAvailableForInstall: true,
}
expectedInstallers := map[string]*fleet.SoftwarePackageOrApp{
installer1.Filename: {
Name: installer1.Filename,
Version: installer1.Version,
SelfService: ptr.Bool(false),
},
}
checkSoftware := func(swList []*fleet.HostSoftwareWithInstaller, excludeNames ...string) {
for _, got := range swList {
want, ok := expectedInstallers[got.SoftwarePackage.Name]
if slices.Contains(excludeNames, got.SoftwarePackage.Name) {
require.False(t, ok)
continue
}
require.True(t, ok)
require.Equal(t, want, got.SoftwarePackage)
}
}
software, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software)
// installer1 should be in scope since it has no labels
scoped, err := ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.True(t, scoped)
label1, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1" + t.Name()})
require.NoError(t, err)
// assign the label to the host
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label1.ID}))
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
time.Sleep(time.Second)
// assign the label to the software installer
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}},
})
require.NoError(t, err)
// should be empty as the installer label is "exclude any"
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Empty(t, software)
// installer1 should be out of scope since the label is "exclude any"
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.False(t, scoped)
// Update the label to be "include any"
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}},
})
require.NoError(t, err)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software)
// Now installer1 is in scope again: label is "include any"
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// Add an installer. No label yet.
installer2 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
UninstallScript: "goodbye",
InstallerFile: tfr1,
StorageID: "storage2",
Filename: "file2",
Title: "file2",
Version: "2.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi2",
Platform: "darwin",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, installer2)
require.NoError(t, err)
expectedInstallers[installer2.Filename] = &fleet.SoftwarePackageOrApp{
Name: installer2.Filename,
Version: installer2.Version,
SelfService: ptr.Bool(false),
}
// There's 2 installers now: installerID1 and installerID2 (because it has no labels associated)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software)
// Add "exclude any" labels to installer2
label2, err := ds.NewLabel(ctx, &fleet.Label{Name: "label2" + t.Name()})
require.NoError(t, err)
label3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3" + t.Name()})
require.NoError(t, err)
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID2, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{
label2.Name: {LabelName: label2.Name, LabelID: label2.ID},
label3.Name: {LabelName: label3.Name, LabelID: label3.ID},
},
})
require.NoError(t, err)
// Now host has label1, label2
require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label2.ID}))
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
time.Sleep(time.Second)
// List should be back to just installer1
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, installer2.Filename)
// installer1 is still in scope
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// installer2 is out of scope, because host has label2
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID2, host.ID)
require.NoError(t, err)
require.False(t, scoped)
// Add an installer. No label yet.
installer3 := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
UninstallScript: "goodbye",
InstallerFile: tfr1,
StorageID: "storage3",
Filename: "file3",
Title: "file3",
Version: "3.0",
Source: "apps",
UserID: user1.ID,
BundleIdentifier: "bi3",
Platform: "darwin",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
installerID3, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, installer3)
require.NoError(t, err)
time.Sleep(time.Second)
expectedInstallers[installer3.Filename] = &fleet.SoftwarePackageOrApp{
Name: installer3.Filename,
Version: installer3.Version,
SelfService: ptr.Bool(false),
}
// Add a new label and apply it to the installer. There are no hosts with this label.
label4, err := ds.NewLabel(ctx, &fleet.Label{Name: "label4" + t.Name()})
require.NoError(t, err)
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID3, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{label4.Name: {LabelName: label4.Name, LabelID: label4.ID}},
})
require.NoError(t, err)
// We should have [installerID1, installerID3], but the exclude any label has
// no results for this host yet, so it's just installerID1 for now.
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, software, 1)
// installer1 is still in scope
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// installer3 is not in scope yet, because label is "exclude any" and host doesn't have results
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID3, host.ID)
require.NoError(t, err)
require.False(t, scoped)
// mark as if label had been reported (but host is still not a member)
host.LabelUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
time.Sleep(time.Second)
// now has 2 software (installer1 and 3)
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, installer2.Filename)
// installer1 is still in scope
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// installer3 is in scope, because label is "exclude any" and host doesn't have the label
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID3, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// Now include hosts with label4. No host has this label, so we shouldn't see installerID3 anymore.
err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID3, fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{label4.Name: {LabelName: label4.Name, LabelID: label4.ID}},
})
require.NoError(t, err)
// We should have [installerID1]
software, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
checkSoftware(software, installer2.Filename, installer3.Filename)
// installer1 is still in scope
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID)
require.NoError(t, err)
require.True(t, scoped)
// installer3 is not in scope
scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID3, host.ID)
require.NoError(t, err)
require.False(t, scoped)
}

View file

@ -301,11 +301,12 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) {
// create a software installer not installed on any host
installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "installer1",
Source: "apps",
InstallScript: "echo",
Filename: "installer1.pkg",
UserID: user1.ID,
Title: "installer1",
Source: "apps",
InstallScript: "echo",
Filename: "installer1.pkg",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, installer1)
@ -316,11 +317,12 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) {
})
// create a software installer with an install request on host1
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "installer2",
Source: "apps",
InstallScript: "echo",
Filename: "installer2.pkg",
UserID: user1.ID,
Title: "installer2",
Source: "apps",
InstallScript: "echo",
Filename: "installer2.pkg",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
_, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2, false, nil)
@ -639,6 +641,7 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) {
BundleIdentifier: "foo.bar",
TeamID: &team1.ID,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, installer1)
@ -649,12 +652,13 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) {
})
// create a software installer for team2
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "installer2",
Source: "apps",
InstallScript: "echo",
Filename: "installer2.pkg",
TeamID: &team2.ID,
UserID: user1.ID,
Title: "installer2",
Source: "apps",
InstallScript: "echo",
Filename: "installer2.pkg",
TeamID: &team2.ID,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, installer2)
@ -876,20 +880,22 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) {
// create a couple software installers not installed on any host
installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "installer1",
Source: "apps",
InstallScript: "echo",
Filename: "installer1.pkg",
UserID: user1.ID,
Title: "installer1",
Source: "apps",
InstallScript: "echo",
Filename: "installer1.pkg",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, installer1)
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "installer2",
Source: "apps",
InstallScript: "echo",
Filename: "installer2.pkg",
UserID: user1.ID,
Title: "installer2",
Source: "apps",
InstallScript: "echo",
Filename: "installer2.pkg",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, installer2)
@ -981,20 +987,22 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore
// create 2 software installers
installer1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "installer1",
Source: "apps",
InstallScript: "echo",
Filename: "installer1.pkg",
UserID: user1.ID,
Title: "installer1",
Source: "apps",
InstallScript: "echo",
Filename: "installer1.pkg",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, installer1)
installer2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "installer2",
Source: "apps",
InstallScript: "echo",
Filename: "installer2.pkg",
UserID: user1.ID,
Title: "installer2",
Source: "apps",
InstallScript: "echo",
Filename: "installer2.pkg",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, installer2)
@ -1178,6 +1186,7 @@ func testListSoftwareTitlesAllTeams(t *testing.T, ds *Datastore) {
Filename: "foobar.pkg",
TeamID: nil,
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
@ -1361,6 +1370,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) {
Filename: "installer1.pkg",
BundleIdentifier: "com.foo.installer1",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, installer1)
@ -1372,6 +1382,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) {
TeamID: &tm.ID,
BundleIdentifier: "com.foo.installer2",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, installer2)

View file

@ -67,6 +67,14 @@ func connectMySQL(t testing.TB, testName string, opts *DatastoreTestOptions) *Da
tc := config.TestConfig()
tc.Osquery.MinSoftwareLastOpenedAtDiff = defaultMinLastOpenedAtDiff
// TODO: for some reason we never log datastore messages when running integration tests, why?
//
// Changes below assume that we want to follows the same pattern as the rest of the codebase.
dslogger := log.NewLogfmtLogger(os.Stdout)
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
dslogger = log.NewNopLogger()
}
// set SQL mode to ANSI, as it's a special mode equivalent to:
// REAL_AS_FLOAT, PIPES_AS_CONCAT, ANSI_QUOTES, IGNORE_SPACE, and
// ONLY_FULL_GROUP_BY
@ -76,7 +84,7 @@ func connectMySQL(t testing.TB, testName string, opts *DatastoreTestOptions) *Da
// standard SQL.
//
// https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_ansi
ds, err := New(cfg, clock.NewMockClock(), Logger(log.NewNopLogger()), LimitAttempts(1), replicaOpt, SQLMode("ANSI"), WithFleetConfig(&tc))
ds, err := New(cfg, clock.NewMockClock(), Logger(dslogger), LimitAttempts(1), replicaOpt, SQLMode("ANSI"), WithFleetConfig(&tc))
require.Nil(t, err)
if opts.DummyReplica {

View file

@ -1663,13 +1663,20 @@ func (a ActivityTypeUninstalledSoftware) Documentation() (activity, details, det
}`
}
type ActivitySoftwareLabel struct {
Name string `json:"name"`
ID uint `json:"id"`
}
type ActivityTypeAddedSoftware struct {
SoftwareTitle string `json:"software_title"`
SoftwarePackage string `json:"software_package"`
TeamName *string `json:"team_name"`
TeamID *uint `json:"team_id"`
SelfService bool `json:"self_service"`
SoftwareTitleID uint `json:"software_title_id"`
SoftwareTitle string `json:"software_title"`
SoftwarePackage string `json:"software_package"`
TeamName *string `json:"team_name"`
TeamID *uint `json:"team_id"`
SelfService bool `json:"self_service"`
SoftwareTitleID uint `json:"software_title_id"`
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
}
func (a ActivityTypeAddedSoftware) ActivityName() string {
@ -1683,22 +1690,36 @@ func (a ActivityTypeAddedSoftware) Documentation() (string, string, string) {
- "team_name": Name of the team to which this software was added.` + " `null` " + `if it was added to no team." +
- "team_id": The ID of the team to which this software was added.` + " `null` " + `if it was added to no team.
- "self_service": Whether the software is available for installation by the end user.
- "software_title_id": ID of the added software title.`, `{
- "software_title_id": ID of the added software title.
- "labels_include_any": Target hosts that have any label in the array.
- "labels_exclude_any": Target hosts that don't have any label in the array.`, `{
"software_title": "Falcon.app",
"software_package": "FalconSensor-6.44.pkg",
"team_name": "Workstations",
"team_id": 123,
"self_service": true,
"software_title_id": 2234
"software_title_id": 2234,
"labels_include_any": [
{
"name": "Engineering",
"id": 12
},
{
"name": "Product",
"id": 17
}
]
}`
}
type ActivityTypeEditedSoftware struct {
SoftwareTitle string `json:"software_title"`
SoftwarePackage *string `json:"software_package"`
TeamName *string `json:"team_name"`
TeamID *uint `json:"team_id"`
SelfService bool `json:"self_service"`
SoftwareTitle string `json:"software_title"`
SoftwarePackage *string `json:"software_package"`
TeamName *string `json:"team_name"`
TeamID *uint `json:"team_id"`
SelfService bool `json:"self_service"`
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
}
func (a ActivityTypeEditedSoftware) ActivityName() string {
@ -1711,21 +1732,35 @@ func (a ActivityTypeEditedSoftware) Documentation() (string, string, string) {
- "software_package": Filename of the installer as of this update (including if unchanged).
- "team_name": Name of the team on which this software was updated.` + " `null` " + `if it was updated on no team.
- "team_id": The ID of the team on which this software was updated.` + " `null` " + `if it was updated on no team.
- "self_service": Whether the software is available for installation by the end user.`, `{
- "self_service": Whether the software is available for installation by the end user.
- "labels_include_any": Target hosts that have any label in the array.
- "labels_exclude_any": Target hosts that don't have any label in the array.`, `{
"software_title": "Falcon.app",
"software_package": "FalconSensor-6.44.pkg",
"team_name": "Workstations",
"team_id": 123,
"self_service": true
"self_service": true,
"labels_include_any": [
{
"name": "Engineering",
"id": 12
},
{
"name": "Product",
"id": 17
}
]
}`
}
type ActivityTypeDeletedSoftware struct {
SoftwareTitle string `json:"software_title"`
SoftwarePackage string `json:"software_package"`
TeamName *string `json:"team_name"`
TeamID *uint `json:"team_id"`
SelfService bool `json:"self_service"`
SoftwareTitle string `json:"software_title"`
SoftwarePackage string `json:"software_package"`
TeamName *string `json:"team_name"`
TeamID *uint `json:"team_id"`
SelfService bool `json:"self_service"`
LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"`
LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"`
}
func (a ActivityTypeDeletedSoftware) ActivityName() string {
@ -1738,12 +1773,24 @@ func (a ActivityTypeDeletedSoftware) Documentation() (string, string, string) {
- "software_package": Filename of the installer.
- "team_name": Name of the team to which this software was added.` + " `null` " + `if it was added to no team.
- "team_id": The ID of the team to which this software was added.` + " `null` " + `if it was added to no team.
- "self_service": Whether the software was available for installation by the end user.`, `{
- "self_service": Whether the software was available for installation by the end user.
- "labels_include_any": Target hosts that have any label in the array.
- "labels_exclude_any": Target hosts that don't have any label in the array.`, `{
"software_title": "Falcon.app",
"software_package": "FalconSensor-6.44.pkg",
"team_name": "Workstations",
"team_id": 123,
"self_service": true
"self_service": true,
"labels_include_any": [
{
"name": "Engineering",
"id": 12
},
{
"name": "Product",
"id": 17
}
]
}`
}

View file

@ -609,6 +609,10 @@ type Datastore interface {
ListHostSoftware(ctx context.Context, host *Host, opts HostSoftwareTitleListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error)
// IsSoftwareInstallerLabelScoped returns whether or not the given installerID is scoped to the
// given host ID by labels.
IsSoftwareInstallerLabelScoped(ctx context.Context, installerID, hostID uint) (bool, error)
// SetHostSoftwareInstallResult records the result of a software installation
// attempt on the host.
SetHostSoftwareInstallResult(ctx context.Context, result *HostSoftwareInstallResultPayload) error

View file

@ -194,3 +194,30 @@ func DetectMissingLabels(validLabelMap map[string]uint, unvalidatedLabels []stri
return missingLabels
}
// LabelIdent is a simple struct to hold the ID and Name of a label
type LabelIdent struct {
LabelID uint
LabelName string
}
// LabelScope identifies the manner by which labels may be used to scope entities, such as MDM
// profiles and software installers, to subsets of hosts.
type LabelScope string
const (
// LabelScopeExcludeAny indicates that a label-scoped entity (e.g., MDM profiles, software
// installers) should NOT be applied to a host if the host is a mamber of any of the associated labels.
LabelScopeExcludeAny LabelScope = "exclude_any"
// LabelScopeIncludeAny indicates that a label-scoped entity (e.g., MDM profiles, software
// installers) should be applied to a host that if the host is a member of all of the associated labels.
LabelScopeIncludeAny LabelScope = "include_any"
// LabelScopeIncludeAll indicates that a label-scoped entity (e.g., MDM profiles, software
// installers) should be applied to a host if the host is a member of all of the associated labels.
LabelScopeIncludeAll LabelScope = "include_all"
)
type LabelIdentsWithScope struct {
LabelScope LabelScope
ByName map[string]LabelIdent
}

View file

@ -380,15 +380,20 @@ type ScriptPayload struct {
}
type SoftwareInstallerPayload struct {
URL string `json:"url"`
PreInstallQuery string `json:"pre_install_query"`
InstallScript string `json:"install_script"`
UninstallScript string `json:"uninstall_script"`
PostInstallScript string `json:"post_install_script"`
SelfService bool `json:"self_service"`
FleetMaintained bool `json:"-"`
Filename string `json:"-"`
InstallDuringSetup *bool `json:"install_during_setup"` // if nil, do not change saved value, otherwise set it
URL string `json:"url"`
PreInstallQuery string `json:"pre_install_query"`
InstallScript string `json:"install_script"`
UninstallScript string `json:"uninstall_script"`
PostInstallScript string `json:"post_install_script"`
SelfService bool `json:"self_service"`
FleetMaintained bool `json:"-"`
Filename string `json:"-"`
InstallDuringSetup *bool `json:"install_during_setup"` // if nil, do not change saved value, otherwise set it
LabelsIncludeAny []string `json:"labels_include_any"`
LabelsExcludeAny []string `json:"labels_exclude_any"`
// ValidatedLabels is a struct that contains the validated labels for the
// software installer. It is nil if the labels have not been validated.
ValidatedLabels *LabelIdentsWithScope
}
type HostLockWipeStatus struct {

View file

@ -259,6 +259,11 @@ type Service interface {
// ListHostsInLabel returns a slice of hosts in the label with the given ID.
ListHostsInLabel(ctx context.Context, lid uint, opt HostListOptions) ([]*Host, error)
// BatchValidateLabels validates that each of the provided label names exists. The returned map
// is keyed by label name. Caller must ensure that appropirate authorization checks are
// performed prior to calling this method.
BatchValidateLabels(ctx context.Context, labelNames []string) (map[string]LabelIdent, error)
// /////////////////////////////////////////////////////////////////////////////
// QueryService
@ -649,7 +654,7 @@ type Service interface {
// BatchSetSoftwareInstallers asynchronously replaces the software installers for a specified team.
// Returns a request UUID that can be used to track an ongoing batch request (with GetBatchSetSoftwareInstallersResult).
BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []SoftwareInstallerPayload, dryRun bool) (string, error)
BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []*SoftwareInstallerPayload, dryRun bool) (string, error)
// GetBatchSetSoftwareInstallersResult polls for the status of a batch-apply started by BatchSetSoftwareInstallers.
// Return values:
// - 'status': status of the batch-apply which can be "processing", "completed" or "failed".
@ -1163,7 +1168,7 @@ type Service interface {
// Fleet-maintained apps
// AddFleetMaintainedApp adds a Fleet-maintained app to the given team.
AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) (uint, error)
AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, labelsIncludeAny, labelsExcludeAny []string) (uint, error)
// ListFleetMaintainedApps lists Fleet-maintained apps available to a specific team
ListFleetMaintainedApps(ctx context.Context, teamID *uint, opts ListOptions) ([]MaintainedApp, *PaginationMetadata, error)
// GetFleetMaintainedApp returns a Fleet-maintained app by ID

View file

@ -127,6 +127,10 @@ type SoftwareInstaller struct {
// AutomaticInstallPolicies is the list of policies that trigger automatic
// installation of this software.
AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies" db:"-"`
// LablesIncludeAny is the list of "include any" labels for this software installer (if not nil).
LabelsIncludeAny []SoftwareScopeLabel `json:"labels_include_any" db:"labels_include_any"`
// LabelsExcludeAny is the list of "exclude any" labels for this software installer (if not nil).
LabelsExcludeAny []SoftwareScopeLabel `json:"labels_exclude_any" db:"labels_exclude_any"`
}
// SoftwarePackageResponse is the response type used when applying software by batch.
@ -331,7 +335,12 @@ type UploadSoftwareInstallerPayload struct {
PackageIDs []string
UninstallScript string
Extension string
InstallDuringSetup *bool // keep saved value if nil, otherwise set as indicated
InstallDuringSetup *bool // keep saved value if nil, otherwise set as indicated
LabelsIncludeAny []string // names of "include any" labels
LabelsExcludeAny []string // names of "exclude any" labels
// ValidatedLabels is a struct that contains the validated labels for the software installer. It
// is nil if the labels have not been validated.
ValidatedLabels *LabelIdentsWithScope
}
type UpdateSoftwareInstallerPayload struct {
@ -356,6 +365,11 @@ type UpdateSoftwareInstallerPayload struct {
Filename string
Version string
PackageIDs []string
LabelsIncludeAny []string // names of "include any" labels
LabelsExcludeAny []string // names of "exclude any" labels
// ValidatedLabels is a struct that contains the validated labels for the software installer. It
// can be nil if the labels have not been validated or if the labels are not being updated.
ValidatedLabels *LabelIdentsWithScope
}
// DownloadSoftwareInstallerPayload is the payload for downloading a software installer.
@ -452,6 +466,8 @@ type SoftwarePackageSpec struct {
InstallScript TeamSpecSoftwareAsset `json:"install_script"`
PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"`
UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"`
LabelsIncludeAny []string `json:"labels_include_any"`
LabelsExcludeAny []string `json:"labels_exclude_any"`
// ReferencedYamlPath is the resolved path of the file used to fill the
// software package. Only present after parsing a GitOps file on the fleetctl
@ -616,3 +632,18 @@ func NewTempFileReader(from io.Reader, tempDirFn func() string) (*TempFileReader
}
return tfr, nil
}
// SoftwareScopeLabel represents the many-to-many relationship between
// software titles and labels.
//
// NOTE: json representation of the fields is a bit awkward to match the
// required API response, as this struct is returned within software title details.
//
// NOTE: depending on how/where this struct is used, fields MAY BE
// UNRELIABLE insofar as they represent default, empty values.
type SoftwareScopeLabel struct {
LabelName string `db:"label_name" json:"name"`
LabelID uint `db:"label_id" json:"id"` // label id in database, which may be the empty value in some cases where id is not known in advance (e.g., if labels are created during gitops processing)
Exclude bool `db:"exclude" json:"-"` // not rendered in JSON, used when processing LabelsIncludeAny and LabelsExcludeAny on parent title (may be the empty value in some cases)
TitleID uint `db:"title_id" json:"-"` // not rendered in JSON, used to store the associated title ID (may be the empty value in some cases)
}

View file

@ -451,6 +451,8 @@ type ListCVEsFunc func(ctx context.Context, maxAge time.Duration) ([]fleet.CVEMe
type ListHostSoftwareFunc func(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error)
type IsSoftwareInstallerLabelScopedFunc func(ctx context.Context, installerID uint, hostID uint) (bool, error)
type SetHostSoftwareInstallResultFunc func(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error
type UploadedSoftwareExistsFunc func(ctx context.Context, bundleIdentifier string, teamID *uint) (bool, error)
@ -1827,6 +1829,9 @@ type DataStore struct {
ListHostSoftwareFunc ListHostSoftwareFunc
ListHostSoftwareFuncInvoked bool
IsSoftwareInstallerLabelScopedFunc IsSoftwareInstallerLabelScopedFunc
IsSoftwareInstallerLabelScopedFuncInvoked bool
SetHostSoftwareInstallResultFunc SetHostSoftwareInstallResultFunc
SetHostSoftwareInstallResultFuncInvoked bool
@ -4430,6 +4435,13 @@ func (s *DataStore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts
return s.ListHostSoftwareFunc(ctx, host, opts)
}
func (s *DataStore) IsSoftwareInstallerLabelScoped(ctx context.Context, installerID uint, hostID uint) (bool, error) {
s.mu.Lock()
s.IsSoftwareInstallerLabelScopedFuncInvoked = true
s.mu.Unlock()
return s.IsSoftwareInstallerLabelScopedFunc(ctx, installerID, hostID)
}
func (s *DataStore) SetHostSoftwareInstallResult(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error {
s.mu.Lock()
s.SetHostSoftwareInstallResultFuncInvoked = true

View file

@ -1007,6 +1007,8 @@ func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuri
PostInstallScript: string(pc),
UninstallScript: string(us),
InstallDuringSetup: installDuringSetup,
LabelsIncludeAny: si.LabelsIncludeAny,
LabelsExcludeAny: si.LabelsExcludeAny,
}
}

View file

@ -404,6 +404,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
// Fleet-maintained apps
ue.POST("/api/_version_/fleet/software/fleet_maintained_apps", addFleetMaintainedAppEndpoint, addFleetMaintainedAppRequest{})
ue.PATCH("/api/_version_/fleet/software/fleet_maintained_apps", editFleetMaintainedAppEndpoint, editFleetMaintainedAppRequest{})
ue.GET("/api/_version_/fleet/software/fleet_maintained_apps", listFleetMaintainedAppsEndpoint, listFleetMaintainedAppsRequest{})
ue.GET("/api/_version_/fleet/software/fleet_maintained_apps/{app_id}", getFleetMaintainedApp, getFleetMaintainedAppRequest{})

View file

@ -11990,14 +11990,15 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() {
tfr1, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
require.NoError(t, err)
sw1, _, err := s.ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install foo",
InstallerFile: tfr1,
StorageID: uuid.NewString(),
Filename: "foo.pkg",
Title: "foo",
Source: "apps",
Version: "0.0.1",
UserID: adminUser.ID,
InstallScript: "install foo",
InstallerFile: tfr1,
StorageID: uuid.NewString(),
Filename: "foo.pkg",
Title: "foo",
Source: "apps",
Version: "0.0.1",
UserID: adminUser.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
s1Meta, err := s.ds.GetSoftwareInstallerMetadataByID(ctx, sw1)

View file

@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
@ -10461,6 +10462,69 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
require.Equal(t, payload.Filename, getDeviceSw.Software[0].SoftwarePackage.Name)
require.Equal(t, payload.Version, getDeviceSw.Software[0].SoftwarePackage.Version)
// =========================================
// test label scoping
// =========================================
// TODO(JVE): remove/update this once the API is in place
updateInstallerLabel := func(siID, labelID uint, exclude bool) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(
ctx,
`INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)`,
siID, labelID, exclude,
)
return err
})
}
var installerID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &installerID, "SELECT id FROM software_installers WHERE title_id = ?", titleID)
})
require.NotEmpty(t, installerID)
// create some labels
var labelResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: "label1",
Hosts: []string{host.Hostname},
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: "label2",
Hosts: []string{host.Hostname},
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)
// Set to "exclude any". Installer should be missing from the response for both host details and
// for self service
updateInstallerLabel(installerID, labelResp.Label.ID, true)
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
require.Empty(t, getHostSw.Software)
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Empty(t, getDeviceSw.Software)
// Set to "include any". Installer should be in response.
updateInstallerLabel(installerID, labelResp.Label.ID, false)
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true")
require.Len(t, getHostSw.Software, 1)
require.Equal(t, getHostSw.Software[0].Name, "ruby")
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK)
getDeviceSw = getDeviceSoftwareResponse{}
err = json.NewDecoder(res.Body).Decode(&getDeviceSw)
require.NoError(t, err)
require.Len(t, getDeviceSw.Software, 1)
require.Equal(t, getDeviceSw.Software[0].Name, "ruby")
// request installation on the host
var installResp installSoftwareResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install",
@ -10653,21 +10717,63 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
require.Equal(t, checkSoftwareTitle(t, payload.Title, "deb_packages"), *meta.TitleID)
require.NotZero(t, meta.UploadedAt)
// get metadata by team and title ID so we can check labels
meta2, err := s.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(context.Background(), payload.TeamID, *meta.TitleID, false)
require.NoError(t, err)
// check labels include any
require.Len(t, meta2.LabelsIncludeAny, len(payload.LabelsIncludeAny))
byName := make(map[string]struct{}, len(meta2.LabelsIncludeAny))
for _, l := range meta2.LabelsIncludeAny {
byName[l.LabelName] = struct{}{}
require.Equal(t, *meta2.TitleID, l.TitleID)
require.False(t, l.Exclude)
}
require.Len(t, byName, len(payload.LabelsIncludeAny))
for _, l := range payload.LabelsIncludeAny {
_, ok := byName[l]
require.True(t, ok)
}
// check labels exclude any
require.Len(t, meta2.LabelsExcludeAny, len(payload.LabelsExcludeAny))
byName = make(map[string]struct{}, len(meta2.LabelsExcludeAny))
for _, l := range meta2.LabelsExcludeAny {
byName[l.LabelName] = struct{}{}
require.Equal(t, *meta2.TitleID, l.TitleID)
require.True(t, l.Exclude)
}
require.Len(t, byName, len(payload.LabelsExcludeAny))
for _, l := range payload.LabelsExcludeAny {
_, ok := byName[l]
require.True(t, ok)
}
return meta.InstallerID, *meta.TitleID
}
t.Run("upload no team software installer", func(t *testing.T) {
// status is reflected in list hosts responses and counts when filtering by software title and status
// create a label to test also the counts per label with the software install status filter
var labelResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: t.Name(),
Query: "select 1",
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some install script",
PreInstallQuery: "some pre install query",
PostInstallScript: "some post install script",
Filename: "ruby.deb",
// additional fields below are pre-populated so we can re-use the payload later for the test assertions
Title: "ruby",
Version: "1:2.5.1",
Source: "deb_packages",
StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
Platform: "linux",
Title: "ruby",
Version: "1:2.5.1",
Source: "deb_packages",
StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628",
Platform: "linux",
LabelsIncludeAny: []string{t.Name()},
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
@ -10676,12 +10782,75 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
_, titleID := checkSoftwareInstaller(t, payload)
// check activity
activityData := fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null, "self_service": false, "software_title_id": %d}`, titleID)
s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0)
activityData := fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null,
"team_id": null, "self_service": false, "software_title_id": %d, "labels_include_any": [{"id": %d, "name": %q}]}`,
titleID, labelResp.Label.ID, t.Name())
s.lastActivityMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0)
// upload again fails
s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already exists")
// patch the software installer to change the labels
body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
"team_id": {"0"},
"labels_exclude_any": {t.Name()},
})
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers)
expectedPayload := *payload
expectedPayload.LabelsIncludeAny = nil
expectedPayload.LabelsExcludeAny = []string{labelResp.Label.Name}
checkSoftwareInstaller(t, &expectedPayload)
// Create a host and assign the label to it
host := createOrbitEnrolledHost(t, "linux", "label_host", s.ds)
err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{labelResp.Label.ID: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
// Attempt to install. Should fail because label is "exclude any"
resp := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(resp.Body), "Couldn't install. Host isn't member of the labels defined for this software title.")
// patch the software installer again but this time change the pre install query and leave the labels as is
body, headers = generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{
"team_id": {"0"},
"pre_install_query": {"some other pre install query"},
})
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers)
expectedPayload.PreInstallQuery = "some other pre install query"
expectedPayload.LabelsIncludeAny = nil // no change
expectedPayload.LabelsExcludeAny = []string{labelResp.Label.Name} // no change
checkSoftwareInstaller(t, &expectedPayload)
// update the label to be "include any". This should allow for the installation to happen.
var b3 bytes.Buffer
w3 := multipart.NewWriter(&b3)
require.NoError(t, w3.WriteField("team_id", "0"))
require.NoError(t, w3.WriteField("pre_install_query", "some other pre install query"))
require.NoError(t, w3.WriteField("labels_include_any", labelResp.Label.Name))
w3.Close()
headers = map[string]string{
"Content-Type": w3.FormDataContentType(),
"Accept": "application/json",
"Authorization": fmt.Sprintf("Bearer %s", s.token),
}
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), b3.Bytes(), http.StatusOK, headers)
expectedPayload.PreInstallQuery = "some other pre install query"
expectedPayload.LabelsIncludeAny = []string{labelResp.Label.Name}
expectedPayload.LabelsExcludeAny = nil
checkSoftwareInstaller(t, &expectedPayload)
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusAccepted)
// update the installer succeeds
body, headers = generateMultipartRequest(t, "software",
"", []byte{}, s.token, map[string][]string{"self_service": {"true"}, "team_id": {"0"}})
s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers)
activityData = fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null,
"team_id": null, "self_service": true, "labels_include_any": [{"id": %d, "name": %q}]}`,
labelResp.Label.ID, labelResp.Label.Name)
s.lastActivityMatches(fleet.ActivityTypeEditedSoftware{}.ActivityName(), activityData, 0)
// orbit-downloading fails with invalid orbit node key
s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{
InstallerID: 123,
@ -10696,6 +10865,10 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
// delete from team 0 succeeds
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", "0")
activityData = fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null,
"team_id": null, "self_service": true, "labels_include_any": [{"id": %d, "name": %q}]}`,
labelResp.Label.ID, labelResp.Label.Name)
s.lastActivityMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), activityData, 0)
})
t.Run("create team software installer", func(t *testing.T) {
@ -10912,7 +11085,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD
// check activity
s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(),
`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": 0, "self_service": true}`, 0)
`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null, "self_service": true}`, 0)
// download the installer, not found anymore
s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprintf("%d", 0))
@ -11243,6 +11416,7 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() {
func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
t := s.T()
ctx := context.Background()
// non-existent team
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{}, http.StatusNotFound, "team_name", "foo")
@ -11255,13 +11429,13 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
require.NoError(t, err)
// software with a bad URL
softwareToInstall := []fleet.SoftwareInstallerPayload{
softwareToInstall := []*fleet.SoftwareInstallerPayload{
{URL: "."},
}
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", tm.Name)
// software with a too big URL
softwareToInstall = []fleet.SoftwareInstallerPayload{
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: "https://ftp.mozilla.org/" + strings.Repeat("a", 4000-23)},
}
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", tm.Name)
@ -11284,7 +11458,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
t.Cleanup(srv.Close)
// do a request with a URL that returns a 404.
softwareToInstall = []fleet.SoftwareInstallerPayload{
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: srv.URL + "/not_found.pkg"},
}
var batchResponse batchSetSoftwareInstallersResponse
@ -11295,7 +11469,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
// do a request with a valid URL
rubyURL := srv.URL + "/ruby.deb"
softwareToInstall = []fleet.SoftwareInstallerPayload{
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: rubyURL},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
@ -11306,7 +11480,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
require.NotNil(t, packages[0].TeamID)
require.Equal(t, tm.ID, *packages[0].TeamID)
softwareToInstallBadSecret := []fleet.SoftwareInstallerPayload{
softwareToInstallBadSecret := []*fleet.SoftwareInstallerPayload{
{
URL: rubyURL,
InstallScript: "echo $FLEET_SECRET_INVALID",
@ -11379,7 +11553,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
require.Equal(t, titlesResp, newTitlesResp)
// empty payload cleans the software items
softwareToInstall = []fleet.SoftwareInstallerPayload{}
softwareToInstall = []*fleet.SoftwareInstallerPayload{}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
require.Empty(t, packages)
@ -11392,7 +11566,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
//////////////////////////
// Do a request with a valid URL with no team
//////////////////////////
softwareToInstall = []fleet.SoftwareInstallerPayload{
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: rubyURL},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
@ -11432,8 +11606,45 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = ptr.Bool(true)
require.Equal(t, titlesResp, newTitlesResp)
// create some labels A and B
lblA, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "A"})
require.NoError(t, err)
lblB, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "B"})
require.NoError(t, err)
// providing both labels include/exclude results in an error
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: rubyURL, LabelsIncludeAny: []string{lblA.Name}, LabelsExcludeAny: []string{lblB.Name}},
}
res := s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(res.Body), `Only one of "labels_include_any" or "labels_exclude_any" can be included.`)
// providing a non-existing label results in an error
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: rubyURL, LabelsIncludeAny: []string{"no-such-label"}},
}
res = s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(res.Body), `some or all the labels provided don't exist`)
// valid installer scoped by label
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{URL: rubyURL, LabelsIncludeAny: []string{lblA.Name}},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
require.Len(t, packages, 1)
require.NotNil(t, packages[0].TitleID)
require.Equal(t, rubyURL, packages[0].URL)
require.Nil(t, packages[0].TeamID)
meta, err := s.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, *packages[0].TitleID, false)
require.NoError(t, err)
require.Empty(t, meta.LabelsExcludeAny)
require.Len(t, meta.LabelsIncludeAny, 1)
require.Equal(t, lblA.ID, meta.LabelsIncludeAny[0].LabelID)
require.Equal(t, lblA.Name, meta.LabelsIncludeAny[0].LabelName)
// empty payload cleans the software items
softwareToInstall = []fleet.SoftwareInstallerPayload{}
softwareToInstall = []*fleet.SoftwareInstallerPayload{}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID)
require.Empty(t, packages)
@ -11505,7 +11716,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersSideEffec
t.Cleanup(srv.Close)
// set up software to install
softwareToInstall := []fleet.SoftwareInstallerPayload{
softwareToInstall := []*fleet.SoftwareInstallerPayload{
{URL: srv.URL},
}
var batchResponse batchSetSoftwareInstallersResponse
@ -11593,7 +11804,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersSideEffec
require.Equal(t, fleet.SoftwareInstallPending, *getHostSoftwareResp.Software[0].Status)
// update pre-install query
withUpdatedPreinstallQuery := []fleet.SoftwareInstallerPayload{
withUpdatedPreinstallQuery := []*fleet.SoftwareInstallerPayload{
{URL: srv.URL, PreInstallQuery: "SELECT * FROM os_version"},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: withUpdatedPreinstallQuery}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
@ -11638,7 +11849,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersSideEffec
require.Equal(t, fleet.SoftwareInstalled, *hostResp.Software[0].Status)
// update install script
withUpdatedInstallScript := []fleet.SoftwareInstallerPayload{
withUpdatedInstallScript := []*fleet.SoftwareInstallerPayload{
{URL: srv.URL, InstallScript: "apt install ruby"},
}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: withUpdatedInstallScript}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
@ -11706,7 +11917,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersSideEffec
require.Equal(t, fleet.SoftwareUninstallPending, *afterPreinstallHostResp.Software[0].Status)
// delete all installers
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: []fleet.SoftwareInstallerPayload{}}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{}}, http.StatusAccepted, &batchResponse, "team_name", tm.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)
require.Len(t, packages, 0)
@ -11766,7 +11977,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersWithPolic
t.Cleanup(srv.Close)
// team1 has ruby.deb
softwareToInstall := []fleet.SoftwareInstallerPayload{
softwareToInstall := []*fleet.SoftwareInstallerPayload{
{
URL: srv.URL + "/ruby.deb",
},
@ -11781,7 +11992,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersWithPolic
require.Equal(t, srv.URL+"/ruby.deb", packages[0].URL)
// team2 has dummy_installer.pkg and ruby.deb.
softwareToInstall = []fleet.SoftwareInstallerPayload{
softwareToInstall = []*fleet.SoftwareInstallerPayload{
{
URL: srv.URL + "/dummy_installer.pkg",
},
@ -11831,7 +12042,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersWithPolic
}, http.StatusOK, &mtplr)
// Get rid of all installers in team1.
softwareToInstall = []fleet.SoftwareInstallerPayload{}
softwareToInstall = []*fleet.SoftwareInstallerPayload{}
s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", team1.Name)
packages = waitBatchSetSoftwareInstallersCompleted(t, s, team1.Name, batchResponse.RequestUUID)
require.Len(t, packages, 0)
@ -12389,6 +12600,17 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
token := "secret_token"
createDeviceTokenForHost(t, s.ds, host1.ID, token)
// Create a label and assign it to the host
var labelResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: t.Name(),
Query: "select 1",
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)
err := s.ds.RecordLabelQueryExecutions(context.Background(), host1, map[uint]*bool{labelResp.Label.ID: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
payloadNoSS := &fleet.UploadSoftwareInstallerPayload{
PreInstallQuery: "SELECT 1",
InstallScript: "install",
@ -12407,6 +12629,7 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
Filename: "emacs.deb",
Title: "emacs",
SelfService: true,
LabelsIncludeAny: []string{labelResp.Label.Name},
}
s.uploadSoftwareInstaller(t, payloadSS, http.StatusOK, "")
titleIDSS := getSoftwareTitleID(t, s.ds, payloadSS.Title, "deb_packages")
@ -12416,7 +12639,23 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Software title is not available through self-service")
// request self-install of software that allows it
// Add an installer with an exclude any label. Installation attempt should fail.
payloadLabelSS := &fleet.UploadSoftwareInstallerPayload{
PreInstallQuery: "SELECT 42",
InstallScript: "install again",
PostInstallScript: "echo bye",
Filename: "vim.deb",
Title: "vim",
SelfService: true,
LabelsExcludeAny: []string{labelResp.Label.Name},
}
s.uploadSoftwareInstaller(t, payloadLabelSS, http.StatusOK, "")
titleIDLabelSS := getSoftwareTitleID(t, s.ds, payloadLabelSS.Title, "deb_packages")
resp := s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", token, titleIDLabelSS), nil, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(resp.Body), "Couldn't install. Host isn't member of the labels defined for this software title.")
// request self-install of software that allows it (is self-service + label scoped)
s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", token, titleIDSS), nil, http.StatusAccepted)
// it shows up as "self-installed" in the upcoming activities of the host
@ -12426,7 +12665,7 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
require.Nil(t, listUpcomingAct.Activities[0].ActorID)
var details fleet.ActivityTypeInstalledSoftware
err := json.Unmarshal([]byte(*listUpcomingAct.Activities[0].Details), &details)
err = json.Unmarshal([]byte(*listUpcomingAct.Activities[0].Details), &details)
require.NoError(t, err)
require.Equal(t, host1.ID, details.HostID)
require.Equal(t, details.SoftwareTitle, payloadSS.Title)
@ -14814,6 +15053,184 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
require.Nil(t, hostVanillaOsquery5Team1LastInstall)
}
func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallersLabelScoping() {
t := s.T()
ctx := context.Background()
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "linux",
})
require.NoError(t, err)
orbitKey := setOrbitEnrollment(t, host, s.ds)
host.OrbitNodeKey = &orbitKey
// Create a few labels
var newLabelResp createLabelResponse
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: uuid.NewString(),
Query: "SELECT 1",
}, http.StatusOK, &newLabelResp)
lbl1 := newLabelResp.Label
newLabelResp = createLabelResponse{}
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: uuid.NewString(),
Query: "SELECT 2",
}, http.StatusOK, &newLabelResp)
lbl2 := newLabelResp.Label
newLabelResp = createLabelResponse{}
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: uuid.NewString(),
Query: "SELECT 3",
}, http.StatusOK, &newLabelResp)
lbl3 := newLabelResp.Label
// Add label1 and label2 to the host
err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl1.ID: ptr.Bool(true), lbl2.ID: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
// upload software. Add label1 and label3 as "exclude any" labels.
rubyPayload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some deb install script",
Filename: "ruby.deb",
TeamID: nil,
LabelsExcludeAny: []string{lbl1.Name, lbl3.Name},
Platform: "linux",
}
s.uploadSoftwareInstaller(t, rubyPayload, http.StatusOK, "")
// Get software title ID of the uploaded installer.
resp := listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "ruby",
"team_id", "0",
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
rubyDebTitleID := resp.SoftwareTitles[0].ID
var rubyDetail getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", rubyDebTitleID), nil, http.StatusOK, &rubyDetail)
require.NotNil(t, rubyDetail.SoftwareTitle)
require.NotNil(t, rubyDetail.SoftwareTitle.SoftwarePackage)
rubyInstallerID := rubyDetail.SoftwareTitle.SoftwarePackage.InstallerID
policy1, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{
Name: "policy1",
Query: "SELECT 1;",
Platform: "linux",
})
require.NoError(t, err)
mtplr := modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/0/policies/%d", policy1.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: rubyDebTitleID},
},
}, http.StatusOK, &mtplr)
host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
// Send back a failed result for the policy.
distributedResp := submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host,
map[uint]*bool{
policy1.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy1, err = s.ds.Policy(ctx, policy1.ID)
require.NoError(t, err)
// Because the installer is not in scope, we do not mark the policy as failed.
require.Equal(t, uint(0), policy1.PassingHostCount)
require.Equal(t, uint(0), policy1.FailingHostCount)
// No installation attempt, because we skipped due to label scoping
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
vimPayload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "some deb install script",
Filename: "vim.deb",
TeamID: nil,
LabelsIncludeAny: []string{lbl1.Name, lbl2.Name},
Platform: "linux",
}
s.uploadSoftwareInstaller(t, vimPayload, http.StatusOK, "")
resp = listSoftwareTitlesResponse{}
s.DoJSON(
"GET", "/api/latest/fleet/software/titles",
listSoftwareTitlesRequest{},
http.StatusOK, &resp,
"query", "vim",
"team_id", "0",
)
require.Len(t, resp.SoftwareTitles, 1)
require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage)
vimTitleID := resp.SoftwareTitles[0].ID
var vimDetail getSoftwareTitleResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", vimTitleID), nil, http.StatusOK, &vimDetail)
require.NotNil(t, vimDetail.SoftwareTitle)
require.NotNil(t, vimDetail.SoftwareTitle.SoftwarePackage)
vimInstallerID := vimDetail.SoftwareTitle.SoftwarePackage.InstallerID
policy2, err := s.ds.NewTeamPolicy(ctx, 0, nil, fleet.PolicyPayload{
Name: "policy2",
Query: "SELECT 2;",
Platform: "linux",
})
require.NoError(t, err)
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/0/policies/%d", policy2.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: vimTitleID},
},
}, http.StatusOK, &mtplr)
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, vimInstallerID)
require.NoError(t, err)
require.Nil(t, host1LastInstall)
distributedResp = submitDistributedQueryResultsResponse{}
s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host,
map[uint]*bool{
policy2.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedResp)
err = s.ds.UpdateHostPolicyCounts(ctx)
require.NoError(t, err)
policy2, err = s.ds.Policy(ctx, policy2.ID)
require.NoError(t, err)
// Because the installer is in scope, we do mark the policy as failed.
require.Equal(t, uint(0), policy2.PassingHostCount)
require.Equal(t, uint(1), policy2.FailingHostCount)
// We have an installation attempt for vim, because it's in scope
host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, vimInstallerID)
require.NoError(t, err)
require.NotNil(t, host1LastInstall)
}
func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsScripts() {
t := s.T()
ctx := context.Background()
@ -15695,6 +16112,83 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
require.NotNil(t, policies[0].InstallSoftware)
require.Equal(t, tpResp.Policy.InstallSoftware.Name, policies[0].InstallSoftware.Name)
require.Equal(t, tpResp.Policy.InstallSoftware.SoftwareTitleID, policies[0].InstallSoftware.SoftwareTitleID)
// ===========================================================================================
// Adding label-scoped FMA
// ===========================================================================================
// Add some labels
var newLabelResp createLabelResponse
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: t.Name() + "1",
Platform: "darwin",
Query: "SELECT 1",
}, http.StatusOK, &newLabelResp)
lbl1 := newLabelResp.Label
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: t.Name() + "2",
Platform: "darwin",
Query: "SELECT 1",
}, http.StatusOK, &newLabelResp)
lbl2 := newLabelResp.Label
// Add another FMA
req = &addFleetMaintainedAppRequest{
AppID: 6,
SelfService: false,
PreInstallQuery: "SELECT 1",
InstallScript: "echo foo",
PostInstallScript: "echo done",
TeamID: ptr.Uint(0),
LabelsIncludeAny: []string{lbl1.Name, lbl2.Name},
}
addMAResp = addFleetMaintainedAppResponse{}
s.DoJSON("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusOK, &addMAResp)
require.NoError(t, addMAResp.Err)
require.NotEmpty(t, addMAResp.SoftwareTitleID)
// Get software title details
titleResp = getSoftwareTitleResponse{}
s.DoJSON(
"GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addMAResp.SoftwareTitleID),
getSoftwareTitleRequest{},
http.StatusOK, &titleResp,
"team_id", "0",
)
require.NotNil(t, titleResp.SoftwareTitle)
swTitle = titleResp.SoftwareTitle
require.NotNil(t, swTitle.SoftwarePackage)
require.Empty(t, swTitle.SoftwarePackage.LabelsExcludeAny)
require.Len(t, swTitle.SoftwarePackage.LabelsIncludeAny, 2)
gotNames := make(map[string]bool)
for _, lbl := range swTitle.SoftwarePackage.LabelsIncludeAny {
gotNames[lbl.LabelName] = true
}
require.True(t, gotNames[lbl1.Name])
require.True(t, gotNames[lbl2.Name])
// Can't set non-existent label
req = &addFleetMaintainedAppRequest{
AppID: 7,
SelfService: false,
PreInstallQuery: "SELECT 1",
InstallScript: "echo foo",
PostInstallScript: "echo done",
TeamID: ptr.Uint(0),
LabelsIncludeAny: []string{"no-such-label"},
}
addMAResp = addFleetMaintainedAppResponse{}
r = s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(r.Body), "some or all the labels provided don't exist")
// Can't set both labels_include_any and labels_exclude_any
req.LabelsIncludeAny = []string{lbl1.Name, lbl2.Name}
req.LabelsExcludeAny = []string{lbl1.Name}
addMAResp = addFleetMaintainedAppResponse{}
r = s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(r.Body), `Only one of "labels_include_any" or "labels_exclude_any" can be included`)
}
func (s *integrationEnterpriseTestSuite) TestWindowsMigrateMDMNotEnabled() {
@ -15706,3 +16200,158 @@ func (s *integrationEnterpriseTestSuite) TestWindowsMigrateMDMNotEnabled() {
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows MDM is not enabled")
}
func (s *integrationEnterpriseTestSuite) TestDeleteLabels() {
t := s.T()
// create a couple labels
var newLabelResp createLabelResponse
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: "TestDeleteLabels1",
Platform: "darwin",
Query: "SELECT 1",
}, http.StatusOK, &newLabelResp)
lbl1 := newLabelResp.Label.ID
s.DoJSON("POST", "/api/v1/fleet/labels", fleet.LabelPayload{
Name: "TestDeleteLabels2",
Platform: "darwin",
Query: "SELECT 2",
}, http.StatusOK, &newLabelResp)
lbl2 := newLabelResp.Label.ID
// create a software installer associated with the label
installer := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "ruby.deb",
SelfService: false,
TeamID: nil,
LabelsIncludeAny: []string{"TestDeleteLabels1"},
}
s.uploadSoftwareInstaller(t, installer, http.StatusOK, "")
// try to delete the label associated with the installer
res := s.Do("DELETE", "/api/v1/fleet/labels/id/"+fmt.Sprint(lbl1), nil, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "foreign key constraint on labels: TestDeleteLabels1")
// try to delete a label that does not exist by id and name
var delLabelResp deleteLabelByIDResponse
s.DoJSON("DELETE", "/api/v1/fleet/labels/id/"+fmt.Sprint(lbl1+1000), nil, http.StatusNotFound, &delLabelResp)
s.DoJSON("DELETE", "/api/v1/fleet/labels/no-such-label", nil, http.StatusNotFound, &delLabelResp)
// delete the unused label2
s.DoJSON("DELETE", "/api/v1/fleet/labels/id/"+fmt.Sprint(lbl2), nil, http.StatusOK, &delLabelResp)
}
func (s *integrationEnterpriseTestSuite) TestListHostSoftwareWithLabelScoping() {
ctx := context.Background()
t := s.T()
host := createOrbitEnrolledHost(t, "linux", "", s.ds)
// Create software installers and corresponding host install requests.
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script",
PreInstallQuery: "pre install query",
PostInstallScript: "post install script",
Filename: "ruby.deb",
Title: "ruby",
}
s.uploadSoftwareInstaller(t, payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages")
latestInstallUUID := func() string {
var id string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &id, `SELECT execution_id FROM host_software_installs ORDER BY id DESC LIMIT 1`)
})
return id
}
// create install request for the software and record a successful result
resp := installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusAccepted, &resp)
installUUID := latestInstallUUID()
s.Do("POST", "/api/fleet/orbit/software_install/result",
json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "",
"install_script_exit_code": 0,
"install_script_output": "success"
}`, *host.OrbitNodeKey, installUUID)),
http.StatusNoContent)
// Software is now installed on the host. We should see it in the host software list
getHostSw := getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
require.Len(t, getHostSw.Software, 1)
require.Equal(t, getHostSw.Software[0].Name, "ruby")
// De-scope the software by adding an exclude any label that the host has.
// TODO(JVE): remove/update this once the API is in place
updateInstallerLabel := func(siID, labelID uint, exclude bool) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(
ctx,
`INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)`,
siID, labelID, exclude,
)
return err
})
}
var installerID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &installerID, "SELECT id FROM software_installers WHERE title_id = ?", titleID)
})
require.NotEmpty(t, installerID)
// create some labels and assign them to the host
var labelResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: "label1",
Hosts: []string{host.Hostname},
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)
lbl1 := labelResp.Label
s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{
Name: "label2",
Query: "SELECT 1",
}}, http.StatusOK, &labelResp)
require.NotZero(t, labelResp.Label.ID)
lbl2 := labelResp.Label
err := s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl2.ID: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err)
updateInstallerLabel(installerID, lbl1.ID, true)
updateInstallerLabel(installerID, lbl2.ID, true)
// We should still see the software at this point, because we haven't uninstalled it yet
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
require.Len(t, getHostSw.Software, 1)
// uninstall the software
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", host.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
require.Len(t, getHostSw.Software, 1)
assert.NotNil(t, getHostSw.Software[0].SoftwarePackage.LastInstall)
assert.Equal(t, fleet.SoftwareUninstallPending, *getHostSw.Software[0].Status)
require.NotNil(t, getHostSw.Software[0].SoftwarePackage.LastUninstall)
uninstallExecutionID := getHostSw.Software[0].SoftwarePackage.LastUninstall.ExecutionID
// Host sends failed uninstall result
var orbitPostScriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "uninstall"}`, *host.OrbitNodeKey,
uninstallExecutionID)),
http.StatusOK, &orbitPostScriptResp)
// Now that the software is uninstalled, we should no longer see it in the host software list,
// because it is de-scoped via labels.
getHostSw = getHostSoftwareResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw)
require.Empty(t, getHostSw.Software)
}

View file

@ -4839,10 +4839,12 @@ func generateMultipartRequest(t *testing.T,
writer := multipart.NewWriter(&body)
// add file content
ff, err := writer.CreateFormFile(uploadFileField, fileName)
require.NoError(t, err)
_, err = io.Copy(ff, bytes.NewReader(fileContent))
require.NoError(t, err)
if fileName != "" || len(fileContent) > 0 {
ff, err := writer.CreateFormFile(uploadFileField, fileName)
require.NoError(t, err)
_, err = io.Copy(ff, bytes.NewReader(fileContent))
require.NoError(t, err)
}
// add extra fields
for key, values := range extraFields {
@ -4852,7 +4854,7 @@ func generateMultipartRequest(t *testing.T,
}
}
err = writer.Close()
err := writer.Close()
require.NoError(t, err)
headers := map[string]string{
@ -12179,6 +12181,7 @@ func (s *integrationMDMTestSuite) TestSetupExperience() {
UserID: user1.ID,
TeamID: &team1.ID,
Platform: string(fleet.MacOSPlatform),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
_ = installerID1
require.NoError(t, err)

View file

@ -5,6 +5,8 @@ import (
"fmt"
"net/http"
"github.com/fleetdm/fleet/v4/server"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
@ -649,3 +651,38 @@ func (svc *Service) GetLabelSpec(ctx context.Context, name string) (*fleet.Label
return svc.ds.GetLabelSpec(ctx, name)
}
func (svc *Service) BatchValidateLabels(ctx context.Context, labelNames []string) (map[string]fleet.LabelIdent, error) {
if authctx, ok := authz_ctx.FromContext(ctx); !ok {
return nil, fleet.NewAuthRequiredError("batch validate labels: missing authorization context")
} else if !authctx.Checked() {
return nil, fleet.NewAuthRequiredError("batch validate labels: method requires previous authorization")
}
if len(labelNames) == 0 {
return nil, nil
}
uniqueNames := server.RemoveDuplicatesFromSlice(labelNames)
labels, err := svc.ds.LabelIDsByName(ctx, uniqueNames)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting label IDs by name")
}
if len(labels) != len(uniqueNames) {
return nil, &fleet.BadRequestError{
Message: "some or all the labels provided don't exist",
InternalErr: fmt.Errorf("names provided: %v", labelNames),
}
}
byName := make(map[string]fleet.LabelIdent, len(labels))
for labelName, labelID := range labels {
byName[labelName] = fleet.LabelIdent{
LabelName: labelName,
LabelID: labelID,
}
}
return byName, nil
}

View file

@ -5,6 +5,7 @@ import (
"testing"
"time"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -312,3 +313,108 @@ func TestLabelsWithReplica(t *testing.T) {
require.ElementsMatch(t, []uint{h1.ID}, hostIDs)
require.Equal(t, 1, lbl.HostCount)
}
func TestBatchValidateLabels(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
t.Run("no auth context", func(t *testing.T) {
_, err := svc.BatchValidateLabels(context.Background(), nil)
require.ErrorContains(t, err, "Authentication required")
})
authCtx := authz_ctx.AuthorizationContext{}
ctx = authz_ctx.NewContext(ctx, &authCtx)
t.Run("no auth checked", func(t *testing.T) {
_, err := svc.BatchValidateLabels(ctx, nil)
require.ErrorContains(t, err, "Authentication required")
})
// validator requires that an authz check has been performed upstream so we'll set it now for
// the rest of the tests
authCtx.SetChecked()
mockLabels := map[string]uint{
"foo": 1,
"bar": 2,
"baz": 3,
}
mockLabelIdent := func(name string, id uint) fleet.LabelIdent {
return fleet.LabelIdent{LabelID: id, LabelName: name}
}
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) {
res := make(map[string]uint)
if names == nil {
return res, nil
}
for _, name := range names {
if id, ok := mockLabels[name]; ok {
res[name] = id
}
}
return res, nil
}
testCases := []struct {
name string
labelNames []string
expectLabels map[string]fleet.LabelIdent
expectError string
}{
{
"no labels",
nil,
nil,
"",
},
{
"include labels",
[]string{"foo", "bar"},
map[string]fleet.LabelIdent{
"foo": mockLabelIdent("foo", 1),
"bar": mockLabelIdent("bar", 2),
},
"",
},
{
"non-existent label",
[]string{"foo", "qux"},
nil,
"some or all the labels provided don't exist",
},
{
"duplicate label",
[]string{"foo", "foo"},
map[string]fleet.LabelIdent{
"foo": mockLabelIdent("foo", 1),
},
"",
},
{
"empty slice",
[]string{},
nil,
"",
},
{
"empty string",
[]string{""},
nil,
"some or all the labels provided don't exist",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got, err := svc.BatchValidateLabels(ctx, tt.labelNames)
if tt.expectError != "" {
require.Contains(t, err.Error(), tt.expectError)
} else {
require.NoError(t, err)
require.Equal(t, tt.expectLabels, got)
}
})
}
}

View file

@ -10,13 +10,15 @@ import (
)
type addFleetMaintainedAppRequest struct {
TeamID *uint `json:"team_id"`
AppID uint `json:"fleet_maintained_app_id"`
InstallScript string `json:"install_script"`
PreInstallQuery string `json:"pre_install_query"`
PostInstallScript string `json:"post_install_script"`
SelfService bool `json:"self_service"`
UninstallScript string `json:"uninstall_script"`
TeamID *uint `json:"team_id"`
AppID uint `json:"fleet_maintained_app_id"`
InstallScript string `json:"install_script"`
PreInstallQuery string `json:"pre_install_query"`
PostInstallScript string `json:"post_install_script"`
SelfService bool `json:"self_service"`
UninstallScript string `json:"uninstall_script"`
LabelsIncludeAny []string `json:"labels_include_any"`
LabelsExcludeAny []string `json:"labels_exclude_any"`
}
type addFleetMaintainedAppResponse struct {
@ -39,6 +41,8 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc
req.PostInstallScript,
req.UninstallScript,
req.SelfService,
req.LabelsIncludeAny,
req.LabelsExcludeAny,
)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
@ -50,7 +54,7 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc
return &addFleetMaintainedAppResponse{SoftwareTitleID: titleId}, nil
}
func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) (uint, error) {
func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, labelsIncludeAny, labelsExcludeAny []string) (uint, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
@ -58,6 +62,24 @@ func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, app
return 0, fleet.ErrMissingLicense
}
type editFleetMaintainedAppRequest struct {
TeamID *uint `json:"team_id"`
AppID uint `json:"fleet_maintained_app_id"`
InstallScript string `json:"install_script"`
PreInstallQuery string `json:"pre_install_query"`
PostInstallScript string `json:"post_install_script"`
SelfService bool `json:"self_service"`
UninstallScript string `json:"uninstall_script"`
LabelsIncludeAny []string `json:"labels_include_any"`
LabelsExcludeAny []string `json:"labels_exclude_any"`
}
func editFleetMaintainedAppEndpoint(ctx context.Context, request any, svc fleet.Service) (errorer, error) {
// TODO: implement this
return nil, errors.New("not implemented")
}
type listFleetMaintainedAppsRequest struct {
fleet.ListOptions
TeamID *uint `query:"team_id,optional"`

View file

@ -1010,6 +1010,8 @@ func (svc *Service) SubmitDistributedQueryResults(
logging.WithErr(ctx, err)
}
// NOTE: if the installers for the policies here are not scoped to the host via labels, we update the policy status here to stop it from showing up as "failed" in the
// host details.
if err := svc.processSoftwareForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, host.OrbitNodeKey, policyResults); err != nil {
logging.WithErr(ctx, err)
}
@ -1795,6 +1797,17 @@ func (svc *Service) processSoftwareForNewlyFailingPolicies(
level.Debug(logger).Log("msg", "installer platform does not match host platform")
continue
}
scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, failingPolicyWithInstaller.InstallerID, hostID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking if software installer is label scoped to host")
}
if !scoped {
// NOTE: we update the policy status here to stop it from showing up as "failed" in the
// host details.
incomingPolicyResults[failingPolicyWithInstaller.ID] = nil
level.Debug(logger).Log("msg", "not marking policy as failed since software is out of scope for host")
continue
}
hostLastInstall, err := svc.ds.GetHostLastInstallData(ctx, hostID, installerMetadata.InstallerID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get host last install data")

View file

@ -30,6 +30,8 @@ type uploadSoftwareInstallerRequest struct {
PostInstallScript string
SelfService bool
UninstallScript string
LabelsIncludeAny []string
LabelsExcludeAny []string
}
type updateSoftwareInstallerRequest struct {
@ -41,6 +43,8 @@ type updateSoftwareInstallerRequest struct {
PostInstallScript *string
UninstallScript *string
SelfService *bool
LabelsIncludeAny []string
LabelsExcludeAny []string
}
type uploadSoftwareInstallerResponse struct {
@ -131,6 +135,30 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http
decoded.SelfService = &parsed
}
// decode labels
var inclAny, exclAny []string
var existsInclAny, existsExclAny bool
inclAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)]
switch {
case !existsInclAny:
decoded.LabelsIncludeAny = nil
case len(inclAny) == 1 && inclAny[0] == "":
decoded.LabelsIncludeAny = []string{}
default:
decoded.LabelsIncludeAny = inclAny
}
exclAny, existsExclAny = r.MultipartForm.Value[string(fleet.LabelsExcludeAny)]
switch {
case !existsExclAny:
decoded.LabelsExcludeAny = nil
case len(exclAny) == 1 && exclAny[0] == "":
decoded.LabelsExcludeAny = []string{}
default:
decoded.LabelsExcludeAny = exclAny
}
return &decoded, nil
}
@ -145,6 +173,8 @@ func updateSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s
PostInstallScript: req.PostInstallScript,
UninstallScript: req.UninstallScript,
SelfService: req.SelfService,
LabelsIncludeAny: req.LabelsIncludeAny,
LabelsExcludeAny: req.LabelsExcludeAny,
}
if req.File != nil {
ff, err := req.File.Open()
@ -261,6 +291,31 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http
decoded.SelfService = parsed
}
// decode labels
// decode labels
var inclAny, exclAny []string
var existsInclAny, existsExclAny bool
inclAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)]
switch {
case !existsInclAny:
decoded.LabelsIncludeAny = nil
case len(inclAny) == 1 && inclAny[0] == "":
decoded.LabelsIncludeAny = []string{}
default:
decoded.LabelsIncludeAny = inclAny
}
exclAny, existsExclAny = r.MultipartForm.Value[string(fleet.LabelsExcludeAny)]
switch {
case !existsExclAny:
decoded.LabelsExcludeAny = nil
case len(exclAny) == 1 && exclAny[0] == "":
decoded.LabelsExcludeAny = []string{}
default:
decoded.LabelsExcludeAny = exclAny
}
return &decoded, nil
}
@ -289,6 +344,8 @@ func uploadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s
Filename: req.File.Filename,
SelfService: req.SelfService,
UninstallScript: req.UninstallScript,
LabelsIncludeAny: req.LabelsIncludeAny,
LabelsExcludeAny: req.LabelsExcludeAny,
}
if err := svc.UploadSoftwareInstaller(ctx, payload); err != nil {
@ -549,9 +606,9 @@ func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID st
////////////////////////////////////////////////////////////////////////////////
type batchSetSoftwareInstallersRequest struct {
TeamName string `json:"-" query:"team_name,optional"`
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
Software []fleet.SoftwareInstallerPayload `json:"software"`
TeamName string `json:"-" query:"team_name,optional"`
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
Software []*fleet.SoftwareInstallerPayload `json:"software"`
}
type batchSetSoftwareInstallersResponse struct {
@ -571,7 +628,7 @@ func batchSetSoftwareInstallersEndpoint(ctx context.Context, request interface{}
return batchSetSoftwareInstallersResponse{RequestUUID: requestUUID}, nil
}
func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool) (string, error) {
func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []*fleet.SoftwareInstallerPayload, dryRun bool) (string, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)

View file

@ -5,6 +5,8 @@ import (
"testing"
"time"
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
@ -116,7 +118,8 @@ func TestSoftwareInstallersAuth(t *testing.T) {
}
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName,
_ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
_ sqlx.QueryerContext,
) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{}, nil
}
@ -154,3 +157,293 @@ func TestSoftwareInstallersAuth(t *testing.T) {
})
}
}
func TestValidateSoftwareInstallerLabels(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license})
t.Run("validate no update", func(t *testing.T) {
t.Run("no auth context", func(t *testing.T) {
_, err := eeservice.ValidateSoftwareLabels(context.Background(), svc, nil, nil)
require.ErrorContains(t, err, "Authentication required")
})
authCtx := authz_ctx.AuthorizationContext{}
ctx = authz_ctx.NewContext(ctx, &authCtx)
t.Run("no auth checked", func(t *testing.T) {
_, err := eeservice.ValidateSoftwareLabels(ctx, svc, nil, nil)
require.ErrorContains(t, err, "Authentication required")
})
// validator requires that an authz check has been performed upstream so we'll set it now for
// the rest of the tests
authCtx.SetChecked()
mockLabels := map[string]uint{
"foo": 1,
"bar": 2,
"baz": 3,
}
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) {
res := make(map[string]uint)
if names == nil {
return res, nil
}
for _, name := range names {
if id, ok := mockLabels[name]; ok {
res[name] = id
}
}
return res, nil
}
testCases := []struct {
name string
payloadIncludeAny []string
payloadExcludeAny []string
expectLabels map[string]fleet.LabelIdent
expectScope fleet.LabelScope
expectError string
}{
{
"no labels",
nil,
nil,
nil,
"",
"",
},
{
"include labels",
[]string{"foo", "bar"},
nil,
map[string]fleet.LabelIdent{
"foo": {LabelID: 1, LabelName: "foo"},
"bar": {LabelID: 2, LabelName: "bar"},
},
fleet.LabelScopeIncludeAny,
"",
},
{
"exclude labels",
nil,
[]string{"bar", "baz"},
map[string]fleet.LabelIdent{
"bar": {LabelID: 2, LabelName: "bar"},
"baz": {LabelID: 3, LabelName: "baz"},
},
fleet.LabelScopeExcludeAny,
"",
},
{
"include and exclude labels",
[]string{"foo"},
[]string{"bar"},
nil,
"",
`Only one of "labels_include_any" or "labels_exclude_any" can be included.`,
},
{
"non-existent label",
[]string{"foo", "qux"},
nil,
nil,
"",
"some or all the labels provided don't exist",
},
{
"duplicate label",
[]string{"foo", "foo"},
nil,
map[string]fleet.LabelIdent{
"foo": {LabelID: 1, LabelName: "foo"},
},
fleet.LabelScopeIncludeAny,
"",
},
{
"empty slice",
nil,
[]string{},
nil,
"",
"",
},
{
"empty string",
nil,
[]string{""},
nil,
"",
"some or all the labels provided don't exist",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got, err := eeservice.ValidateSoftwareLabels(ctx, svc, tt.payloadIncludeAny, tt.payloadExcludeAny)
if tt.expectError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectError)
} else {
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, tt.expectScope, got.LabelScope)
require.Equal(t, tt.expectLabels, got.ByName)
}
})
}
})
t.Run("validate update", func(t *testing.T) {
t.Run("no auth context", func(t *testing.T) {
_, _, err := eeservice.ValidateSoftwareLabelsForUpdate(context.Background(), svc, nil, nil, nil)
require.ErrorContains(t, err, "Authentication required")
})
authCtx := authz_ctx.AuthorizationContext{}
ctx = authz_ctx.NewContext(ctx, &authCtx)
t.Run("no auth checked", func(t *testing.T) {
_, _, err := eeservice.ValidateSoftwareLabelsForUpdate(ctx, svc, nil, nil, nil)
require.ErrorContains(t, err, "Authentication required")
})
// validator requires that an authz check has been performed upstream so we'll set it now for
// the rest of the tests
authCtx.SetChecked()
mockLabels := map[string]uint{
"foo": 1,
"bar": 2,
"baz": 3,
}
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) {
res := make(map[string]uint)
if names == nil {
return res, nil
}
for _, name := range names {
if id, ok := mockLabels[name]; ok {
res[name] = id
}
}
return res, nil
}
testCases := []struct {
name string
existingInstaller *fleet.SoftwareInstaller
payloadIncludeAny []string
payloadExcludeAny []string
shouldUpdate bool
expectLabels map[string]fleet.LabelIdent
expectScope fleet.LabelScope
expectError string
}{
{
"no installer",
nil,
nil,
[]string{"foo"},
false,
nil,
"",
"existing installer must be provided",
},
{
"no labels",
&fleet.SoftwareInstaller{},
nil,
nil,
false,
nil,
"",
"",
},
{
"add label",
&fleet.SoftwareInstaller{
LabelsIncludeAny: []fleet.SoftwareScopeLabel{{LabelID: 1, LabelName: "foo"}},
LabelsExcludeAny: []fleet.SoftwareScopeLabel{},
},
[]string{"foo", "bar"},
nil,
true,
map[string]fleet.LabelIdent{
"foo": {LabelID: 1, LabelName: "foo"},
"bar": {LabelID: 2, LabelName: "bar"},
},
fleet.LabelScopeIncludeAny,
"",
},
{
"change scope",
&fleet.SoftwareInstaller{
LabelsIncludeAny: []fleet.SoftwareScopeLabel{{LabelID: 1, LabelName: "foo"}},
LabelsExcludeAny: []fleet.SoftwareScopeLabel{},
},
nil,
[]string{"foo"},
true,
map[string]fleet.LabelIdent{
"foo": {LabelID: 1, LabelName: "foo"},
},
fleet.LabelScopeExcludeAny,
"",
},
{
"remove label",
&fleet.SoftwareInstaller{
LabelsIncludeAny: []fleet.SoftwareScopeLabel{{LabelID: 1, LabelName: "foo"}},
LabelsExcludeAny: []fleet.SoftwareScopeLabel{},
},
[]string{},
nil,
true,
nil,
"",
"",
},
{
"no change",
&fleet.SoftwareInstaller{
LabelsIncludeAny: []fleet.SoftwareScopeLabel{{LabelID: 1, LabelName: "foo"}},
LabelsExcludeAny: []fleet.SoftwareScopeLabel{},
},
[]string{"foo"},
nil,
false,
nil,
"",
"",
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
shouldUpate, got, err := eeservice.ValidateSoftwareLabelsForUpdate(ctx, svc, tt.existingInstaller, tt.payloadIncludeAny, tt.payloadExcludeAny)
if tt.expectError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectError)
} else {
require.NoError(t, err)
if tt.shouldUpdate {
require.True(t, shouldUpate)
require.NotNil(t, got)
require.Equal(t, tt.expectScope, got.LabelScope)
require.Equal(t, tt.expectLabels, got.ByName)
} else {
require.False(t, shouldUpate)
require.Nil(t, got)
}
}
})
}
})
}

View file

@ -122,6 +122,24 @@ func (ts *withServer) commonTearDownTest(t *testing.T) {
require.NoError(t, ts.ds.DeleteHost(ctx, host.ID))
}
teams, err := ts.ds.ListTeams(ctx, fleet.TeamFilter{User: &u}, fleet.ListOptions{})
require.NoError(t, err)
for _, tm := range teams {
err := ts.ds.DeleteTeam(ctx, tm.ID)
require.NoError(t, err)
}
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM policies;`)
return err
})
// Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above).
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`)
return err
})
lbls, err := ts.ds.ListLabels(ctx, fleet.TeamFilter{}, fleet.ListOptions{})
require.NoError(t, err)
for _, lbl := range lbls {
@ -152,24 +170,6 @@ func (ts *withServer) commonTearDownTest(t *testing.T) {
}
}
teams, err := ts.ds.ListTeams(ctx, fleet.TeamFilter{User: &u}, fleet.ListOptions{})
require.NoError(t, err)
for _, tm := range teams {
err := ts.ds.DeleteTeam(ctx, tm.ID)
require.NoError(t, err)
}
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM policies;`)
return err
})
// Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above).
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`)
return err
})
// Clean scripts in "No team" (the others are deleted in ts.ds.DeleteTeam above).
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM scripts WHERE global_or_team_id = 0;`)
@ -587,6 +587,16 @@ func (ts *withServer) uploadSoftwareInstaller(
if payload.SelfService {
require.NoError(t, w.WriteField("self_service", "true"))
}
if payload.LabelsIncludeAny != nil {
for _, l := range payload.LabelsIncludeAny {
require.NoError(t, w.WriteField("labels_include_any", l))
}
}
if payload.LabelsExcludeAny != nil {
for _, l := range payload.LabelsExcludeAny {
require.NoError(t, w.WriteField("labels_exclude_any", l))
}
}
w.Close()