diff --git a/ee/maintained-apps/ingesters/homebrew/ingester_test.go b/ee/maintained-apps/ingesters/homebrew/ingester_test.go index 9f7b2404c0..0e93aaf123 100644 --- a/ee/maintained-apps/ingesters/homebrew/ingester_test.go +++ b/ee/maintained-apps/ingesters/homebrew/ingester_test.go @@ -216,7 +216,7 @@ func TestIngestValidations(t *testing.T) { // The managed "is app open" query matches a running process inside the app bundle. require.Equal(t, - fmt.Sprintf("SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON p.path LIKE concat(a.path, '/%%') WHERE a.bundle_identifier = '%s');", out.UniqueIdentifier), + fmt.Sprintf("SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON substr(p.path, 1, LENGTH(a.path) + 1) = concat(a.path, '/') WHERE a.bundle_identifier = '%s');", out.UniqueIdentifier), out.Queries.Open, ) }) diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index c22c0b1b69..ad22b7815f 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -809,8 +809,18 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } var shouldDoSideEffects bool - if err := svc.reconcilePatchPolicy(ctx, payload, existingInstaller); err != nil { - return nil, err + var existingPolicy *fleet.PatchPolicyData + var patchFlag, patchWhenClosedFlag bool + + if existingInstaller.FleetMaintainedAppID != nil { + existingPolicy, err = svc.ds.GetPatchPolicy(ctx, payload.TeamID, payload.TitleID) + if err != nil && !fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, err, "getting patch policy") + } + patchFlag, patchWhenClosedFlag, err = planPatchPolicy(payload, existingInstaller, existingPolicy) + if err != nil { + return nil, err + } } // persist changes starting here, now that we've done all the validation/diffing we can @@ -928,6 +938,30 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } } + // Create, update, or delete the patch policy after the installer save + patchTeamID := ptr.ValOrZero(payload.TeamID) + switch { + case !patchFlag && existingPolicy != nil: + if _, err := svc.DeleteTeamPolicies(ctx, patchTeamID, []uint{existingPolicy.ID}); err != nil { + return nil, ctxerr.Wrap(ctx, err, "deleting patch policy") + } + case patchFlag && existingPolicy == nil: + patchType := fleet.PolicyTypePatch + if _, err := svc.NewTeamPolicy(ctx, patchTeamID, fleet.NewTeamPolicyPayload{ + Type: &patchType, + PatchSoftwareTitleID: &payload.TitleID, + PatchWhenClosed: patchWhenClosedFlag, + // patch_when_closed requires continuous automations on; the create rejects it otherwise. + ContinuousAutomationsEnabled: patchWhenClosedFlag, + }); err != nil { + return nil, ctxerr.Wrap(ctx, err, "creating patch policy") + } + case patchFlag && existingPolicy != nil && patchWhenClosedFlag != existingPolicy.PatchWhenClosed: + if _, err := svc.ModifyTeamPolicy(ctx, patchTeamID, existingPolicy.ID, fleet.ModifyPolicyPayload{PatchWhenClosed: &patchWhenClosedFlag}); err != nil { + return nil, ctxerr.Wrap(ctx, err, "modifying patch policy") + } + } + // re-pull the edited installer to reflect side effects; return that specific // package, not the title's first-added one. May be able to optimize this out later. updatedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctxdb.RequirePrimary(ctx, true), payload.TeamID, payload.TitleID, payload.InstallerID, true) @@ -944,66 +978,43 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return updatedInstaller, nil } -func (svc *Service) reconcilePatchPolicy(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload, installer *fleet.SoftwareInstaller) error { - // Only the Fleet-maintained package has a managed pre-install query, so nothing here applies - // to any other package. Patch controls on a non-FMA package are rejected by the caller. +func planPatchPolicy(payload *fleet.UpdateSoftwareInstallerPayload, installer *fleet.SoftwareInstaller, existingPolicy *fleet.PatchPolicyData) (patchFlag bool, patchWhenClosedFlag bool, err error) { + // Only the Fleet-maintained package has a managed pre-install query; patch controls on any other + // package are rejected by the caller. if installer.FleetMaintainedAppID == nil { - return nil - } - if payload.Patch == nil && payload.PatchWhenClosed == nil && payload.PreInstallQuery == nil { - return nil + return false, false, nil } - existing, err := svc.ds.GetPatchPolicy(ctx, payload.TeamID, payload.TitleID) - if err != nil && !fleet.IsNotFound(err) { - return ctxerr.Wrap(ctx, err, "getting patch policy") - } - - patchEnabled := existing != nil - if payload.Patch != nil { - patchEnabled = *payload.Patch - } - // patch_when_closed needs a patch policy: reject when patch is disabled, or when it's omitted - // and the title has no policy to enable it on. - if payload.PatchWhenClosed != nil && *payload.PatchWhenClosed && !patchEnabled { - return &fleet.BadRequestError{Message: `"patch_when_closed" requires "patch" to be enabled.`} - } - // Default patch_when_closed on for a new patch policy, otherwise keep the existing value. - patchWhenClosed := existing != nil && existing.PatchWhenClosed - switch { - case !patchEnabled: - patchWhenClosed = false - case payload.PatchWhenClosed != nil: - patchWhenClosed = *payload.PatchWhenClosed - case existing == nil: - patchWhenClosed = true - } - - // The pre-install query is read-only while patch_when_closed is enabled. - if patchWhenClosed && payload.PreInstallQuery != nil { - return &fleet.BadRequestError{Message: `Couldn't edit. "pre_install_query" is managed by Fleet and can't be set directly while "patch_when_closed" is enabled.`} - } - - teamID := ptr.ValOrZero(payload.TeamID) - switch { - case !patchEnabled: - if existing == nil { - return nil + // Resolve both optional flags into plain bools so the logic below never touches the pointers. + // An omitted patch keeps the current state. + if payload.Patch == nil { + if existingPolicy != nil { + patchFlag = true } - _, err = svc.DeleteTeamPolicies(ctx, teamID, []uint{existing.ID}) - case existing == nil: - patchType := fleet.PolicyTypePatch - _, err = svc.NewTeamPolicy(ctx, teamID, fleet.NewTeamPolicyPayload{ - Type: &patchType, - PatchSoftwareTitleID: &payload.TitleID, - PatchWhenClosed: patchWhenClosed, - }) - case patchWhenClosed != existing.PatchWhenClosed: - _, err = svc.ModifyTeamPolicy(ctx, teamID, existing.ID, fleet.ModifyPolicyPayload{PatchWhenClosed: &patchWhenClosed}) - default: - return nil + } else { + patchFlag = *payload.Patch } - return ctxerr.Wrap(ctx, err, "reconciling patch policy") + // An omitted patch_when_closed keeps the current value, or defaults on for a new policy. + if payload.PatchWhenClosed == nil { + if existingPolicy != nil { + patchWhenClosedFlag = existingPolicy.PatchWhenClosed + } else { + patchWhenClosedFlag = true + } + } else { + patchWhenClosedFlag = *payload.PatchWhenClosed + } + + // patch_when_closed is only meaningful with patch enabled (in this request or already on the title). + if payload.PatchWhenClosed != nil && !patchFlag { + return false, false, &fleet.BadRequestError{Message: `If "patch_when_closed" is set, "patch" must be true.`} + } + + // The pre-install query is read-only only while patch_when_closed will actually be in effect. + if patchFlag && patchWhenClosedFlag && payload.PreInstallQuery != nil { + return false, false, &fleet.BadRequestError{Message: `Couldn't edit. "pre_install_query" is managed by Fleet and can't be set directly while "patch_when_closed" is enabled.`} + } + return patchFlag, patchWhenClosedFlag, nil } func (svc *Service) validateEmbeddedSecretsOnScript(ctx context.Context, scriptName string, script *string, diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go index d213a9a1dc..1efe34b261 100644 --- a/ee/server/service/software_installers_test.go +++ b/ee/server/service/software_installers_test.go @@ -2652,115 +2652,78 @@ func TestNormalizeSetupExperiencePlatforms(t *testing.T) { } } -func TestReconcilePatchPolicy(t *testing.T) { - ctx := context.Background() +func TestPlanPatchPolicy(t *testing.T) { titleID := uint(42) teamID := uint(0) fmaInstaller := &fleet.SoftwareInstaller{TitleID: &titleID, FleetMaintainedAppID: new(uint(7)), PreInstallQuery: "SELECT old;"} nonFMAInstaller := &fleet.SoftwareInstaller{TitleID: &titleID} - // setup resolves GetPatchPolicy to existing (nil means the title has no patch policy). - setup := func(t *testing.T, existing *fleet.PatchPolicyData) (*Service, *svcmock.Service) { - ds := new(mock.Store) - ds.GetPatchPolicyFunc = func(ctx context.Context, gotTeamID *uint, gotTitleID uint) (*fleet.PatchPolicyData, error) { - if existing == nil { - return nil, ¬FoundError{} - } - return existing, nil - } - return newTestServiceWithMock(t, ds) - } - payload := func(patch *bool, patchWhenClosed *bool) *fleet.UpdateSoftwareInstallerPayload { return &fleet.UpdateSoftwareInstallerPayload{TitleID: titleID, TeamID: &teamID, Patch: patch, PatchWhenClosed: patchWhenClosed} } - // patch_when_closed can't be enabled unless patch is enabled too, whether patch is omitted - // (with no existing policy) or explicitly disabled. + // patch_when_closed set without patch enabled is rejected, whether patch is omitted with no + // existing policy or explicitly disabled. t.Run("rejects patch_when_closed without patch", func(t *testing.T) { - svc, _ := setup(t, nil) - require.ErrorContains(t, svc.reconcilePatchPolicy(ctx, payload(nil, new(true)), fmaInstaller), "requires") - require.ErrorContains(t, svc.reconcilePatchPolicy(ctx, payload(new(false), new(true)), fmaInstaller), "requires") + _, _, err := planPatchPolicy(payload(nil, new(true)), fmaInstaller, nil) + require.ErrorContains(t, err, `"patch" must be true`) + _, _, err = planPatchPolicy(payload(new(false), new(true)), fmaInstaller, nil) + require.ErrorContains(t, err, `"patch" must be true`) }) // While patch_when_closed is on, the user pre-install query is managed and can't be edited. t.Run("rejects pre-install edit while managed", func(t *testing.T) { - svc, _ := setup(t, &fleet.PatchPolicyData{ID: 9, PatchWhenClosed: true}) p := payload(nil, nil) p.PreInstallQuery = new("SELECT changed;") - err := svc.reconcilePatchPolicy(ctx, p, fmaInstaller) + _, _, err := planPatchPolicy(p, fmaInstaller, &fleet.PatchPolicyData{ID: 9, PatchWhenClosed: true}) require.ErrorContains(t, err, "managed by Fleet") }) - // A pre-install edit on a non-FMA package is never managed. + // A pre-install edit on a non-FMA package is never managed; nothing to plan. t.Run("allows pre-install edit on non-FMA package", func(t *testing.T) { - svc, _ := setup(t, &fleet.PatchPolicyData{ID: 9, PatchWhenClosed: true}) p := payload(nil, nil) p.PreInstallQuery = new("SELECT changed;") - require.NoError(t, svc.reconcilePatchPolicy(ctx, p, nonFMAInstaller)) + patchFlag, _, err := planPatchPolicy(p, nonFMAInstaller, &fleet.PatchPolicyData{ID: 9, PatchWhenClosed: true}) + require.NoError(t, err) + assert.False(t, patchFlag) }) - // patch:true with no existing policy creates one with the requested patch_when_closed. + // patch:true with no existing policy plans a create with patch_when_closed on. t.Run("creates when no policy exists", func(t *testing.T) { - svc, base := setup(t, nil) - base.NewTeamPolicyFunc = func(ctx context.Context, tID uint, p fleet.NewTeamPolicyPayload) (*fleet.Policy, error) { - require.NotNil(t, p.Type) - assert.Equal(t, fleet.PolicyTypePatch, *p.Type) - assert.True(t, p.PatchWhenClosed) - return &fleet.Policy{}, nil - } - require.NoError(t, svc.reconcilePatchPolicy(ctx, payload(new(true), new(true)), fmaInstaller)) - assert.True(t, base.NewTeamPolicyFuncInvoked) + patchFlag, patchWhenClosedFlag, err := planPatchPolicy(payload(new(true), new(true)), fmaInstaller, nil) + require.NoError(t, err) + assert.True(t, patchFlag) + assert.True(t, patchWhenClosedFlag) }) // patch:true with patch_when_closed omitted defaults a new policy to "only when closed". t.Run("new policy defaults to patch_when_closed", func(t *testing.T) { - svc, base := setup(t, nil) - var got bool - base.NewTeamPolicyFunc = func(ctx context.Context, tID uint, p fleet.NewTeamPolicyPayload) (*fleet.Policy, error) { - got = p.PatchWhenClosed - return &fleet.Policy{}, nil - } - require.NoError(t, svc.reconcilePatchPolicy(ctx, payload(new(true), nil), fmaInstaller)) - assert.True(t, got) + _, patchWhenClosedFlag, err := planPatchPolicy(payload(new(true), nil), fmaInstaller, nil) + require.NoError(t, err) + assert.True(t, patchWhenClosedFlag) }) - // patch:false deletes the existing patch policy. - t.Run("deletes when patch disabled", func(t *testing.T) { - svc, base := setup(t, &fleet.PatchPolicyData{ID: 9}) - var deleted []uint - base.DeleteTeamPoliciesFunc = func(ctx context.Context, tID uint, ids []uint) ([]uint, error) { - deleted = ids - return ids, nil - } - require.NoError(t, svc.reconcilePatchPolicy(ctx, payload(new(false), nil), fmaInstaller)) - assert.Equal(t, []uint{9}, deleted) + // patch:false disables the existing patch policy. + t.Run("disables when patch off", func(t *testing.T) { + patchFlag, _, err := planPatchPolicy(payload(new(false), nil), fmaInstaller, &fleet.PatchPolicyData{ID: 9}) + require.NoError(t, err) + assert.False(t, patchFlag) }) - // Toggling patch_when_closed on an existing policy updates it rather than recreating it. + // Toggling patch_when_closed on an existing policy keeps patch on and flips the value. t.Run("updates patch_when_closed on existing policy", func(t *testing.T) { - svc, base := setup(t, &fleet.PatchPolicyData{ID: 9, PatchWhenClosed: false}) - var modified *bool - base.ModifyTeamPolicyFunc = func(ctx context.Context, tID uint, id uint, p fleet.ModifyPolicyPayload) (*fleet.Policy, error) { - assert.Equal(t, uint(9), id) - modified = p.PatchWhenClosed - return &fleet.Policy{}, nil - } - require.NoError(t, svc.reconcilePatchPolicy(ctx, payload(nil, new(true)), fmaInstaller)) - assert.False(t, base.NewTeamPolicyFuncInvoked) - require.NotNil(t, modified) - assert.True(t, *modified) + patchFlag, patchWhenClosedFlag, err := planPatchPolicy(payload(nil, new(true)), fmaInstaller, &fleet.PatchPolicyData{ID: 9, PatchWhenClosed: false}) + require.NoError(t, err) + assert.True(t, patchFlag) + assert.True(t, patchWhenClosedFlag) }) - // A pre-install edit is allowed and touches no policy when the title's patch policy has - // patch_when_closed off. + // A pre-install edit is allowed when the title's patch policy has patch_when_closed off. t.Run("pre-install edit allowed when patch_when_closed is off", func(t *testing.T) { - svc, base := setup(t, &fleet.PatchPolicyData{ID: 9, PatchWhenClosed: false}) p := payload(nil, nil) p.PreInstallQuery = new("SELECT changed;") - require.NoError(t, svc.reconcilePatchPolicy(ctx, p, fmaInstaller)) - assert.False(t, base.NewTeamPolicyFuncInvoked) - assert.False(t, base.ModifyTeamPolicyFuncInvoked) - assert.False(t, base.DeleteTeamPoliciesFuncInvoked) + _, patchWhenClosedFlag, err := planPatchPolicy(p, fmaInstaller, &fleet.PatchPolicyData{ID: 9, PatchWhenClosed: false}) + require.NoError(t, err) + assert.False(t, patchWhenClosedFlag) }) } diff --git a/pkg/patch_policy/patch_policy.go b/pkg/patch_policy/patch_policy.go index 484a6e15f5..fa76966ba1 100644 --- a/pkg/patch_policy/patch_policy.go +++ b/pkg/patch_policy/patch_policy.go @@ -169,7 +169,7 @@ func defaultMacOSOpenQuery(bundleIdentifier string) string { // - get processes by name - requires a lot of manual overrides // - use the running_apps table - not reliable when run through orbit // - use the "app" artifact in the homebrew cask - requires extra code to extract - openTemplate := "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON p.path LIKE concat(a.path, '/%%') WHERE a.bundle_identifier = '%s');" + openTemplate := "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON substr(p.path, 1, LENGTH(a.path) + 1) = concat(a.path, '/') WHERE a.bundle_identifier = '%s');" return fmt.Sprintf(openTemplate, escapeSQLLiteral(bundleIdentifier)) } @@ -235,7 +235,7 @@ var windowsOpenQueryOverrides = map[string]string{ //nolint:gosec // G101 false "PyCharm Community Edition": "IN ('pycharm.exe','pycharm64.exe')", "PyCharm Professional": "IN ('pycharm.exe','pycharm64.exe')", "Rider": "IN ('rider.exe','rider64.exe')", - "RStudio": "IN ('rgui.exe','rsession.exe' 'rstudio.exe')", + "RStudio": "IN ('rgui.exe','rsession.exe','rstudio.exe')", "RubyMine": "IN ('rubymine.exe','rubymine64.exe')", "RustRover": "IN ('rustrover.exe','rustrover64.exe')", "Spotify": "IN ('spotify.exe','spotifywebhelper.exe')", diff --git a/pkg/patch_policy/patch_policy_test.go b/pkg/patch_policy/patch_policy_test.go index fa57d37349..2052e15de2 100644 --- a/pkg/patch_policy/patch_policy_test.go +++ b/pkg/patch_policy/patch_policy_test.go @@ -74,11 +74,11 @@ func TestGenerateOpenQuery(t *testing.T) { // macOS resolves the app's install path from its bundle identifier and matches a process // running from inside it. got := patch_policy.GenerateOpenQuery("darwin", "org.mozilla.firefox", "") - require.Equal(t, "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON p.path LIKE concat(a.path, '/%') WHERE a.bundle_identifier = 'org.mozilla.firefox');", got) + require.Equal(t, "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON substr(p.path, 1, LENGTH(a.path) + 1) = concat(a.path, '/') WHERE a.bundle_identifier = 'org.mozilla.firefox');", got) // Apostrophes in the bundle identifier are escaped so they can't break the literal. got = patch_policy.GenerateOpenQuery("darwin", "com.oreilly.o'reilly", "") - require.Equal(t, "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON p.path LIKE concat(a.path, '/%') WHERE a.bundle_identifier = 'com.oreilly.o''reilly');", got) + require.Equal(t, "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON substr(p.path, 1, LENGTH(a.path) + 1) = concat(a.path, '/') WHERE a.bundle_identifier = 'com.oreilly.o''reilly');", got) // Windows matches a process named ".exe". got = patch_policy.GenerateOpenQuery("windows", "", "Slack") diff --git a/server/datastore/mysql/migrations/tables/20260721173820_PatchWhenClosed_test.go b/server/datastore/mysql/migrations/tables/20260721173820_PatchWhenClosed_test.go index 6d3bdee64d..e674e29929 100644 --- a/server/datastore/mysql/migrations/tables/20260721173820_PatchWhenClosed_test.go +++ b/server/datastore/mysql/migrations/tables/20260721173820_PatchWhenClosed_test.go @@ -49,7 +49,7 @@ func TestUp_20260721173820(t *testing.T) { `SELECT patch_when_closed FROM policies WHERE id = ?`, policy2)) assert.True(t, patchWhenClosed) - const managedQuery = "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON p.path LIKE concat(a.path, '/%') WHERE a.bundle_identifier = 'com.example.app');" + const managedQuery = "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON substr(p.path, 1, LENGTH(a.path) + 1) = concat(a.path, '/') WHERE a.bundle_identifier = 'com.example.app');" title2 := execNoErrLastID(t, db, `INSERT INTO software_titles (name, source, extension_for) VALUES ('App2', 'apps', '')`) installer2 := execNoErrLastID(t, db, ` diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 2f2631e843..1450ab9cbd 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -1782,6 +1782,13 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs if err != nil { return ctxerr.Wrap(ctx, err, "getting patch policy installer") } + + // Defensive: this can only happen if this endpoint is being called not via gitops + // and batch software didn't get called before. + if spec.PatchWhenClosed && installer.PreInstallQuery != "" { + return ctxerr.Errorf(ctx, "policy %q: pre_install_query can't be set on Fleet-maintained app %q when patch_when_closed is true", spec.Name, spec.FleetMaintainedAppSlug) + } + generated, err := patch_policy.GenerateFromInstaller(patch_policy.PolicyData{ Name: spec.Name, Description: spec.Description, diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index c44480e44d..5b1840d4fd 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -93,6 +93,7 @@ func TestPolicies(t *testing.T) { {"PolicyModificationResetsAttemptNumber", testPolicyModificationResetsAttemptNumber}, {"TeamPatchPolicy", testTeamPatchPolicy}, {"ApplyPolicySpecsDynamicAndPatchSameFMA", testApplyPolicySpecsDynamicAndPatchSameFMA}, + {"ApplyPolicySpecsPatchWhenClosedRejectsPreInstallQuery", testApplyPolicySpecsPatchWhenClosedRejectsPreInstallQuery}, {"ApplyPolicySpecsRenamePatchPolicyRegression43687", testApplyPolicySpecsRenamePatchPolicyRegression43687}, {"TeamPolicyAutomationFilter", testTeamPolicyAutomationFilter}, {"BatchedPolicyMembershipCleanup", testBatchedPolicyMembershipCleanup}, @@ -8333,6 +8334,64 @@ func testApplyPolicySpecsDynamicAndPatchSameFMA(t *testing.T, ds *Datastore) { require.Equal(t, fmaTitleID, *patch.PatchSoftwareTitleID) } +func testApplyPolicySpecsPatchWhenClosedRejectsPreInstallQuery(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: "team-pwc-pre-install"}) + require.NoError(t, err) + + maintainedApp, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{ + Name: "Maintained2", + Slug: "maintained2", + Platform: "darwin", + UniqueIdentifier: "fleet.maintained2", + }) + require.NoError(t, err) + + // The package carries a user-set pre-install query. + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + StorageID: "storage-pwc-pre-install", + Filename: "maintained2", + Title: "Maintained2", + Version: "1.0", + Source: "apps", + Platform: "darwin", + BundleIdentifier: "fleet.maintained2", + UserID: user1.ID, + TeamID: &team1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + FleetMaintainedAppID: &maintainedApp.ID, + }) + require.NoError(t, err) + + spec := func(patchWhenClosed bool) []*fleet.PolicySpec { + return []*fleet.PolicySpec{{ + Name: "patch-fma-when-closed", + Query: "SELECT 1;", + Team: team1.Name, + Type: fleet.PolicyTypePatch, + FleetMaintainedAppSlug: "maintained2", + PatchWhenClosed: patchWhenClosed, + }} + } + + // patch_when_closed is rejected while the package has its own pre-install query. + err = ds.ApplyPolicySpecs(ctx, user1.ID, spec(true)) + require.ErrorContains(t, err, "pre_install_query can't be set on Fleet-maintained app") + + // The rejected batch wrote nothing. + var count int + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &count, `SELECT COUNT(*) FROM policies WHERE name = ?`, "patch-fma-when-closed") + }) + require.Zero(t, count) + + // The same spec applies without patch_when_closed. + require.NoError(t, ds.ApplyPolicySpecs(ctx, user1.ID, spec(false))) +} + // testApplyPolicySpecsRenamePatchPolicyRegression43687 reproduces the customer // scenario from issue #43687: GitOps renaming a patch policy that references // an FMA (e.g. "Adobe Reader up to date" -> "Adobe Reader") used to 5xx with diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 1aff29ab2b..d930d62594 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -1990,6 +1990,33 @@ func (ds *Datastore) ProcessInstallerUpdateSideEffects(ctx context.Context, inst return ds.activateNextUpcomingActivityForBatchOfHosts(ctx, activateAffectedHostIDs) } +func (ds *Datastore) ClearPreInstallQueryForTitle(ctx context.Context, teamID uint, titleID uint) error { + // An FMA title has one is_active=1 row, so team and title identify the managed installer. + var installer fleet.SoftwareInstaller + err := sqlx.GetContext(ctx, ds.writer(ctx), &installer, ` + SELECT id, COALESCE(pre_install_query, '') AS pre_install_query + FROM software_installers + WHERE global_or_team_id = ? + AND title_id = ? + AND fleet_maintained_app_id IS NOT NULL + AND is_active = 1 + LIMIT 1`, teamID, titleID) + switch { + case errors.Is(err, sql.ErrNoRows): + return nil + case err != nil: + return ctxerr.Wrap(ctx, err, "get title installer") + case installer.PreInstallQuery == "": + return nil + } + + if _, err := ds.writer(ctx).ExecContext(ctx, + `UPDATE software_installers SET pre_install_query = '' WHERE id = ?`, installer.InstallerID); err != nil { + return ctxerr.Wrap(ctx, err, "clear pre-install query for title") + } + return ds.ProcessInstallerUpdateSideEffects(ctx, installer.InstallerID, true, false) +} + func (ds *Datastore) runInstallerUpdateSideEffectsInTransaction(ctx context.Context, tx sqlx.ExtContext, installerID uint, wasMetadataUpdated bool, wasPackageUpdated bool, isEdit bool) (affectedHostIDs []uint, err error) { if wasMetadataUpdated || wasPackageUpdated { // cancel pending installs/uninstalls // TODO make this less naive; this assumes that installs/uninstalls execute and report back immediately diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index e834699637..6c5ba024d4 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -7183,7 +7183,7 @@ func testGetSoftwareInstallDetailsPatchWhenClosed(t *testing.T, ds *Datastore) { require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID}))) const userQuery = "SELECT 1 FROM user_configured_query;" - const managedQuery = "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON p.path LIKE concat(a.path, '/%') WHERE a.bundle_identifier = 'com.example.pwc');" + const managedQuery = "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON substr(p.path, 1, LENGTH(a.path) + 1) = concat(a.path, '/') WHERE a.bundle_identifier = 'com.example.pwc');" // A Fleet-maintained-app-backed installer carries both the user pre-install query and the // Fleet-managed app_open_query. @@ -7226,6 +7226,10 @@ func testGetSoftwareInstallDetailsPatchWhenClosed(t *testing.T, ds *Datastore) { }) require.NoError(t, err) require.Equal(t, patchWhenClosed, p.PatchWhenClosed) + if patchWhenClosed { + // The service does this after writing the policy; call it here to exercise the same effect. + require.NoError(t, ds.ClearPreInstallQueryForTitle(ctx, team.ID, titleID)) + } return p } @@ -7246,12 +7250,13 @@ func testGetSoftwareInstallDetailsPatchWhenClosed(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, managedQuery, activatedDetails.PreInstallCondition) - // Same installer, but a manual (non-policy) install falls back to the user query. + // Same installer via a manual (non-policy) install: no pre-install condition, because enabling + // patch_when_closed cleared the installer's user query. manualExec, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, closedInstaller, fleet.HostSoftwareInstallOptions{}) require.NoError(t, err) manualDetails, err := ds.GetSoftwareInstallDetails(ctx, manualExec) require.NoError(t, err) - require.Equal(t, userQuery, manualDetails.PreInstallCondition) + require.Empty(t, manualDetails.PreInstallCondition) // A patch policy without patch_when_closed keeps the user query on the policy path. forceInstaller, forceTitle := newInstaller(t, "pwc-force") @@ -7299,7 +7304,7 @@ func testSoftwareInstallerAppOpenQueryRoundTrip(t *testing.T, ds *Datastore) { } // The managed "is app open" query round-trips through create -> metadata read. - const managed = "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON p.path LIKE concat(a.path, '/%') WHERE a.bundle_identifier = 'com.example.app');" + const managed = "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM apps a JOIN processes p ON substr(p.path, 1, LENGTH(a.path) + 1) = concat(a.path, '/') WHERE a.bundle_identifier = 'com.example.app');" titleID := newInstaller(t, "app-open-1", managed) meta, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, titleID, false) require.NoError(t, err) diff --git a/server/fleet/api_policies.go b/server/fleet/api_policies.go index 9140152b7e..13f3ef407e 100644 --- a/server/fleet/api_policies.go +++ b/server/fleet/api_policies.go @@ -187,9 +187,9 @@ type TeamPolicyRequest struct { LabelsExcludeAll []string `json:"labels_exclude_all" premium:"true"` ConditionalAccessEnabled bool `json:"conditional_access_enabled"` ContinuousAutomationsEnabled bool `json:"continuous_automations_enabled" premium:"true"` - PatchWhenClosed bool `json:"patch_when_closed" premium:"true"` Type *string `json:"type"` PatchSoftwareTitleID *uint `json:"patch_software_title_id"` + PatchWhenClosed bool `json:"patch_when_closed" premium:"true"` } type TeamPolicyResponse struct { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 7c0e523784..07708fbebf 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2931,6 +2931,10 @@ type Datastore interface { // to how the virtual column works). ProcessInstallerUpdateSideEffects(ctx context.Context, installerID uint, wasMetadataUpdated bool, wasPackageUpdated bool) error + // ClearPreInstallQueryForTitle blanks the pre-install query on a title's active Fleet-maintained + // installer and cancels its pending installs. No-op when the query is already empty. + ClearPreInstallQueryForTitle(ctx context.Context, teamID uint, titleID uint) error + // SaveInstallerUpdates persists new values to an existing installer. See comments in the payload struct // for which fields must be set. SaveInstallerUpdates(ctx context.Context, payload *UpdateSoftwareInstallerPayload) error diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index beb7ab1df7..5062e2d7bd 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1656,6 +1656,8 @@ type UpdateInstallerUpgradeCodeFunc func(ctx context.Context, id uint, upgradeCo type ProcessInstallerUpdateSideEffectsFunc func(ctx context.Context, installerID uint, wasMetadataUpdated bool, wasPackageUpdated bool) error +type ClearPreInstallQueryForTitleFunc func(ctx context.Context, teamID uint, titleID uint) error + type SaveInstallerUpdatesFunc func(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) error type UpdateInstallerSelfServiceFlagFunc func(ctx context.Context, selfService bool, id uint) error @@ -4687,6 +4689,9 @@ type DataStore struct { ProcessInstallerUpdateSideEffectsFunc ProcessInstallerUpdateSideEffectsFunc ProcessInstallerUpdateSideEffectsFuncInvoked bool + ClearPreInstallQueryForTitleFunc ClearPreInstallQueryForTitleFunc + ClearPreInstallQueryForTitleFuncInvoked bool + SaveInstallerUpdatesFunc SaveInstallerUpdatesFunc SaveInstallerUpdatesFuncInvoked bool @@ -11275,6 +11280,13 @@ func (s *DataStore) ProcessInstallerUpdateSideEffects(ctx context.Context, insta return s.ProcessInstallerUpdateSideEffectsFunc(ctx, installerID, wasMetadataUpdated, wasPackageUpdated) } +func (s *DataStore) ClearPreInstallQueryForTitle(ctx context.Context, teamID uint, titleID uint) error { + s.mu.Lock() + s.ClearPreInstallQueryForTitleFuncInvoked = true + s.mu.Unlock() + return s.ClearPreInstallQueryForTitleFunc(ctx, teamID, titleID) +} + func (s *DataStore) SaveInstallerUpdates(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) error { s.mu.Lock() s.SaveInstallerUpdatesFuncInvoked = true diff --git a/server/service/global_policies.go b/server/service/global_policies.go index 46a0b15ffa..42e9b7b4ca 100644 --- a/server/service/global_policies.go +++ b/server/service/global_policies.go @@ -256,7 +256,7 @@ func (svc Service) removeGlobalPoliciesFromWebhookConfig(ctx context.Context, id const ( errPolicyAllFleetsForConditionalAccess = "\"All fleets\" policy cannot have conditional_access_enabled set" errPolicyAllFleetsForContinuousAutomations = "\"All fleets\" policy cannot have continuous_automations_enabled set" - errPatchWhenClosedRequiresContinuousAutomations = "\"continuous_automations_enabled\" cannot be disabled while \"patch_when_closed\" is enabled" + errPatchWhenClosedRequiresContinuousAutomations = "If \"patch_when_closed\" is true, \"continuous_automations_enabled\" can't be set to false." ) func modifyGlobalPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { @@ -500,6 +500,11 @@ func (svc *Service) ApplyPolicySpecs(ctx context.Context, policies []*fleet.Poli return fleet.ErrMissingLicense } + // PatchWhenClosed is premium-only. + if policy.PatchWhenClosed && !license.IsPremium(ctx) { + return fleet.ErrMissingLicense + } + // Make sure any applied labels exist. labels := slices.Concat(policy.LabelsIncludeAny, policy.LabelsIncludeAll, policy.LabelsExcludeAny, policy.LabelsExcludeAll) if len(labels) > 0 { diff --git a/server/service/global_policies_test.go b/server/service/global_policies_test.go index 91da610c50..ddd67ff9aa 100644 --- a/server/service/global_policies_test.go +++ b/server/service/global_policies_test.go @@ -555,6 +555,54 @@ func TestApplyPolicySpecsLabelScopeRequiresPremium(t *testing.T) { require.False(t, ds.ApplyPolicySpecsFuncInvoked) } +func TestApplyPolicySpecsPatchWhenClosedRequiresPremium(t *testing.T) { + newDS := func() *mock.Store { + ds := new(mock.Store) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + return &fleet.Team{ID: 1, Name: name}, nil + } + ds.ApplyPolicySpecsFunc = func(ctx context.Context, authorID uint, specs []*fleet.PolicySpec) error { + return nil + } + return ds + } + + // patch_when_closed requires a patch-type team policy. + patchSpec := func() *fleet.PolicySpec { + return &fleet.PolicySpec{ + Name: "patch policy", + Team: "team1", + Type: fleet.PolicyTypePatch, + PatchWhenClosed: true, + } + } + + testAdmin := fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)} + + // A free-tier caller can't apply patch_when_closed, and we never reach the datastore. + t.Run("free tier rejected", func(t *testing.T) { + ds := newDS() + svc, ctx := newTestService(t, ds, nil, nil) + viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: &testAdmin}) + err := svc.ApplyPolicySpecs(viewerCtx, []*fleet.PolicySpec{patchSpec()}) + require.ErrorIs(t, err, fleet.ErrMissingLicense) + require.False(t, ds.ApplyPolicySpecsFuncInvoked) + }) + + // A premium caller applies it successfully. + t.Run("premium accepted", func(t *testing.T) { + ds := newDS() + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}) + viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: &testAdmin}) + err := svc.ApplyPolicySpecs(viewerCtx, []*fleet.PolicySpec{patchSpec()}) + require.NoError(t, err) + require.True(t, ds.ApplyPolicySpecsFuncInvoked) + }) +} + func TestApplyPolicySpecsDefaultType(t *testing.T) { ds := new(mock.Store) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { diff --git a/server/service/software_titles.go b/server/service/software_titles.go index 80b2db95a7..b722b7610d 100644 --- a/server/service/software_titles.go +++ b/server/service/software_titles.go @@ -261,6 +261,12 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint return nil, ctxerr.Wrap(ctx, err, "get patch policy") } pkg.PatchPolicy = patchPolicy + + // While patch_when_closed is on, the pre-install query is Fleet's managed + // app open query, shown read-only. + if patchPolicy != nil && patchPolicy.PatchWhenClosed { + pkg.PreInstallQuery = pkg.AppOpenQuery + } } } diff --git a/server/service/team_policies.go b/server/service/team_policies.go index 0510d79e6c..7c4e0a2242 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -42,9 +42,9 @@ func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Serv LabelsExcludeAll: req.LabelsExcludeAll, ConditionalAccessEnabled: req.ConditionalAccessEnabled, ContinuousAutomationsEnabled: req.ContinuousAutomationsEnabled, - PatchWhenClosed: req.PatchWhenClosed, Type: req.Type, PatchSoftwareTitleID: req.PatchSoftwareTitleID, + PatchWhenClosed: req.PatchWhenClosed, }) if err != nil { return fleet.TeamPolicyResponse{Err: err}, nil @@ -104,6 +104,12 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, tp fleet.NewT return nil, ctxerr.Wrap(ctx, err, "populate automations") } + if policy.Type == fleet.PolicyTypePatch && policy.PatchWhenClosed && policy.PatchSoftwareTitleID != nil { + if err := svc.ds.ClearPreInstallQueryForTitle(ctx, teamID, *policy.PatchSoftwareTitleID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "clear pre-install query for title") + } + } + if teamID == 0 { noTeamID := int64(0) if err := svc.NewActivity( @@ -304,9 +310,8 @@ func (svc *Service) newTeamPolicyPayloadToPolicyPayload(ctx context.Context, tea } // Continuous automations must be enabled so the patch policy keeps retrying until the app is closed. - continuousAutomationsEnabled := p.ContinuousAutomationsEnabled - if p.PatchWhenClosed { - continuousAutomationsEnabled = true + if p.PatchWhenClosed && !p.ContinuousAutomationsEnabled { + return fleet.PolicyPayload{}, &fleet.BadRequestError{Message: errPatchWhenClosedRequiresContinuousAutomations} } return fleet.PolicyPayload{ @@ -326,7 +331,7 @@ func (svc *Service) newTeamPolicyPayloadToPolicyPayload(ctx context.Context, tea LabelsExcludeAny: p.LabelsExcludeAny, LabelsExcludeAll: p.LabelsExcludeAll, ConditionalAccessEnabled: p.ConditionalAccessEnabled, - ContinuousAutomationsEnabled: continuousAutomationsEnabled, + ContinuousAutomationsEnabled: p.ContinuousAutomationsEnabled, PatchWhenClosed: p.PatchWhenClosed, Type: policyType, PatchSoftwareTitleID: p.PatchSoftwareTitleID, @@ -713,10 +718,9 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f if p.PatchWhenClosed != nil { patchWhenClosed = *p.PatchWhenClosed } + // patch_when_closed needs continuous automations: reject an explicit false, otherwise force it on. if patchWhenClosed && p.ContinuousAutomationsEnabled != nil && !*p.ContinuousAutomationsEnabled { - return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ - Message: fmt.Sprintf("policy payload verification: %s", errPatchWhenClosedRequiresContinuousAutomations), - }) + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: errPatchWhenClosedRequiresContinuousAutomations}) } if patchWhenClosed { policy.ContinuousAutomationsEnabled = true @@ -797,6 +801,12 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f return nil, ctxerr.Wrap(ctx, err, "populate automations") } + if policy.Type == fleet.PolicyTypePatch && policy.PatchWhenClosed && policy.PatchSoftwareTitleID != nil { + if err := svc.ds.ClearPreInstallQueryForTitle(ctx, ptr.ValOrZero(teamID), *policy.PatchSoftwareTitleID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "clear pre-install query for title") + } + } + if teamID == nil { globalTeamID := int64(-1) if err := svc.NewActivity( diff --git a/server/service/team_policies_test.go b/server/service/team_policies_test.go index c1f1d12ffd..01931ce282 100644 --- a/server/service/team_policies_test.go +++ b/server/service/team_policies_test.go @@ -237,17 +237,22 @@ func TestTeamPolicyPatchWhenClosed(t *testing.T) { ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, tID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{TitleID: new(patchSoftwareTitleID), SoftwareTitle: "App", DisplayName: "App"}, nil } + ds.ClearPreInstallQueryForTitleFunc = func(ctx context.Context, teamID uint, titleID uint) error { + return nil + } return ds } - // Creating a patch-when-closed policy forces continuous automations on, even when the - // request left the field at its default false. - t.Run("create auto-sets continuous automations", func(t *testing.T) { + // Creating a patch-when-closed policy with continuous automations on succeeds and clears the + // title's managed pre-install query. + t.Run("create patch-when-closed policy", func(t *testing.T) { ds := setupDS() var captured fleet.PolicyPayload ds.NewTeamPolicyFunc = func(ctx context.Context, tID uint, authorID *uint, args fleet.PolicyPayload) (*fleet.Policy, error) { captured = args - return freshPatchPolicy(), nil + created := freshPatchPolicy() + created.PatchWhenClosed = true + return created, nil } opts := &TestServerOpts{} svc, baseCtx := newTestService(t, ds, nil, nil, opts) @@ -256,13 +261,30 @@ func TestTeamPolicyPatchWhenClosed(t *testing.T) { } _, err := svc.NewTeamPolicy(adminCtx(baseCtx), teamID, fleet.NewTeamPolicyPayload{ - Type: &patchType, - PatchSoftwareTitleID: new(patchSoftwareTitleID), - PatchWhenClosed: true, + Type: &patchType, + PatchSoftwareTitleID: new(patchSoftwareTitleID), + PatchWhenClosed: true, + ContinuousAutomationsEnabled: true, }) require.NoError(t, err) assert.True(t, captured.PatchWhenClosed) - assert.True(t, captured.ContinuousAutomationsEnabled, "patch_when_closed should force continuous automations on") + assert.True(t, captured.ContinuousAutomationsEnabled) + // enabling patch_when_closed cancels the title's pending installs so they re-evaluate + assert.True(t, ds.ClearPreInstallQueryForTitleFuncInvoked) + }) + + // continuous_automations_enabled=false with patch_when_closed=true is rejected on create too. + t.Run("create rejects disabling continuous automations", func(t *testing.T) { + ds := setupDS() + svc, baseCtx := newTestService(t, ds, nil, nil) + _, err := svc.NewTeamPolicy(adminCtx(baseCtx), teamID, fleet.NewTeamPolicyPayload{ + Type: &patchType, + PatchSoftwareTitleID: new(patchSoftwareTitleID), + PatchWhenClosed: true, + ContinuousAutomationsEnabled: false, + }) + require.Error(t, err) + require.ErrorContains(t, err, "continuous_automations_enabled") }) // patch_when_closed only applies to patch policies. @@ -270,16 +292,18 @@ func TestTeamPolicyPatchWhenClosed(t *testing.T) { ds := setupDS() svc, baseCtx := newTestService(t, ds, nil, nil) _, err := svc.NewTeamPolicy(adminCtx(baseCtx), teamID, fleet.NewTeamPolicyPayload{ - Name: "dynamic policy", - Query: "SELECT 1;", - PatchWhenClosed: true, + Name: "dynamic policy", + Query: "SELECT 1;", + // Continuous automations must be on, otherwise that check rejects the payload first. + PatchWhenClosed: true, + ContinuousAutomationsEnabled: true, }) require.Error(t, err) - require.ErrorContains(t, err, "patch_when_closed") + require.ErrorContains(t, err, "only supported for patch policies") }) - // Continuous automations can't be turned off in the same request that keeps - // patch_when_closed on. + // An explicit continuous_automations_enabled=false alongside patch_when_closed=true is rejected; + // omitting it (see next case) still auto-sets it to true. t.Run("modify rejects disabling continuous automations", func(t *testing.T) { ds := setupDS() svc, baseCtx := newTestService(t, ds, nil, nil) @@ -312,6 +336,8 @@ func TestTeamPolicyPatchWhenClosed(t *testing.T) { require.NotNil(t, saved) assert.True(t, saved.PatchWhenClosed) assert.True(t, saved.ContinuousAutomationsEnabled) + // enabling patch_when_closed cancels the title's pending installs so they re-evaluate + assert.True(t, ds.ClearPreInstallQueryForTitleFuncInvoked) }) }