Skip to content

Notify before patching: patch notification kind and activities #50912

Description

@cdcme

Related user story

#39178

Task

Implement notify_before_patching as the first end-user notification kind. When a policy-driven install skips because the app is open, batch those apps into one notification for the host, render the toast contents, record the outcome as an activity, and queue the installs when the end user picks Update now.

After this sub-issue an admin can watch the whole chain: the app-open skip, the toast on the end user's Mac, and the activity saying whether it was shown. The countdown and the forced install come next.

Depends on the notifications platform and the policy field.

Migration

The kind needs to query its apps on two hot paths, so they get columns rather than living in the notification's JSON payload.

CREATE TABLE patch_notifications (
  notification_uuid VARCHAR(36) NOT NULL,
  install_at        TIMESTAMP NULL,
  reminder_sent_at  TIMESTAMP NULL,
  PRIMARY KEY (notification_uuid),
  CONSTRAINT fk_pn_notification FOREIGN KEY (notification_uuid)
    REFERENCES end_user_notifications (uuid) ON DELETE CASCADE
);

CREATE TABLE patch_notification_apps (
  notification_uuid     VARCHAR(36) NOT NULL,
  policy_id             INT UNSIGNED NULL,
  software_title_id     INT UNSIGNED NULL,
  software_installer_id INT UNSIGNED NULL,
  PRIMARY KEY (notification_uuid, software_title_id),
  KEY idx_pna_policy (policy_id),
  CONSTRAINT fk_pna_notification FOREIGN KEY (notification_uuid)
    REFERENCES end_user_notifications (uuid) ON DELETE CASCADE,
  CONSTRAINT fk_pna_policy FOREIGN KEY (policy_id) REFERENCES policies (id) ON DELETE SET NULL
);

install_at is written by the next sub-issue. It is declared here so the schema lands once.

Why not keep the apps in payload. The dedup guard and the Automation runs query both filter by app and by policy. Scanning JSON for that on every policy run does not hold up.

Batching

After SaveHostSoftwareInstallResult records an app-open skip (server/service/orbit.go:1915), collect the host's skips into one notification.

for each app-open skip on host H for a policy with notify_before_patching:
    if the app already appears in patch_notification_apps for a
       non-terminal end_user_notifications row on H:
        skip it
    else:
        add it to this pass's batch
if the batch is non-empty:
    create one end_user_notifications row (kind = notify_before_patching)
    insert one patch_notification_apps row per app

The dedup guard is required, not an optimization. notify_before_patching forces continuous_automations_enabled, so the policy fires on every host refetch. Without the guard, each refetch starts a new toast and a new countdown for the same app. This is the End user already notified about this app? → Wait, nothing to do branch of the decisions flowchart.

Non-terminal means pending, dispatched, or displayed. A notification that has failed, expired, or been acted on no longer covers its apps.

One notification per host per pass, not per app. Fleet-maintained apps update about twice a day, sometimes ten at once. One toast listing them all is the design; ten toasts is not.

Render

func (k *patchKind) Render(ctx context.Context, n *fleet.EndUserNotification) (*fleet.NotificationView, error)

Read the apps from patch_notification_apps, and the deadline from patch_notifications.install_at. Logo comes from Settings > Organization settings > Organization info (fleet.DesktopOrgInfo).

Copy is verbatim from the design. The payload carries a reminder flag that picks between the two forms:

Field First notification Reminder
Title Save your work 💾 Save your work 💾
Description These apps will close and update in **1 hour**. These apps will close and update in **5 minutes**.
Secondary action Remind me 5 minutes before Hide
Primary action Update now Update now

With one app, the description reads This app will close and update in **5 minutes**. Once installs are queued, each item carries Installing... and only Hide remains.

Activities

Add to server/fleet/activities.go, beside ActivityTypeInstalledSoftware at line 1203:

type ActivityTypeNotifiedEndUserBeforePatching struct {
	HostID                uint     `json:"host_id"`
	HostDisplayName       string   `json:"host_display_name"`
	PatchNotificationUUID string   `json:"patch_notification_uuid"`
	SoftwareTitles        []string `json:"software_titles"`
	PolicyIDs             []uint   `json:"policy_ids"`
	TimeBefore            int      `json:"time_before"`     // seconds: 3600 or 300
	InstallAt             time.Time `json:"install_at"`
	Status                string   `json:"status"`          // "success" or "failed"
	ScriptExecutionID     string   `json:"script_execution_id,omitempty"`
}

Emit it from OnOutcome. The reminder reuses patch_notification_uuid, which is why core re-dispatches one row instead of creating a second.

One activity type, not two. Marko settled this on the parent story: a single notified_end_user_before_patching, with status distinguishing the outcome, modelled on installed_software. The deadline field is install_at, for consistency with other endpoints.

