From 23fb23ee75a4bc1c7fe7778e99cbd609dbc9c49c Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Mon, 6 Jan 2025 12:18:16 -0500 Subject: [PATCH] fix: retrigger automatic installations after label scope changes (#25163) > Related issue: #25071 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated automated tests - [x] A detailed QA plan exists on the associated ticket (if it isn't there, work with the product group's QA engineer to add it) - [x] Manual QA for all new/changed functionality --- ee/server/service/software_installers.go | 34 ++- server/datastore/mysql/policies.go | 28 +++ server/datastore/mysql/policies_test.go | 81 +++++++ server/datastore/mysql/software_installers.go | 106 +++++++++ server/datastore/mysql/software_test.go | 8 + server/fleet/datastore.go | 12 + server/mock/datastore_mock.go | 36 +++ server/service/integration_enterprise_test.go | 209 ++++++++++++++++++ server/service/testing_client.go | 10 + 9 files changed, 523 insertions(+), 1 deletion(-) diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 81b539c5b1f4..4bdba884a577 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -438,10 +438,41 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. payload.SelfService = &existingInstaller.SelfService } + // Get the hosts that are NOT in label scope currently (before the update happens) + var hostsNotInScope map[uint]struct{} + if dirty["Labels"] { + hostsNotInScope, err = svc.ds.GetExcludedHostIDMapForSoftwareInstaller(ctx, payload.InstallerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting hosts not in scope for installer") + } + } + if err := svc.ds.SaveInstallerUpdates(ctx, payload); err != nil { return nil, ctxerr.Wrap(ctx, err, "saving installer updates") } + if dirty["Labels"] { + // Get the hosts that are now IN label scope (after the update) + hostsInScope, err := svc.ds.GetIncludedHostIDMapForSoftwareInstaller(ctx, payload.InstallerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting hosts in scope for installer") + } + + var hostsToClear []uint + for id := range hostsInScope { + if _, ok := hostsNotInScope[id]; ok { + // it was not in scope but now it is, so we should clear policy status + hostsToClear = append(hostsToClear, id) + } + } + + // We clear the policy status here because otherwise the policy automation machinery + // won't pick this up and the software won't install. + if err := svc.ds.ClearAutoInstallPolicyStatusForHosts(ctx, payload.InstallerID, hostsToClear); err != nil { + return nil, ctxerr.Wrap(ctx, err, "failed to clear auto install policy status for host") + } + } + // if we're updating anything other than self-service, we cancel pending installs/uninstalls, // and if we're updating the package we reset counts. This is run in its own transaction internally // for consistency, but independent of the installer update query as the main update should stick @@ -484,7 +515,8 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } func (svc *Service) validateEmbeddedSecretsOnScript(ctx context.Context, scriptName string, script *string, - argErr *fleet.InvalidArgumentError) *fleet.InvalidArgumentError { + argErr *fleet.InvalidArgumentError, +) *fleet.InvalidArgumentError { if script != nil { if errScript := svc.ds.ValidateEmbeddedSecrets(ctx, []string{*script}); errScript != nil { if argErr != nil { diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 61b2187e6af9..8f25fd467768 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -416,6 +416,34 @@ func (ds *Datastore) RecordPolicyQueryExecutions(ctx context.Context, host *flee return nil } +func (ds *Datastore) ClearAutoInstallPolicyStatusForHosts(ctx context.Context, installerID uint, hostIDs []uint) error { + if len(hostIDs) == 0 { + return nil + } + + stmt := ` +UPDATE + policies p + JOIN policy_membership pm ON pm.policy_id = p.id +SET + passes = NULL +WHERE + p.software_installer_id = ? + AND pm.host_id IN (?) + ` + + stmt, args, err := sqlx.In(stmt, installerID, hostIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "building in statement for clearing auto install policy status") + } + + if _, err := ds.writer(ctx).ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "clearing auto install policy status") + } + + return nil +} + func (ds *Datastore) ListGlobalPolicies(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return listPoliciesDB(ctx, ds.reader(ctx), nil, opts) } diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index 5ce3c95a7604..7059ff736af6 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -72,6 +72,7 @@ func TestPolicies(t *testing.T) { {"TestPoliciesTeamPoliciesWithScript", testTeamPoliciesWithScript}, {"TeamPoliciesNoTeam", testTeamPoliciesNoTeam}, {"TestPoliciesBySoftwareTitleID", testPoliciesBySoftwareTitleID}, + {"TestClearAutoInstallPolicyStatusForHost", testClearAutoInstallPolicyStatusForHost}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -5371,3 +5372,83 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, policies, 0) } + +func testClearAutoInstallPolicyStatusForHost(t *testing.T, ds *Datastore) { + ctx := context.Background() + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1" + t.Name()}) + require.NoError(t, err) + + // create a regular policy + policy1 := newTestPolicy(t, ds, user1, "policy 1"+t.Name(), "darwin", &team1.ID) + + // create an automatic install policy + policy2 := newTestPolicy(t, ds, user1, "policy 2"+t.Name(), "darwin", &team1.ID) + installer, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir) + require.NoError(t, err) + + installer1ID, _, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + InstallerFile: installer, + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: &team1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + policy2.SoftwareInstallerID = ptr.Uint(installer1ID) + err = ds.SavePolicy(context.Background(), policy2, false, false) + require.NoError(t, err) + + // create a host + host, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String(uuid.New().String()), + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String(uuid.New().String()), + UUID: uuid.New().String(), + Hostname: "host" + t.Name(), + TeamID: &team1.ID, + Platform: "darwin", + }) + require.NoError(t, err) + + // record a policy run for both policies + err = ds.RecordPolicyQueryExecutions(ctx, host, map[uint]*bool{ + policy1.ID: ptr.Bool(true), + policy2.ID: ptr.Bool(false), // software isn't installed on host, so Fleet should install it + }, time.Now(), false) + require.NoError(t, err) + + hostPolicies, err := ds.ListPoliciesForHost(ctx, host) + require.NoError(t, err) + require.Len(t, hostPolicies, 2) + sort.Slice(hostPolicies, func(i, j int) bool { + return hostPolicies[i].ID < hostPolicies[j].ID + }) + require.Equal(t, hostPolicies[0].Response, "pass") + require.Equal(t, hostPolicies[1].Response, "fail") + + // clear status for automatic install policy + err = ds.ClearAutoInstallPolicyStatusForHosts(ctx, installer1ID, []uint{host.ID}) + require.NoError(t, err) + + // the status should be NULL for the automatic install policy but not the "regular" one + hostPolicies, err = ds.ListPoliciesForHost(ctx, host) + require.NoError(t, err) + require.Len(t, hostPolicies, 2) + sort.Slice(hostPolicies, func(i, j int) bool { + return hostPolicies[i].ID < hostPolicies[j].ID + }) + require.Equal(t, hostPolicies[0].Response, "pass") + require.Empty(t, hostPolicies[1].Response) +} diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 0abc88d950b6..dd7a0aa091dc 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -1755,3 +1755,109 @@ func (ds *Datastore) IsSoftwareInstallerLabelScoped(ctx context.Context, install return res, nil } + +const labelScopedFilter = ` +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 = ?) + + 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 = h.id + WHERE + sil.software_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 = 1) >= 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 = h.id +WHERE + sil.software_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` + +func (ds *Datastore) GetIncludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) { + stmt := fmt.Sprintf(`SELECT + h.id +FROM + hosts h +WHERE + EXISTS (%s) +`, labelScopedFilter) + + var hostIDs []uint + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostIDs, stmt, installerID, installerID, installerID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "listing hosts included in software installer scope") + } + + res := make(map[uint]struct{}, len(hostIDs)) + for _, id := range hostIDs { + res[id] = struct{}{} + } + + return res, nil +} + +func (ds *Datastore) GetExcludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) { + stmt := fmt.Sprintf(`SELECT + h.id +FROM + hosts h +WHERE + NOT EXISTS (%s) +`, labelScopedFilter) + + var hostIDs []uint + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostIDs, stmt, installerID, installerID, installerID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "listing hosts excluded from software installer scope") + } + + res := make(map[uint]struct{}, len(hostIDs)) + for _, id := range hostIDs { + res[id] = struct{}{} + } + + return res, nil +} diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index cbba49884b63..d654a9e0ad4f 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -5321,6 +5321,10 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, scoped) + hostsInScope, err := ds.GetIncludedHostIDMapForSoftwareInstaller(ctx, installerID1) + require.NoError(t, err) + require.Contains(t, hostsInScope, host.ID) + label1, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1" + t.Name()}) require.NoError(t, err) @@ -5343,6 +5347,10 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Empty(t, software) + hostsNotInScope, err := ds.GetExcludedHostIDMapForSoftwareInstaller(ctx, installerID1) + require.NoError(t, err) + require.Contains(t, hostsNotInScope, host.ID) + // installer1 should be out of scope since the label is "exclude any" scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, host.ID) require.NoError(t, err) diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 489145fae596..8d9f173f55fa 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1706,6 +1706,18 @@ type Datastore interface { // Software installers // + // GetIncludedHostIDMapForSoftwareInstaller gets the set of hosts that are targeted/in scope for the + // given software installer, based label membership. + GetIncludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) + + // GetExcludedHostIDMapForSoftwareInstaller gets the set of hosts that are NOT targeted/in scope for the + // given software installer, based label membership. + GetExcludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) + + // ClearAutoInstallPolicyStatusForHosts clears out the status of the policy related to the given + // software installer for all the given hosts. + ClearAutoInstallPolicyStatusForHosts(ctx context.Context, installerID uint, hostIDs []uint) error + // GetSoftwareInstallDetails returns details required to fetch and // run software installers GetSoftwareInstallDetails(ctx context.Context, executionId string) (*SoftwareInstallDetails, error) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index f56e54e43081..b686b20370ad 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1077,6 +1077,12 @@ type WipeHostViaWindowsMDMFunc func(ctx context.Context, host *fleet.Host, cmd * type UpdateHostLockWipeStatusFromAppleMDMResultFunc func(ctx context.Context, hostUUID string, cmdUUID string, requestType string, succeeded bool) error +type GetIncludedHostIDMapForSoftwareInstallerFunc func(ctx context.Context, installerID uint) (map[uint]struct{}, error) + +type GetExcludedHostIDMapForSoftwareInstallerFunc func(ctx context.Context, installerID uint) (map[uint]struct{}, error) + +type ClearAutoInstallPolicyStatusForHostsFunc func(ctx context.Context, installerID uint, hostIDs []uint) error + type GetSoftwareInstallDetailsFunc func(ctx context.Context, executionId string) (*fleet.SoftwareInstallDetails, error) type ListPendingSoftwareInstallsFunc func(ctx context.Context, hostID uint) ([]string, error) @@ -2772,6 +2778,15 @@ type DataStore struct { UpdateHostLockWipeStatusFromAppleMDMResultFunc UpdateHostLockWipeStatusFromAppleMDMResultFunc UpdateHostLockWipeStatusFromAppleMDMResultFuncInvoked bool + GetIncludedHostIDMapForSoftwareInstallerFunc GetIncludedHostIDMapForSoftwareInstallerFunc + GetIncludedHostIDMapForSoftwareInstallerFuncInvoked bool + + GetExcludedHostIDMapForSoftwareInstallerFunc GetExcludedHostIDMapForSoftwareInstallerFunc + GetExcludedHostIDMapForSoftwareInstallerFuncInvoked bool + + ClearAutoInstallPolicyStatusForHostsFunc ClearAutoInstallPolicyStatusForHostsFunc + ClearAutoInstallPolicyStatusForHostsFuncInvoked bool + GetSoftwareInstallDetailsFunc GetSoftwareInstallDetailsFunc GetSoftwareInstallDetailsFuncInvoked bool @@ -6636,6 +6651,27 @@ func (s *DataStore) UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Conte return s.UpdateHostLockWipeStatusFromAppleMDMResultFunc(ctx, hostUUID, cmdUUID, requestType, succeeded) } +func (s *DataStore) GetIncludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) { + s.mu.Lock() + s.GetIncludedHostIDMapForSoftwareInstallerFuncInvoked = true + s.mu.Unlock() + return s.GetIncludedHostIDMapForSoftwareInstallerFunc(ctx, installerID) +} + +func (s *DataStore) GetExcludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) { + s.mu.Lock() + s.GetExcludedHostIDMapForSoftwareInstallerFuncInvoked = true + s.mu.Unlock() + return s.GetExcludedHostIDMapForSoftwareInstallerFunc(ctx, installerID) +} + +func (s *DataStore) ClearAutoInstallPolicyStatusForHosts(ctx context.Context, installerID uint, hostIDs []uint) error { + s.mu.Lock() + s.ClearAutoInstallPolicyStatusForHostsFuncInvoked = true + s.mu.Unlock() + return s.ClearAutoInstallPolicyStatusForHostsFunc(ctx, installerID, hostIDs) +} + func (s *DataStore) GetSoftwareInstallDetails(ctx context.Context, executionId string) (*fleet.SoftwareInstallDetails, error) { s.mu.Lock() s.GetSoftwareInstallDetailsFuncInvoked = true diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 79437ffb7c00..8d102a974687 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -15306,6 +15306,215 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers require.NotNil(t, host1LastInstall) } +func (s *integrationEnterpriseTestSuite) TestPolicyAutomationLabelScopingRetrigger() { + 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, + LabelsIncludeAny: []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) + + // No install attempt yet + 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 in scope, the policy is failing + require.Equal(t, uint(0), policy1.PassingHostCount) + require.Equal(t, uint(1), policy1.FailingHostCount) + + // We've triggered an installation attempt + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + + // Update the installer's labels to "exclude any". This de-scopes the software. + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + InstallScript: ptr.String("some install script"), + PreInstallQuery: ptr.String("some pre install query"), + PostInstallScript: ptr.String("some post install script"), + Filename: "ruby.deb", + TitleID: rubyDebTitleID, + TeamID: nil, + LabelsExcludeAny: []string{lbl1.Name, lbl3.Name}, + }, http.StatusOK, "") + + // The update should clear out the installation attempt + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Update the installer's labels to be "include any" again. The software is now back in scope. + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + InstallScript: ptr.String("some install script"), + PreInstallQuery: ptr.String("some pre install query"), + PostInstallScript: ptr.String("some post install script"), + Filename: "ruby.deb", + TitleID: rubyDebTitleID, + TeamID: nil, + LabelsIncludeAny: []string{lbl1.Name, lbl3.Name}, + }, http.StatusOK, "") + + // Simulate a failure of the policy + 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 in scope, the policy should be failing again + require.Equal(t, uint(0), policy1.PassingHostCount) + require.Equal(t, uint(1), policy1.FailingHostCount) + + // We have an installation attempt again; the policy automation has been re-triggered + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + + // Update the include any labels. The host doesn't have label2, so this means that the software + // moved out of scope. + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + InstallScript: ptr.String("some install script"), + PreInstallQuery: ptr.String("some pre install query"), + PostInstallScript: ptr.String("some post install script"), + Filename: "ruby.deb", + TitleID: rubyDebTitleID, + TeamID: nil, + LabelsIncludeAny: []string{lbl2.Name}, + }, http.StatusOK, "") + + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Update to exclude any with label 2. This moves the software back into scope. The policy + // automation should re-trigger. + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + InstallScript: ptr.String("some install script"), + PreInstallQuery: ptr.String("some pre install query"), + PostInstallScript: ptr.String("some post install script"), + Filename: "ruby.deb", + TitleID: rubyDebTitleID, + TeamID: nil, + LabelsExcludeAny: []string{lbl2.Name}, + }, http.StatusOK, "") + + 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 in scope, the policy should be failing again. + require.Equal(t, uint(0), policy1.PassingHostCount) + require.Equal(t, uint(1), policy1.FailingHostCount) + + // We have an installation attempt again. + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) +} + func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsScripts() { t := s.T() ctx := context.Background() diff --git a/server/service/testing_client.go b/server/service/testing_client.go index b3e0c8966b5a..6734c061289c 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -692,6 +692,16 @@ func (ts *withServer) updateSoftwareInstaller( require.NoError(t, w.WriteField("self_service", "false")) } } + 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()