Skip to content

Notify before patching: policy field, validation, and install-skip wiring #50911

Description

@cdcme

Related user story

#39178

Task

Add notify_before_patching to patch policies and make it behave like patch_when_closed at install time. The policy carries Fleet's managed app-open query as a read-only pre-install query, so an install skips while the app is open instead of quitting it. This sub-issue stops there. Turning that skip into a notification is the next sub-issue.

Model the whole change on patch_when_closed, added in #39962. Every touch point below already has a patch_when_closed line next to it.

This is the API contract the frontend and GitOps sub-issues build against, so land it early.

Migration

ALTER TABLE policies
  ADD COLUMN notify_before_patching TINYINT(1) NOT NULL DEFAULT 0;

Create it with make migration name=AddNotifyBeforePatchingToPolicies. Mirror server/datastore/mysql/migrations/tables/20260807140831_PatchWhenClosed.go, including the columnExists guard. The Down function is a no-op.

Types

Add NotifyBeforePatching beside every PatchWhenClosed in server/fleet/policies.go:

Line Struct Field
80 PolicyPayload NotifyBeforePatching bool
135 NewTeamPolicyPayload NotifyBeforePatching bool
362 ModifyPolicyPayload NotifyBeforePatching *bool \json:"notify_before_patching" premium:"true"``
469 PolicyData NotifyBeforePatching bool \json:"notify_before_patching" db:"notify_before_patching"``
677 PolicySpec NotifyBeforePatching bool \json:"notify_before_patching"``

Also add it to PolicySoftwareInstallerData (server/fleet/policies.go:528-541), which is what the automation path reads.

Use a boolean, matching the REST API doc in #50677. The UI renders it as an "End user experience" dropdown with two options, so a boolean is enough today. See open question 3 before merging: #39176 and #44672 both add options to that dropdown later.

Validation

Four rules. Marko specified the exact user-facing text in a dev note on #50678. Use it verbatim.

Rule Message
Requires a patch policy "notify_before_patching" is only supported for patch policies
Mutually exclusive with patch_when_closed Only one of "patch_when_closed" or "notify_before_patching" can be set to true
Rejects an explicit continuous_automations_enabled: false If "notify_before_patching" is true, "continuous_automations_enabled" can't be set to false.
macOS Fleet-maintained apps only "notify_before_patching" is available for macOS Fleet-maintained apps. It's coming soon to Windows.

The first goes beside errPolicyPatchWhenClosedRequiresPatch at server/fleet/policies.go:153, and is applied in the three verify paths that already check PatchWhenClosed: lines 164, 367, and 734.

The third mirrors errPatchWhenClosedRequiresContinuousAutomations at server/service/global_policies.go:259, enforced at server/service/team_policies.go:314 on create and :723 on modify. Copy that shape exactly.

Reject an explicit false, do not silently override it. Omitting continuous_automations_enabled still auto-sets it to true. Only an explicit false is an error. This is already how patch_when_closed behaves, so it is a mirror rather than a new pattern.

Why mutual exclusion. The UI presents the two as sibling radio branches, so a policy with both set is one the UI cannot render.

Update package endpoint

The Deploy modal and the Edit software modal write through PATCH /software/package, not the policy endpoint, so the flag has to be accepted there too. #50677 documents it alongside automatic_install, patch, and patch_when_closed.

UpdateSoftwareInstallerPayload already carries PatchWhenClosed *bool at server/service/software_installers.go:67-68, parsed from the multipart form at :182 and passed through at :303. Add NotifyBeforePatching *bool following it exactly. A nil pointer leaves the value unchanged.

Why a pointer. The endpoint is a partial update. A plain bool would clear the flag on every request that omits it.

Skip output text

#50677 documents that the two flags produce different pre_install_query_output on a skip:

Flag Output
patch_when_closed Query didn't return result\nThe app was open.
notify_before_patching Query didn't return result\nThe app was open. Fleet notifies the end user 1 hour before the patch is forced.

Set this where the skip is detected, so the install-results endpoint and the details modals both read it from one place.

Managed pre-install query