No reason field. Carry script_execution_id instead. The frontend fetches the run with GET /api/v1/fleet/scripts/results/{execution_id} and picks its copy from the exit code (#50679, and Figma dev note 5582:46519). The platform still maps exit codes to a NotificationReason internally for its retry policy, but that stays server-side.

A deferred notification is reported two ways. Marko added a new notify exit code for "another notification is displayed" on 2026-08-11, having missed it when he built the command. So a deferral the binary catches has a script_execution_id and that exit code. A deferral the dispatcher catches first, by refusing to start a second script while one is unfinished on that host, has no script and so no script_execution_id. Both render the same sentence. Keep both paths: the server-side check saves a pointless round-trip, and the binary is authoritative about what is actually on screen.

This needs a Fleet Desktop release. The exit code does not exist in the shipped 1.5.0 binary. Get the number from Marko before implementing; do not guess it.

Absence of script_execution_id is unambiguous only because the patch kind sets no expires_at, so a server-side deferral is the only activity-emitting outcome without a script run. Do not set an expiry on this kind.

Automation runs

ListPolicyAutomationActivities (server/datastore/mysql/activities.go:1799) is a UNION ALL over branches built in buildPolicyAutomationBranches (:1749). Add a branch that joins the new activity to the policy through patch_notification_apps:

SELECT ... FROM activity_past ap
  JOIN patch_notification_apps pna
    ON pna.notification_uuid = ap.details->>'$.patch_notification_uuid'
  ...
WHERE ap.activity_type = 'notified_end_user_before_patching'
  AND pna.policy_id = ?

Why join rather than read policy_ids from the JSON. One notification covers several policies, so the activity appears in the Automation runs table of each. idx_pna_policy makes that a lookup instead of a scan of every activity's JSON.

Respect the existing status filter: error selects failures, success selects displayed.

Update now

OnAction(ctx, n, "update_now") queues an install for each app in the notification, using ds.InsertSoftwareInstallRequest with the policy ID, matching server/service/osquery.go:2309. Set the notification to acted.

Bypass the app-open gate here. The end user asked for the update, so skipping because the app is open would do nothing and look broken.

Update now cancels the countdown. Setting the notification to acted takes it out of the reminder and install passes in #50913, so no 5 minute reminder follows. The parent story's test plan checks this.

Skip activity

The app-open skip keeps using the existing installed_software activity with the skip flag, for both patch_when_closed and notify_before_patching. Marko confirmed this on the parent story: no new activity type for the skip. The two are told apart by pre_install_query_output, which #50911 sets to different text per flag, and the frontend renders extra copy from that.

The merged doc names this field wrong. #49106 (docs-v4.91.0, line 1661) documents the payload field as "skipped_install" and refers to patch_only_when_closed. Neither exists in code. The payload field is install_skipped_when_app_open (server/fleet/activities.go:1219), and the policy field is patch_when_closed. skipped_install does exist, but as a frontend display status derived from the flag, not as a payload field. Emit install_skipped_when_app_open, and fix the doc in #50918.

Condition of satisfaction

Batching and dedup

  • Two app-open skips on one host in one pass produce one notification with two patch_notification_apps rows.
  • A second skip for an app already covered by a pending, dispatched, or displayed notification does not create a second notification.
  • A skip for an app whose earlier notification is failed, expired, or acted does create a new one.
  • A skip for a different app while a notification is live creates a second notification with its own UUID.
  • Skips on two different hosts produce two notifications.
  • A skip from a patch_when_closed policy creates no notification, but still emits the installed_software activity with the skip flag.

Render

  • The view lists every app in the notification, with icon URLs and the org logo.
  • The reminder payload flag switches the description and the secondary action label.
  • One app renders This app will close and update in ...; more than one renders These apps ....
  • Once acted, every item shows Installing... and only Hide is returned.

Activities

  • Exit code 0 emits the activity with status: "success", every software title, every policy ID, and time_before: 3600.
  • A failure emits it with status: "failed" and the reason from the platform.
  • A reminder emits a second activity with the same patch_notification_uuid and time_before: 300.
  • A displayed or failed notification carries the script_execution_id of the run that produced it.
  • A notification deferred by the dispatcher emits an activity with no script_execution_id.
  • A notification deferred by the binary emits an activity with the new exit code and its script_execution_id.
  • The activity appears in both the global and host activity feeds.

Automation runs

  • A notification covering two policies appears in the Automation runs table of both.
  • status=error returns only failures; status=success returns only displayed.
  • Regression: MYSQL_TEST=1 go test -run TestListPolicyAutomationActivities ./server/datastore/mysql/... still passes for the existing branches.

Update now

  • POST /device/{token}/notifications/{uuid}/actions with update_now queues one install per app, with the policy ID set.
  • Those installs run even when the app is open.
  • A second update_now on an already-acted notification does not double-queue.
  • After update_now, no 5 minute reminder is sent and no install is queued by the deadline pass.

Tests

  • MYSQL_TEST=1 go test ./server/datastore/mysql/...
  • MYSQL_TEST=1 REDIS_TEST=1 go test ./server/service/...
  • End to end: policy fails with the app open, install skips, notification is created, script returns 0, activity appears with the right titles and policy IDs, and the Automation runs table shows a row for each policy.

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