software_installers.app_open_query already holds the managed query (server/fleet/software_installer.go:151), sourced from the Fleet-maintained app manifest. It is applied in ee/server/service/software_installers.go:3044 and :3701 when patch_when_closed is on. Extend both conditions so notify_before_patching applies it too.

Why reuse app_open_query rather than add a column. It is the same query answering the same question: is the app running? The two features differ in what happens after the skip, not in how the skip is detected.

Install-skip detection

server/service/orbit.go:1866-1872 decides whether a failed install was an app-open skip:

	isAppOpenSkip := false
	if result.Status() == fleet.SoftwareInstallFailed &&
		result.PreInstallConditionOutput != nil && *result.PreInstallConditionOutput == "" {
		if cur, curErr := svc.ds.GetSoftwareInstallResults(ctx, result.InstallUUID); curErr == nil && cur != nil {
			isAppOpenSkip = cur.PolicyID != nil && cur.PatchWhenClosed
		}
	}

Change line 1870 to cur.PolicyID != nil && (cur.PatchWhenClosed || cur.NotifyBeforePatching), and add NotifyBeforePatching to whatever GetSoftwareInstallResults returns.

Keep the flag check, do not widen to "empty output". An ordinary empty pre_install_query on a non-managed policy must still count as a failure and consume a retry. The existing comment on line 1863 says so, and server/service/orbit_test.go:1368 guards it.

The downstream effects at orbit.go:1915 (attempt number forced to 0) and :2015 (InstallSkippedWhenAppOpen on the activity) then apply unchanged.

Policy automation

svc.processSoftwareForNewlyFailingPolicies at server/service/osquery.go:2143 already queues installs for failing policies with continuous_automations_enabled. It needs no change: the app-open gating happens agent-side via the pre-install query, and notify_before_patching forces continuous automations on.

Confirm this by reading the function rather than assuming. If it filters on PatchWhenClosed anywhere, add the new flag there too.

Condition of satisfaction

Migration and round-trip

  • MYSQL_TEST=1 go test ./server/datastore/mysql/... passes, and schema.sql is regenerated.
  • A policy created with notify_before_patching: true reads back true from the API.

Validation

  • notify_before_patching: true on a dynamic policy returns 422.
  • notify_before_patching: true with platform: windows returns the Windows message.
  • notify_before_patching: true together with patch_when_closed: true returns 422.
  • notify_before_patching: true sets continuous_automations_enabled: true when the request omits it.
  • notify_before_patching: true with an explicit continuous_automations_enabled: false returns 400 with the documented message, on both create and modify.
  • Each of the four validation messages matches the text in the table above, character for character.
  • Setting notify_before_patching: false on an existing policy leaves continuous_automations_enabled alone.
  • A non-premium request returns ErrMissingLicense.

Managed query

  • Creating a patch policy with notify_before_patching: true sets the installer's pre-install query to the Fleet-maintained app's app_open_query.
  • The query is read-only: an update that tries to change pre_install_query on that installer is rejected, matching the patch_when_closed behavior.
  • Clearing notify_before_patching removes the managed query.

Update package endpoint

  • PATCH /software/package with notify_before_patching=true sets it, and omitting the field leaves it unchanged.
  • The same four validation rules apply on this endpoint.

Install skip

  • A policy-triggered install with notify_before_patching: true and empty pre-install output records install_skipped_when_app_open: true, keeps attempt_number at 0, and does not consume a retry.
  • The skip records the notify_before_patching variant of the output text, and a patch_when_closed skip still records its own.
  • Regression: an ordinary empty pre_install_query on a non-managed policy still fails, counts, and retries. Extend server/service/orbit_test.go:1368.
  • A self-service install, a manual install from host details, and a setup experience install all ignore the managed query, matching patch_when_closed.

Tests

  • MYSQL_TEST=1 go test ./server/datastore/mysql/...
  • MYSQL_TEST=1 REDIS_TEST=1 go test ./server/service/... ./ee/server/service/...
  • go test ./server/fleet/...
  • Run go test ./server/service/ after datastore interface changes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    #g-auto-patchingProduct group focused on auto patching software~backendBackend-related issue.~sub-taskA technical sub-task that is part of a story. (Not QA'd. Not estimated.)

    Type

    Projects

    Status
    📨 Inbox

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions