diff --git a/changes/34288-setup-experience-cancel-activity b/changes/34288-setup-experience-cancel-activity new file mode 100644 index 00000000000..4f6cc462f3c --- /dev/null +++ b/changes/34288-setup-experience-cancel-activity @@ -0,0 +1,3 @@ +- Added activity when setup experience is canceled due to software install failure +- Added cancel activities for each VPP app install skipped due to setup experience cancellation, and switched "failed" activity to "canceled" for package-based software installs in the same situation +- Added install failure activity when VPP installs fail due to licensing issues during setup experience diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index d9d31d95dc0..fab36d5c9fa 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -1070,6 +1070,7 @@ func newAppleMDMWorkerSchedule( commander *apple_mdm.MDMAppleCommander, bootstrapPackageStore fleet.MDMBootstrapPackageStore, vppInstaller fleet.AppleMDMVPPInstaller, + newActivityFn fleet.NewActivityFunc, ) (*schedule.Schedule, error) { const ( name = string(fleet.CronAppleMDMWorker) @@ -1087,6 +1088,7 @@ func newAppleMDMWorkerSchedule( Commander: commander, BootstrapPackageStore: bootstrapPackageStore, VPPInstaller: vppInstaller, + NewActivityFn: newActivityFn, } w.Register(appleMDM) diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index b672164a938..c076a2165c2 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -1248,7 +1248,7 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) { commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService) vppInstaller := svc.(fleet.AppleMDMVPPInstaller) - return newAppleMDMWorkerSchedule(ctx, instanceID, ds, logger, commander, bootstrapPackageStore, vppInstaller) + return newAppleMDMWorkerSchedule(ctx, instanceID, ds, logger, commander, bootstrapPackageStore, vppInstaller, svc.NewActivity) }); err != nil { initFatal(err, "failed to register apple_mdm_worker schedule") } diff --git a/ee/server/service/devices.go b/ee/server/service/devices.go index ce2c9d32dda..2e7100b615a 100644 --- a/ee/server/service/devices.go +++ b/ee/server/service/devices.go @@ -319,10 +319,10 @@ func (svc *Service) getHostSetupExperienceStatus(ctx context.Context, host *flee return nil, ctxerr.Wrap(ctx, err, "listing setup experience results") } - // Mark canceled items as failed. - err = svc.failCancelledSetupExperienceInstalls(ctx, host.ID, hostUUID, host.DisplayName(), results) + // Add activities for canceled installs + setup experience run + err = svc.recordCanceledSetupExperienceSoftwareActivities(ctx, host.ID, hostUUID, host.DisplayName(), results) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "failing cancelled setup experience installs") + return nil, ctxerr.Wrap(ctx, err, "recording cancelled setup experience installs") } var software []*fleet.SetupExperienceStatusResult diff --git a/ee/server/service/orbit.go b/ee/server/service/orbit.go index dcd09e371fc..a30be399e4c 100644 --- a/ee/server/service/orbit.go +++ b/ee/server/service/orbit.go @@ -169,9 +169,8 @@ func (svc *Service) GetOrbitSetupExperienceStatus(ctx context.Context, orbitNode } } - err = svc.failCancelledSetupExperienceInstalls(ctx, host.ID, host.UUID, host.DisplayName(), res) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "failing cancelled setup experience installs") + if err = svc.recordCanceledSetupExperienceSoftwareActivities(ctx, host.ID, host.UUID, host.DisplayName(), res); err != nil { + return nil, ctxerr.Wrap(ctx, err, "recording cancelled setup experience installs") } payload := &fleet.SetupExperienceStatusPayload{ @@ -229,7 +228,7 @@ func (svc *Service) GetOrbitSetupExperienceStatus(ctx context.Context, orbitNode return payload, nil } -func (svc *Service) failCancelledSetupExperienceInstalls( +func (svc *Service) recordCanceledSetupExperienceSoftwareActivities( ctx context.Context, hostID uint, hostUUID string, @@ -241,51 +240,34 @@ func (svc *Service) failCancelledSetupExperienceInstalls( continue } r.Status = fleet.SetupExperienceStatusFailure - svc.logger.InfoContext(ctx, "marking setup experience software as failed due to cancellation", "host_uuid", hostUUID, "software_name", r.Name) + svc.logger.InfoContext(ctx, "emitting activity for canceled setup experience software", "host_uuid", hostUUID, "software_name", r.Name) err := svc.ds.UpdateSetupExperienceStatusResult(ctx, r) if err != nil { - return ctxerr.Wrap(ctx, err, "failing cancelled setup experience software install") + return ctxerr.Wrap(ctx, err, "marking canceled setup experience software install as failed") } - // TODO -- support recording activity for failed VPP apps as well. - // https://github.com/fleetdm/fleet/issues/34288 if r.IsForSoftwarePackage() { - softwarePackage := "" - var source *string - installerMeta, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, *r.SoftwareInstallerID) - if err != nil && !fleet.IsNotFound(err) { - return ctxerr.Wrap(ctx, err, "getting software installer metadata for cancelled setup experience software install") - } - if installerMeta != nil { - softwarePackage = installerMeta.Name - // Get the software title to retrieve the source - if installerMeta.TitleID != nil { - title, err := svc.ds.SoftwareTitleByID(ctx, *installerMeta.TitleID, nil, fleet.TeamFilter{}) - if err != nil && !fleet.IsNotFound(err) { - return ctxerr.Wrap(ctx, err, "getting software title for cancelled setup experience software install") - } - if title != nil { - source = &title.Source - } - } - } - activity := fleet.ActivityTypeInstalledSoftware{ + if err := svc.NewActivity(ctx, nil, fleet.ActivityTypeCanceledInstallSoftware{ HostID: hostID, HostDisplayName: hostDisplayName, SoftwareTitle: r.Name, - SoftwarePackage: softwarePackage, - InstallUUID: ptr.ValOrZero(r.HostSoftwareInstallsExecutionID), - Status: "failed", - SelfService: false, - Source: source, + SoftwareTitleID: ptr.ValOrZero(r.SoftwareTitleID), FromSetupExperience: true, + }); err != nil { + return ctxerr.Wrap(ctx, err, "creating activity for canceled setup experience software install") } - err = svc.NewActivity(ctx, nil, activity) - if err != nil { - return ctxerr.Wrap(ctx, err, "creating activity for cancelled setup experience software install") + } else if r.IsForVPPApp() { + if err := svc.NewActivity(ctx, nil, fleet.ActivityTypeCanceledInstallAppStoreApp{ + HostID: hostID, + HostDisplayName: hostDisplayName, + SoftwareTitle: r.Name, + SoftwareTitleID: ptr.ValOrZero(r.SoftwareTitleID), + FromSetupExperience: true, + }); err != nil { + return ctxerr.Wrap(ctx, err, "creating activity for canceled setup experience VPP app install") } } - continue } + return nil } diff --git a/ee/server/service/orbit_test.go b/ee/server/service/orbit_test.go new file mode 100644 index 00000000000..e68d74a60a9 --- /dev/null +++ b/ee/server/service/orbit_test.go @@ -0,0 +1,378 @@ +package service + +import ( + "context" + "log/slog" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + svcmock "github.com/fleetdm/fleet/v4/server/mock/service" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRecordCanceledSetupExperienceSoftwareActivities(t *testing.T) { + ctx := context.Background() + ds := new(mock.Store) + baseSvc := new(svcmock.Service) + + svc := &Service{ + Service: baseSvc, + ds: ds, + logger: slog.Default(), + } + + hostID := uint(42) + hostUUID := "host-uuid-1" + hostDisplayName := "Test Host" + + t.Run("skips non-cancelled results", func(t *testing.T) { + var activityCreated bool + baseSvc.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + activityCreated = true + return nil + } + ds.UpdateSetupExperienceStatusResultFunc = func(ctx context.Context, status *fleet.SetupExperienceStatusResult) error { + return nil + } + + results := []*fleet.SetupExperienceStatusResult{ + { + HostUUID: hostUUID, + Status: fleet.SetupExperienceStatusPending, + SoftwareInstallerID: ptr.Uint(1), + }, + { + HostUUID: hostUUID, + Status: fleet.SetupExperienceStatusSuccess, + VPPAppTeamID: ptr.Uint(2), + }, + { + HostUUID: hostUUID, + Status: fleet.SetupExperienceStatusRunning, + VPPAppTeamID: ptr.Uint(3), + }, + { + HostUUID: hostUUID, + Status: fleet.SetupExperienceStatusFailure, + SetupExperienceScriptID: ptr.Uint(4), + }, + } + + err := svc.recordCanceledSetupExperienceSoftwareActivities(ctx, hostID, hostUUID, hostDisplayName, results) + require.NoError(t, err) + assert.False(t, activityCreated, "no activity should be created for non-cancelled results") + assert.False(t, ds.UpdateSetupExperienceStatusResultFuncInvoked, "no update should be called for non-cancelled results") + }) + + t.Run("software package cancelled emits canceled_install_software activity with FromSetupExperience", func(t *testing.T) { + ds.UpdateSetupExperienceStatusResultFuncInvoked = false + + installerID := uint(10) + titleID := uint(100) + + ds.UpdateSetupExperienceStatusResultFunc = func(ctx context.Context, status *fleet.SetupExperienceStatusResult) error { + return nil + } + + var createdActivities []fleet.ActivityDetails + var createdUser *fleet.User + baseSvc.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + createdUser = user + createdActivities = append(createdActivities, activity) + return nil + } + + // The failed item that caused the cancellation + failedTitleID := uint(999) + results := []*fleet.SetupExperienceStatusResult{ + { + HostUUID: hostUUID, + Name: "FailedApp", + Status: fleet.SetupExperienceStatusFailure, + SoftwareInstallerID: ptr.Uint(99), + SoftwareTitleID: &failedTitleID, + }, + { + HostUUID: hostUUID, + Name: "DummyApp", + Status: fleet.SetupExperienceStatusCancelled, + SoftwareInstallerID: &installerID, + SoftwareTitleID: &titleID, + HostSoftwareInstallsExecutionID: ptr.String("exec-uuid-1"), + }, + } + + err := svc.recordCanceledSetupExperienceSoftwareActivities(ctx, hostID, hostUUID, hostDisplayName, results) + require.NoError(t, err) + + // Status should have been changed to failure + assert.Equal(t, fleet.SetupExperienceStatusFailure, results[1].Status) + + // Update should have been called + assert.True(t, ds.UpdateSetupExperienceStatusResultFuncInvoked) + + // Should have 1 activity: canceled install (canceled_setup_experience is emitted earlier) + require.Len(t, createdActivities, 1) + + // Canceled install software + canceledAct, ok := createdActivities[0].(fleet.ActivityTypeCanceledInstallSoftware) + require.True(t, ok, "expected ActivityTypeCanceledInstallSoftware, got %T", createdActivities[0]) + assert.Equal(t, hostID, canceledAct.HostID) + assert.Equal(t, hostDisplayName, canceledAct.HostDisplayName) + assert.Equal(t, "DummyApp", canceledAct.SoftwareTitle) + assert.Equal(t, titleID, canceledAct.SoftwareTitleID) + assert.True(t, canceledAct.FromSetupExperience, "FromSetupExperience should be true") + assert.True(t, canceledAct.WasFromAutomation(), "WasFromAutomation should be true") + + // Should be created with nil user (Fleet-initiated) + assert.Nil(t, createdUser) + }) + + t.Run("VPP app cancelled emits canceled_install_app_store_app activity with FromSetupExperience", func(t *testing.T) { + ds.UpdateSetupExperienceStatusResultFuncInvoked = false + + vppTeamID := uint(20) + adamID := "12345" + softwareTitleID := uint(200) + + ds.UpdateSetupExperienceStatusResultFunc = func(ctx context.Context, status *fleet.SetupExperienceStatusResult) error { + return nil + } + + var createdActivities []fleet.ActivityDetails + baseSvc.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + createdActivities = append(createdActivities, activity) + return nil + } + + failedTitleID := uint(888) + results := []*fleet.SetupExperienceStatusResult{ + { + HostUUID: hostUUID, + Name: "FailedVPP", + Status: fleet.SetupExperienceStatusFailure, + VPPAppTeamID: ptr.Uint(99), + SoftwareTitleID: &failedTitleID, + }, + { + HostUUID: hostUUID, + Name: "VPPApp", + Status: fleet.SetupExperienceStatusCancelled, + VPPAppTeamID: &vppTeamID, + VPPAppAdamID: &adamID, + SoftwareTitleID: &softwareTitleID, + }, + } + + err := svc.recordCanceledSetupExperienceSoftwareActivities(ctx, hostID, hostUUID, hostDisplayName, results) + require.NoError(t, err) + + // Status should have been changed to failure + assert.Equal(t, fleet.SetupExperienceStatusFailure, results[1].Status) + + // Should have 1 activity: canceled VPP install (canceled_setup_experience is emitted earlier) + require.Len(t, createdActivities, 1) + + // Canceled install app store app + canceledAct, ok := createdActivities[0].(fleet.ActivityTypeCanceledInstallAppStoreApp) + require.True(t, ok, "expected ActivityTypeCanceledInstallAppStoreApp, got %T", createdActivities[0]) + assert.Equal(t, hostID, canceledAct.HostID) + assert.Equal(t, hostDisplayName, canceledAct.HostDisplayName) + assert.Equal(t, "VPPApp", canceledAct.SoftwareTitle) + assert.Equal(t, softwareTitleID, canceledAct.SoftwareTitleID) + assert.True(t, canceledAct.FromSetupExperience) + assert.True(t, canceledAct.WasFromAutomation()) + }) + + t.Run("mixed cancelled and non-cancelled results", func(t *testing.T) { + ds.UpdateSetupExperienceStatusResultFuncInvoked = false + + installerID := uint(30) + titleID := uint(300) + vppTeamID := uint(40) + adamID := "67890" + vppTitleID := uint(400) + + ds.UpdateSetupExperienceStatusResultFunc = func(ctx context.Context, status *fleet.SetupExperienceStatusResult) error { + return nil + } + + var activities []fleet.ActivityDetails + baseSvc.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + activities = append(activities, activity) + return nil + } + + failedTitleID := uint(777) + results := []*fleet.SetupExperienceStatusResult{ + { + HostUUID: hostUUID, + Name: "FailedApp", + Status: fleet.SetupExperienceStatusFailure, + SoftwareInstallerID: ptr.Uint(50), + SoftwareTitleID: &failedTitleID, + }, + { + HostUUID: hostUUID, + Name: "SuccessApp", + Status: fleet.SetupExperienceStatusSuccess, + SoftwareInstallerID: ptr.Uint(51), + }, + { + HostUUID: hostUUID, + Name: "CancelledSW", + Status: fleet.SetupExperienceStatusCancelled, + SoftwareInstallerID: &installerID, + SoftwareTitleID: &titleID, + HostSoftwareInstallsExecutionID: ptr.String("exec-uuid-3"), + }, + { + HostUUID: hostUUID, + Name: "PendingVPP", + Status: fleet.SetupExperienceStatusPending, + VPPAppTeamID: ptr.Uint(60), + }, + { + HostUUID: hostUUID, + Name: "CancelledVPP", + Status: fleet.SetupExperienceStatusCancelled, + VPPAppTeamID: &vppTeamID, + VPPAppAdamID: &adamID, + SoftwareTitleID: &vppTitleID, + }, + } + + err := svc.recordCanceledSetupExperienceSoftwareActivities(ctx, hostID, hostUUID, hostDisplayName, results) + require.NoError(t, err) + + // Only the two cancelled results should have their status changed + assert.Equal(t, fleet.SetupExperienceStatusFailure, results[0].Status) // was already failed + assert.Equal(t, fleet.SetupExperienceStatusSuccess, results[1].Status) // unchanged + assert.Equal(t, fleet.SetupExperienceStatusFailure, results[2].Status) // cancelled -> failed + assert.Equal(t, fleet.SetupExperienceStatusPending, results[3].Status) // unchanged + assert.Equal(t, fleet.SetupExperienceStatusFailure, results[4].Status) // cancelled -> failed + + // Two activities: canceled sw install + canceled vpp install (canceled_setup_experience emitted earlier) + require.Len(t, activities, 2) + + swAct, ok := activities[0].(fleet.ActivityTypeCanceledInstallSoftware) + require.True(t, ok) + assert.Equal(t, "CancelledSW", swAct.SoftwareTitle) + assert.True(t, swAct.FromSetupExperience) + + vppAct, ok := activities[1].(fleet.ActivityTypeCanceledInstallAppStoreApp) + require.True(t, ok) + assert.Equal(t, "CancelledVPP", vppAct.SoftwareTitle) + assert.True(t, vppAct.FromSetupExperience) + }) + + t.Run("script cancellation does not trigger activity", func(t *testing.T) { + ds.UpdateSetupExperienceStatusResultFuncInvoked = false + + ds.UpdateSetupExperienceStatusResultFunc = func(ctx context.Context, status *fleet.SetupExperienceStatusResult) error { + return nil + } + + var activityCreated bool + baseSvc.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + activityCreated = true + return nil + } + + scriptID := uint(70) + results := []*fleet.SetupExperienceStatusResult{ + { + HostUUID: hostUUID, + Name: "setup.sh", + Status: fleet.SetupExperienceStatusCancelled, + SetupExperienceScriptID: &scriptID, + }, + } + + err := svc.recordCanceledSetupExperienceSoftwareActivities(ctx, hostID, hostUUID, hostDisplayName, results) + require.NoError(t, err) + + // Status should still be changed to failure + assert.Equal(t, fleet.SetupExperienceStatusFailure, results[0].Status) + // But no activity should be created for script cancellations + assert.False(t, activityCreated) + }) + + t.Run("empty results returns nil", func(t *testing.T) { + err := svc.recordCanceledSetupExperienceSoftwareActivities(ctx, hostID, hostUUID, hostDisplayName, nil) + require.NoError(t, err) + + err = svc.recordCanceledSetupExperienceSoftwareActivities(ctx, hostID, hostUUID, hostDisplayName, []*fleet.SetupExperienceStatusResult{}) + require.NoError(t, err) + }) + + t.Run("cancelled items without failed item still emit individual cancel activities", func(t *testing.T) { + ds.UpdateSetupExperienceStatusResultFuncInvoked = false + + installerID := uint(10) + titleID := uint(100) + + ds.UpdateSetupExperienceStatusResultFunc = func(ctx context.Context, status *fleet.SetupExperienceStatusResult) error { + return nil + } + + var createdActivities []fleet.ActivityDetails + baseSvc.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + createdActivities = append(createdActivities, activity) + return nil + } + + // Only cancelled items, no failed item that triggered them + results := []*fleet.SetupExperienceStatusResult{ + { + HostUUID: hostUUID, + Name: "DummyApp", + Status: fleet.SetupExperienceStatusCancelled, + SoftwareInstallerID: &installerID, + SoftwareTitleID: &titleID, + HostSoftwareInstallsExecutionID: ptr.String("exec-uuid-1"), + }, + } + + err := svc.recordCanceledSetupExperienceSoftwareActivities(ctx, hostID, hostUUID, hostDisplayName, results) + require.NoError(t, err) + + // Should only have the canceled install activity + require.Len(t, createdActivities, 1) + _, ok := createdActivities[0].(fleet.ActivityTypeCanceledInstallSoftware) + require.True(t, ok) + }) +} + +func TestCanceledActivityWasFromAutomation(t *testing.T) { + t.Run("CanceledInstallSoftware", func(t *testing.T) { + act := fleet.ActivityTypeCanceledInstallSoftware{ + HostID: 1, + HostDisplayName: "host", + SoftwareTitle: "title", + SoftwareTitleID: 1, + FromSetupExperience: false, + } + assert.False(t, act.WasFromAutomation()) + + act.FromSetupExperience = true + assert.True(t, act.WasFromAutomation()) + }) + + t.Run("CanceledInstallAppStoreApp", func(t *testing.T) { + act := fleet.ActivityTypeCanceledInstallAppStoreApp{ + HostID: 1, + HostDisplayName: "host", + SoftwareTitle: "title", + SoftwareTitleID: 1, + FromSetupExperience: false, + } + assert.False(t, act.WasFromAutomation()) + + act.FromSetupExperience = true + assert.True(t, act.WasFromAutomation()) + }) +} diff --git a/ee/server/service/setup_experience.go b/ee/server/service/setup_experience.go index 0ca6ff92b2a..cecc9600928 100644 --- a/ee/server/service/setup_experience.go +++ b/ee/server/service/setup_experience.go @@ -276,6 +276,24 @@ func (svc *Service) SetupExperienceNextStep(ctx context.Context, host *fleet.Hos svc.logger.WarnContext(ctx, "got an error when attempting to enqueue VPP app install", "err", err, "adam_id", sw.VPPAppAdamID) sw.Status = fleet.SetupExperienceStatusFailure sw.Error = ptr.String(err.Error()) + // Persist the failure before cancelling other steps, so that + // maybeCancelPendingSetupExperienceSteps can find the failed + // item from its loaded statuses. + if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, sw); err != nil { + return false, ctxerr.Wrap(ctx, err, "updating setup experience with vpp install failure") + } + failActivity := fleet.ActivityInstalledAppStoreApp{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + SoftwareTitle: sw.Name, + AppStoreID: ptr.ValOrZero(sw.VPPAppAdamID), + Status: string(fleet.SoftwareInstallFailed), + HostPlatform: host.Platform, + FromSetupExperience: true, + } + if actErr := svc.NewActivity(ctx, nil, failActivity); actErr != nil { + svc.logger.WarnContext(ctx, "failed to create activity for VPP app install failure during setup experience", "err", actErr) + } // At this point we need to check whether the "cancel if software install fails" setting is active, // in which case we'll cancel the remaining pending items. requireAllSoftware, err := svc.IsAllSetupExperienceSoftwareRequired(ctx, host) @@ -291,9 +309,9 @@ func (svc *Service) SetupExperienceNextStep(ctx context.Context, host *fleet.Hos } else { sw.NanoCommandUUID = &cmdUUID sw.Status = fleet.SetupExperienceStatusRunning - } - if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, sw); err != nil { - return false, ctxerr.Wrap(ctx, err, "updating setup experience with vpp install command uuid") + if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, sw); err != nil { + return false, ctxerr.Wrap(ctx, err, "updating setup experience with vpp install command uuid") + } } } case softwareRunning == 0 && len(scriptsPending) > 0: diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index dbfd7aa0fd5..9ee1a214d2c 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -142,6 +142,7 @@ export enum ActivityType { CanceledInstallAppStoreApp = "canceled_install_app_store_app", CanceledInstallSoftware = "canceled_install_software", CanceledUninstallSoftware = "canceled_uninstall_software", + CanceledSetupExperience = "canceled_setup_experience", EnabledAndroidMdm = "enabled_android_mdm", DisabledAndroidMdm = "disabled_android_mdm", ConfiguredMSEntraConditionalAccess = "added_conditional_access_integration_microsoft", @@ -183,6 +184,7 @@ export type IHostPastActivityType = | ActivityType.CanceledInstallAppStoreApp | ActivityType.CanceledInstallSoftware | ActivityType.CanceledUninstallSoftware + | ActivityType.CanceledSetupExperience | ActivityType.InstalledCertificate | ActivityType.ResentCertificate; @@ -283,6 +285,7 @@ export interface IActivityDetails { team_name?: string | null; teams?: ITeamSummary[]; triggered_by?: string; + from_setup_experience?: boolean; user_email?: string; user_id?: number; webhook_url?: string; @@ -316,6 +319,7 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record = { canceled_install_software: "Canceled activity: install software", canceled_run_script: "Canceled activity: run script", canceled_uninstall_software: "Canceled activity: uninstall software", + canceled_setup_experience: "Canceled setup experience", changed_macos_setup_assistant: "Edited macOS automatic enrollment profile", changed_user_global_role: "Edited user's role: global", changed_user_team_role: "Edited user's role: fleet", diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index 8258da11996..8238d12beb6 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -929,12 +929,14 @@ const TAGGED_TEMPLATES = { ); }, ranScript: (activity: IActivity) => { - const { script_name, host_display_name } = activity.details || {}; + const { script_name, host_display_name, from_setup_experience } = + activity.details || {}; return ( <> {" "} ran {formatScriptNameForActivityItem(script_name)} on{" "} - {host_display_name}. + {host_display_name} + {from_setup_experience ? " during setup experience" : ""}. ); }, @@ -1281,6 +1283,7 @@ const TAGGED_TEMPLATES = { software_title: title, status, source, + from_setup_experience, } = details; const showSoftwarePackage = @@ -1293,7 +1296,8 @@ const TAGGED_TEMPLATES = { {getInstallUninstallStatusPredicate(status, isScriptPackageSource)}{" "} {title} {showSoftwarePackage && ` (${details.software_package})`} on{" "} - {hostName}. + {hostName} + {from_setup_experience ? " during setup experience" : ""}. ); }, @@ -1497,12 +1501,27 @@ const TAGGED_TEMPLATES = { ); }, canceledInstallSoftware: (activity: IActivity) => { + const { + software_title: title, + host_display_name: hostName, + from_setup_experience: fromSetupExperience, + } = activity.details || {}; + return ( + <> + {" "} + canceled {title} install on {hostName} + {fromSetupExperience ? " during setup experience" : ""}. + + ); + }, + canceledSetupExperience: (activity: IActivity) => { const { software_title: title, host_display_name: hostName } = activity.details || {}; return ( <> {" "} - canceled {title} install on {hostName}. + canceled setup experience on {hostName} because {title}{" "} + failed to install. ); }, @@ -2156,6 +2175,9 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => { case ActivityType.CanceledUninstallSoftware: { return TAGGED_TEMPLATES.canceledUninstallSoftware(activity); } + case ActivityType.CanceledSetupExperience: { + return TAGGED_TEMPLATES.canceledSetupExperience(activity); + } case ActivityType.CreatedSavedQuery: { return TAGGED_TEMPLATES.createdSavedQuery(activity); } diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx index aba94816bb9..624cdd5c6ca 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityConfig.tsx @@ -21,6 +21,7 @@ import RotatedHostRecoveryLockPasswordActivityItem from "./ActivityItems/Rotated import InstalledSoftwareActivityItem from "./ActivityItems/InstalledSoftwareActivityItem"; import CanceledRunScriptActivityItem from "./ActivityItems/CanceledRunScriptActivityItem"; import CanceledInstallSoftwareActivityItem from "./ActivityItems/CanceledInstallSoftwareActivityItem"; +import CanceledSetupExperienceActivityItem from "./ActivityItems/CanceledSetupExperienceActivityItem"; import CanceledUninstallSoftwareActivtyItem from "./ActivityItems/CanceledUninstallSoftwareActivtyItem"; import InstalledCertificateActivityItem from "./ActivityItems/InstalledCertificateActivityItem"; import ResentCertificateActivityItem from "./ActivityItems/ResentCertificateActivityItem"; @@ -66,6 +67,7 @@ export const pastActivityComponentMap: Record< [ActivityType.CanceledInstallSoftware]: CanceledInstallSoftwareActivityItem, [ActivityType.CanceledInstallAppStoreApp]: CanceledInstallSoftwareActivityItem, [ActivityType.CanceledUninstallSoftware]: CanceledUninstallSoftwareActivtyItem, + [ActivityType.CanceledSetupExperience]: CanceledSetupExperienceActivityItem, [ActivityType.InstalledCertificate]: InstalledCertificateActivityItem, [ActivityType.ResentCertificate]: ResentCertificateActivityItem, }; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/CanceledInstallSoftwareActivityItem/CanceledInstallSoftwareActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/CanceledInstallSoftwareActivityItem/CanceledInstallSoftwareActivityItem.tsx index b83358eaeeb..acbb7daa155 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/CanceledInstallSoftwareActivityItem/CanceledInstallSoftwareActivityItem.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/CanceledInstallSoftwareActivityItem/CanceledInstallSoftwareActivityItem.tsx @@ -9,6 +9,8 @@ const baseClass = "canceled-install-software-activity-item"; const CanceledInstallSoftwareActivityItem = ({ activity, }: IHostActivityItemComponentProps) => { + const fromSetupExperience = activity.details?.from_setup_experience; + return ( {" "} - install on this host. + install on this host + {fromSetupExperience ? " during setup experience" : ""}. ); diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/CanceledSetupExperienceActivityItem/CanceledSetupExperienceActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/CanceledSetupExperienceActivityItem/CanceledSetupExperienceActivityItem.tsx new file mode 100644 index 00000000000..7a7748489d4 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/CanceledSetupExperienceActivityItem/CanceledSetupExperienceActivityItem.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +import ActivityItem from "components/ActivityItem"; + +import { IHostActivityItemComponentProps } from "../../ActivityConfig"; + +const baseClass = "canceled-setup-experience-activity-item"; + +const CanceledSetupExperienceActivityItem = ({ + activity, +}: IHostActivityItemComponentProps) => { + return ( + + <> + {activity.actor_full_name ?? "Fleet"} canceled setup experience + on this host because {activity.details.software_title} failed to + install. + + + ); +}; + +export default CanceledSetupExperienceActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/CanceledSetupExperienceActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/CanceledSetupExperienceActivityItem/index.ts new file mode 100644 index 00000000000..63a40685a0d --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/CanceledSetupExperienceActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./CanceledSetupExperienceActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx index 12a5eabaced..c36067b57f6 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx @@ -20,7 +20,12 @@ const InstalledSoftwareActivityItem = ({ isSoloActivity, }: IHostActivityItemComponentPropsWithShowDetails) => { const { actor_full_name: actorName, details } = activity; - const { self_service, software_title: title, source } = details; + const { + self_service, + software_title: title, + source, + from_setup_experience, + } = details; const status = details.status === "failed" ? "failed_uninstall" : details.status; const isScriptPackageSource = SCRIPT_PACKAGE_SOURCES.includes(source || ""); @@ -50,7 +55,9 @@ const InstalledSoftwareActivityItem = ({ isSoloActivity={isSoloActivity} > <>{actorDisplayName} {installedSoftwarePrefix} {title} on this - host{self_service && " (self-service)"}.{" "} + host + {from_setup_experience ? " during setup experience" : ""} + {self_service && " (self-service)"}. ); }; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx index df7ec7bb49c..43d1c77c977 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx @@ -34,7 +34,11 @@ const RanScriptActivityItem = ({ {" "} {ranScriptPrefix}{" "} {formatScriptNameForActivityItem(activity.details?.script_name)} on this - host.{" "} + host + {activity.details?.from_setup_experience + ? " during setup experience" + : ""} + . ); diff --git a/server/datastore/mysql/setup_experience.go b/server/datastore/mysql/setup_experience.go index a1d20c391ff..94c203440c8 100644 --- a/server/datastore/mysql/setup_experience.go +++ b/server/datastore/mysql/setup_experience.go @@ -861,68 +861,32 @@ WHERE host_uuid = ? } func (ds *Datastore) MaybeUpdateSetupExperienceVPPStatus(ctx context.Context, hostUUID string, nanoCommandUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { - selectStmt := "SELECT id FROM setup_experience_status_results WHERE host_uuid = ? AND nano_command_uuid = ?" - updateStmt := "UPDATE setup_experience_status_results SET status = ? WHERE id = ?" - - var id uint - if err := ds.writer(ctx).GetContext(ctx, &id, selectStmt, hostUUID, nanoCommandUUID); err != nil { - // TODO: maybe we can use the reader instead for this query - if errors.Is(err, sql.ErrNoRows) { - // return early if no results found - return false, nil - } - return false, err - } - res, err := ds.writer(ctx).ExecContext(ctx, updateStmt, status, id) + stmt := `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND nano_command_uuid = ? AND status NOT IN (?, ?, ?)` + res, err := ds.writer(ctx).ExecContext(ctx, stmt, status, hostUUID, nanoCommandUUID, fleet.SetupExperienceStatusSuccess, fleet.SetupExperienceStatusFailure, fleet.SetupExperienceStatusCancelled) if err != nil { return false, err } n, _ := res.RowsAffected() - return n > 0, nil } func (ds *Datastore) MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { - selectStmt := "SELECT id FROM setup_experience_status_results WHERE host_uuid = ? AND host_software_installs_execution_id = ?" - updateStmt := "UPDATE setup_experience_status_results SET status = ? WHERE id = ?" - - var id uint - if err := ds.writer(ctx).GetContext(ctx, &id, selectStmt, hostUUID, executionID); err != nil { - // TODO: maybe we can use the reader instead for this query - if errors.Is(err, sql.ErrNoRows) { - // return early if no results found - return false, nil - } - return false, err - } - res, err := ds.writer(ctx).ExecContext(ctx, updateStmt, status, id) + stmt := `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND host_software_installs_execution_id = ? AND status NOT IN (?, ?, ?)` + res, err := ds.writer(ctx).ExecContext(ctx, stmt, status, hostUUID, executionID, fleet.SetupExperienceStatusSuccess, fleet.SetupExperienceStatusFailure, fleet.SetupExperienceStatusCancelled) if err != nil { return false, err } n, _ := res.RowsAffected() - return n > 0, nil } func (ds *Datastore) MaybeUpdateSetupExperienceScriptStatus(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { - selectStmt := "SELECT id FROM setup_experience_status_results WHERE host_uuid = ? AND script_execution_id = ?" - updateStmt := "UPDATE setup_experience_status_results SET status = ? WHERE id = ?" - - var id uint - if err := ds.writer(ctx).GetContext(ctx, &id, selectStmt, hostUUID, executionID); err != nil { - // TODO: maybe we can use the reader instead for this query - if errors.Is(err, sql.ErrNoRows) { - // return early if no results found - return false, nil - } - return false, err - } - res, err := ds.writer(ctx).ExecContext(ctx, updateStmt, status, id) + stmt := `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND script_execution_id = ? AND status NOT IN (?, ?, ?)` + res, err := ds.writer(ctx).ExecContext(ctx, stmt, status, hostUUID, executionID, fleet.SetupExperienceStatusSuccess, fleet.SetupExperienceStatusFailure, fleet.SetupExperienceStatusCancelled) if err != nil { return false, err } n, _ := res.RowsAffected() - return n > 0, nil } diff --git a/server/datastore/mysql/setup_experience_test.go b/server/datastore/mysql/setup_experience_test.go index 41a7a6e0eff..88e9aaceaa5 100644 --- a/server/datastore/mysql/setup_experience_test.go +++ b/server/datastore/mysql/setup_experience_test.go @@ -34,6 +34,7 @@ func TestSetupExperience(t *testing.T) { {"TestUpdateSetupExperienceScriptWhileEnqueued", testUpdateSetupExperienceScriptWhileEnqueued}, {"TestEnqueueSetupExperienceItemsWindows", testEnqueueSetupExperienceItemsWindows}, {"EnqueueSetupExperienceItemsWithDisplayName", testEnqueueSetupExperienceItemsWithDisplayName}, + {"UpdateStatusGuardsTerminalStates", testUpdateStatusGuardsTerminalStates}, } for _, c := range cases { @@ -1794,6 +1795,185 @@ func testHostInSetupExperience(t *testing.T, ds *Datastore) { require.False(t, inSetupExperience) } +func testUpdateStatusGuardsTerminalStates(t *testing.T, ds *Datastore) { + ctx := context.Background() + hostUUID := uuid.NewString() + + // --- Set up foreign-key references --- + + // User (required for software installer) + user, err := ds.NewUser(ctx, &fleet.User{ + Name: "GuardTest", + Email: "guard@example.com", + GlobalRole: new("admin"), + Password: []byte("12characterslong!"), + }) + require.NoError(t, err) + + // Software installer + installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Filename: "guard_test.pkg", + Title: "Guard Test Software", + Version: "1.0.0", + Source: "apps", + Platform: "darwin", + Extension: "pkg", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + // VPP token + app + dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Guard Kong", "GuardJungle") + require.NoError(t, err) + tok, err := ds.InsertVPPToken(ctx, dataToken) + require.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tok.ID, []uint{}) + require.NoError(t, err) + vppApp, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + BundleIdentifier: "com.guard.test", + Name: "guard_test.app", + LatestVersion: "1.0.0", + }, nil) + require.NoError(t, err) + var vppAppsTeamsID uint + err = sqlx.GetContext(ctx, ds.reader(ctx), &vppAppsTeamsID, + `SELECT id FROM vpp_apps_teams WHERE adam_id = ?`, vppApp.AdamID) + require.NoError(t, err) + + // Setup experience script (raw SQL, same pattern as testSetupExperienceStatusResults) + var scriptID uint + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + res, err := q.ExecContext(ctx, `INSERT INTO setup_experience_scripts (name) VALUES (?)`, "guard_test_script") + require.NoError(t, err) + id, err := res.LastInsertId() + require.NoError(t, err) + scriptID = uint(id) //nolint: gosec + return nil + }) + + // --- Helpers --- + + insertRow := func(sesr *fleet.SetupExperienceStatusResult) { + stmt := `INSERT INTO setup_experience_status_results + (id, host_uuid, name, status, software_installer_id, + host_software_installs_execution_id, vpp_app_team_id, + nano_command_uuid, setup_experience_script_id, + script_execution_id, error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + res, err := q.ExecContext(ctx, stmt, + sesr.ID, sesr.HostUUID, sesr.Name, sesr.Status, + sesr.SoftwareInstallerID, + sesr.HostSoftwareInstallsExecutionID, + sesr.VPPAppTeamID, sesr.NanoCommandUUID, + sesr.SetupExperienceScriptID, + sesr.ScriptExecutionID, sesr.Error) + require.NoError(t, err) + id, err := res.LastInsertId() + require.NoError(t, err) + sesr.ID = uint(id) //nolint: gosec + return nil + }) + } + + readStatus := func(id uint) fleet.SetupExperienceStatusResultStatus { + var status fleet.SetupExperienceStatusResultStatus + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &status, + "SELECT status FROM setup_experience_status_results WHERE id = ?", id) + }) + return status + } + + // --- Negative tests: terminal states must not be overwritten --- + + terminalStatuses := []fleet.SetupExperienceStatusResultStatus{ + fleet.SetupExperienceStatusCancelled, + fleet.SetupExperienceStatusFailure, + fleet.SetupExperienceStatusSuccess, + } + + for _, termStatus := range terminalStatuses { + // Software installer row + execID := uuid.NewString() + row := &fleet.SetupExperienceStatusResult{ + HostUUID: hostUUID, + Name: "sw-" + string(termStatus), + Status: termStatus, + SoftwareInstallerID: new(installerID), + HostSoftwareInstallsExecutionID: new(execID), + } + insertRow(row) + updated, err := ds.MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx, hostUUID, execID, fleet.SetupExperienceStatusFailure) + require.NoError(t, err) + require.False(t, updated, "software installer row in %s should not be updated", termStatus) + require.Equal(t, termStatus, readStatus(row.ID)) + + // VPP row + nanoUUID := uuid.NewString() + row = &fleet.SetupExperienceStatusResult{ + HostUUID: hostUUID, + Name: "vpp-" + string(termStatus), + Status: termStatus, + VPPAppTeamID: new(vppAppsTeamsID), + NanoCommandUUID: new(nanoUUID), + } + insertRow(row) + updated, err = ds.MaybeUpdateSetupExperienceVPPStatus(ctx, hostUUID, nanoUUID, fleet.SetupExperienceStatusFailure) + require.NoError(t, err) + require.False(t, updated, "VPP row in %s should not be updated", termStatus) + require.Equal(t, termStatus, readStatus(row.ID)) + + // Script row + scriptExecID := uuid.NewString() + row = &fleet.SetupExperienceStatusResult{ + HostUUID: hostUUID, + Name: "script-" + string(termStatus), + Status: termStatus, + SetupExperienceScriptID: new(scriptID), + ScriptExecutionID: new(scriptExecID), + } + insertRow(row) + updated, err = ds.MaybeUpdateSetupExperienceScriptStatus(ctx, hostUUID, scriptExecID, fleet.SetupExperienceStatusFailure) + require.NoError(t, err) + require.False(t, updated, "script row in %s should not be updated", termStatus) + require.Equal(t, termStatus, readStatus(row.ID)) + } + + // --- Positive control: pending row CAN be updated --- + + pendingExecID := uuid.NewString() + pendingRow := &fleet.SetupExperienceStatusResult{ + HostUUID: hostUUID, + Name: "sw-pending-positive", + Status: fleet.SetupExperienceStatusPending, + SoftwareInstallerID: new(installerID), + HostSoftwareInstallsExecutionID: new(pendingExecID), + } + insertRow(pendingRow) + updated, err := ds.MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx, hostUUID, pendingExecID, fleet.SetupExperienceStatusFailure) + require.NoError(t, err) + require.True(t, updated, "pending row should be updated") + require.Equal(t, fleet.SetupExperienceStatusFailure, readStatus(pendingRow.ID)) + + // --- Bug-scenario test: canceled VPP row must not flip to failure --- + + cancelledNanoUUID := uuid.NewString() + cancelledVPPRow := &fleet.SetupExperienceStatusResult{ + HostUUID: hostUUID, + Name: "vpp-canceled-bug", + Status: fleet.SetupExperienceStatusCancelled, + VPPAppTeamID: new(vppAppsTeamsID), + NanoCommandUUID: new(cancelledNanoUUID), + } + insertRow(cancelledVPPRow) + updated, err = ds.MaybeUpdateSetupExperienceVPPStatus(ctx, hostUUID, cancelledNanoUUID, fleet.SetupExperienceStatusFailure) + require.NoError(t, err) + require.False(t, updated, "cancelled VPP row must not be overwritten by late failure result") + require.Equal(t, fleet.SetupExperienceStatusCancelled, readStatus(cancelledVPPRow.ID)) +} + func testGetSetupExperienceScriptByID(t *testing.T, ds *Datastore) { ctx := context.Background() diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 0643a6fb9ac..f8c5909014e 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -233,6 +233,8 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeEditedHostIdpData{}, ActivityTypeEditedEnrollSecrets{}, + + ActivityTypeCanceledSetupExperience{}, } // ActivityDetails is an alias for the canonical ActivityDetails interface defined in server/activity/api. @@ -877,7 +879,7 @@ type ActivityTypeRanScript struct { Async bool `json:"async"` PolicyID *uint `json:"policy_id"` PolicyName *string `json:"policy_name"` - FromSetupExperience bool `json:"-"` + FromSetupExperience bool `json:"from_setup_experience"` } func (a ActivityTypeRanScript) ActivityName() string { @@ -1086,7 +1088,7 @@ type ActivityTypeInstalledSoftware struct { Source *string `json:"source,omitempty"` PolicyID *uint `json:"policy_id"` PolicyName *string `json:"policy_name"` - FromSetupExperience bool `json:"-"` + FromSetupExperience bool `json:"from_setup_experience"` CommandUUID string `json:"command_uuid,omitempty"` } @@ -1329,7 +1331,7 @@ type ActivityInstalledAppStoreApp struct { PolicyID *uint `json:"policy_id"` PolicyName *string `json:"policy_name"` HostPlatform string `json:"host_platform"` - FromSetupExperience bool `json:"-"` + FromSetupExperience bool `json:"from_setup_experience"` FromAutoUpdate bool `json:"from_auto_update"` } @@ -1346,11 +1348,10 @@ func (a ActivityInstalledAppStoreApp) WasFromAutomation() bool { } func (a ActivityInstalledAppStoreApp) MustActivateNextUpcomingActivity() bool { - // for VPP apps, we only activate the next upcoming activity if the installation - // failed, because if it succeeded (and in this case, it only means the command to - // install succeeded), we only activate the next activity when we verify the - // app is actually installed. - return a.Status != string(SoftwareInstalled) + // For VPP apps, we only activate the next upcoming activity if the installation + // failed; successes are activated on install verification. + // More info on the blank command UUID skip at https://github.com/fleetdm/fleet/pull/43437#discussion_r3074297749 + return a.CommandUUID != "" && a.Status != string(SoftwareInstalled) } func (a ActivityInstalledAppStoreApp) ActivateNextUpcomingActivityArgs() (uint, string) { @@ -1541,10 +1542,11 @@ func (a ActivityTypeCanceledRunScript) HostIDs() []uint { } type ActivityTypeCanceledInstallSoftware struct { - HostID uint `json:"host_id"` - HostDisplayName string `json:"host_display_name"` - SoftwareTitle string `json:"software_title"` - SoftwareTitleID uint `json:"software_title_id"` + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` + SoftwareTitle string `json:"software_title"` + SoftwareTitleID uint `json:"software_title_id"` + FromSetupExperience bool `json:"from_setup_experience"` } func (a ActivityTypeCanceledInstallSoftware) ActivityName() string { @@ -1555,6 +1557,10 @@ func (a ActivityTypeCanceledInstallSoftware) HostIDs() []uint { return []uint{a.HostID} } +func (a ActivityTypeCanceledInstallSoftware) WasFromAutomation() bool { + return a.FromSetupExperience +} + type ActivityTypeCanceledUninstallSoftware struct { HostID uint `json:"host_id"` HostDisplayName string `json:"host_display_name"` @@ -1571,10 +1577,11 @@ func (a ActivityTypeCanceledUninstallSoftware) HostIDs() []uint { } type ActivityTypeCanceledInstallAppStoreApp struct { - HostID uint `json:"host_id"` - HostDisplayName string `json:"host_display_name"` - SoftwareTitle string `json:"software_title"` - SoftwareTitleID uint `json:"software_title_id"` + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` + SoftwareTitle string `json:"software_title"` + SoftwareTitleID uint `json:"software_title_id"` + FromSetupExperience bool `json:"from_setup_experience"` } func (a ActivityTypeCanceledInstallAppStoreApp) HostIDs() []uint { @@ -1585,6 +1592,10 @@ func (a ActivityTypeCanceledInstallAppStoreApp) ActivityName() string { return "canceled_install_app_store_app" } +func (a ActivityTypeCanceledInstallAppStoreApp) WasFromAutomation() bool { + return a.FromSetupExperience +} + type ActivityTypeRanScriptBatch struct { ScriptName string `json:"script_name"` BatchExecutionID string `json:"batch_execution_id"` @@ -1852,3 +1863,35 @@ func (a ActivityTypeInstalledCertificate) WasFromAutomation() bool { func (a ActivityTypeInstalledCertificate) HostOnly() bool { return true } + +type ActivityTypeClearedPasscode struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` +} + +func (a ActivityTypeClearedPasscode) ActivityName() string { + return "cleared_passcode" +} + +func (a ActivityTypeClearedPasscode) HostIDs() []uint { + return []uint{a.HostID} +} + +type ActivityTypeCanceledSetupExperience struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` + SoftwareTitle string `json:"software_title"` + SoftwareTitleID uint `json:"software_title_id"` +} + +func (a ActivityTypeCanceledSetupExperience) ActivityName() string { + return "canceled_setup_experience" +} + +func (a ActivityTypeCanceledSetupExperience) HostIDs() []uint { + return []uint{a.HostID} +} + +func (a ActivityTypeCanceledSetupExperience) WasFromAutomation() bool { + return true +} diff --git a/server/fleet/activities_test.go b/server/fleet/activities_test.go new file mode 100644 index 00000000000..3bb1052b286 --- /dev/null +++ b/server/fleet/activities_test.go @@ -0,0 +1,82 @@ +package fleet + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestVPPInstallFailureEmptyCommandUUIDDoesNotActivateNext exercises the +// scenario where a VPP install is attempted during setup experience for a +// host that has other upcoming activities queued. If the VPP call fails +// before an MDM command is sent (e.g. no available licenses), the +// CommandUUID is empty. In that case the next upcoming activity must NOT +// be activated, because the current activity was never truly started — +// activating the next one would break the intended sequential ordering. +// +// See commit 159194acc9d92843bb2de933309f159c84a501aa for the fix. +func TestVPPInstallFailureEmptyCommandUUIDDoesNotActivateNext(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + activity ActivityInstalledAppStoreApp + expectActivate bool + }{ + { + name: "failed VPP install with empty command UUID must not activate next upcoming activity", + activity: ActivityInstalledAppStoreApp{ + HostID: 42, + HostDisplayName: "ios-host", + SoftwareTitle: "Licensed App", + AppStoreID: "99999", + CommandUUID: "", // no MDM command was sent + Status: string(SoftwareInstallFailed), + FromSetupExperience: true, + }, + expectActivate: false, + }, + { + name: "failed VPP install with command UUID activates next upcoming activity", + activity: ActivityInstalledAppStoreApp{ + HostID: 42, + HostDisplayName: "ios-host", + SoftwareTitle: "Licensed App", + AppStoreID: "99999", + CommandUUID: "cmd-uuid-abc", + Status: string(SoftwareInstallFailed), + FromSetupExperience: true, + }, + expectActivate: true, + }, + { + name: "successful VPP install must not activate next (handled by install verification)", + activity: ActivityInstalledAppStoreApp{ + HostID: 42, + HostDisplayName: "ios-host", + SoftwareTitle: "Licensed App", + AppStoreID: "99999", + CommandUUID: "cmd-uuid-abc", + Status: string(SoftwareInstalled), + FromSetupExperience: true, + }, + expectActivate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.expectActivate, tt.activity.MustActivateNextUpcomingActivity(), + "MustActivateNextUpcomingActivity() = %v, want %v", + tt.activity.MustActivateNextUpcomingActivity(), tt.expectActivate) + + if tt.expectActivate { + hostID, cmdUUID := tt.activity.ActivateNextUpcomingActivityArgs() + assert.Equal(t, tt.activity.HostID, hostID) + assert.Equal(t, tt.activity.CommandUUID, cmdUUID) + } + }) + } +} diff --git a/server/fleet/setup_experience.go b/server/fleet/setup_experience.go index 75912134e3f..b52aed3b89c 100644 --- a/server/fleet/setup_experience.go +++ b/server/fleet/setup_experience.go @@ -121,6 +121,10 @@ func (s *SetupExperienceStatusResult) IsForSoftwarePackage() bool { return s.SoftwareInstallerID != nil } +func (s *SetupExperienceStatusResult) IsForVPPApp() bool { + return s.VPPAppTeamID != nil +} + func (s *SetupExperienceStatusResult) ForMyDevicePage(token string) { // convert api style iconURL to device token URL if s.IconURL != "" && s.SoftwareTitleID != nil { diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index a13a3263fb7..024e8e9d976 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -3951,7 +3951,7 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ HostUUID: cmdResult.Identifier(), CommandUUID: cmdResult.CommandUUID, CommandStatus: cmdResult.Status, - }, true); err != nil { + }, fleet.NewActivityFunc(svc.newActivityFn)); err != nil { return nil, ctxerr.Wrap(r.Context, err, "updating setup experience status from VPP install result") } else if updated { // TODO: call next step of setup experience? diff --git a/server/service/apple_mdm_cmd_results.go b/server/service/apple_mdm_cmd_results.go index 410eeefd637..4136c40577d 100644 --- a/server/service/apple_mdm_cmd_results.go +++ b/server/service/apple_mdm_cmd_results.go @@ -171,7 +171,7 @@ func NewInstalledApplicationListResultsHandler( HostUUID: installedAppResult.HostUUID(), CommandUUID: expectedInstall.InstallCommandUUID, CommandStatus: terminalStatus, - }, true); err != nil { + }, fleet.NewActivityFunc(newActivityFn)); err != nil { return ctxerr.Wrap(ctx, err, "updating setup experience status from VPP install result") } else if updated { fromSetupExperience = true diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index c0c82dc3134..2b05185dd70 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -7968,7 +7968,8 @@ func (s *integrationEnterpriseTestSuite) TestRunBatchScript() { "batch_execution_id": "%s", "script_execution_id": "%s", "script_name": "%s", - "host_display_name": "%s" + "host_display_name": "%s", + "from_setup_experience": false } `, host1.ID, batchRes.BatchExecutionID, orbitRespHost1.Notifications.PendingScriptExecutionIDs[0], script.Name, host1.DisplayName()) require.Len(t, hostPastActivitiesResp.Activities, 1) @@ -8183,7 +8184,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() { s.lastActivityMatches( fleet.ActivityTypeRanScript{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null, "batch_execution_id": null}`, + `{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null, "batch_execution_id": null, "from_setup_experience": false}`, host.ID, host.DisplayName(), savedNoTmScript.Name, scriptResultResp.ExecutionID, ), 0, @@ -15701,7 +15702,7 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptSoftDelete() { s.lastActivityOfTypeMatches( fleet.ActivityTypeRanScript{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": %q, "script_name": "", "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null, "batch_execution_id": null}`, + `{"host_id": %d, "host_display_name": %q, "script_name": "", "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null, "batch_execution_id": null, "from_setup_experience": false}`, host.ID, host.DisplayName(), scriptExecID), 0) // create a saved script execution request @@ -15723,7 +15724,7 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptSoftDelete() { http.StatusOK) s.lastActivityOfTypeMatches( fleet.ActivityTypeRanScript{}.ActivityName(), - fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "script_name": "script1.sh", "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null, "batch_execution_id": null}`, + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "script_name": "script1.sh", "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null, "batch_execution_id": null, "from_setup_experience": false}`, host.ID, host.DisplayName(), savedScriptExecID), 0) // get the anonymous script result details @@ -17979,7 +17980,8 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers "status": "installed", "source": "apps", "policy_id": %d, - "policy_name": "%s" + "policy_name": "%s", + "from_setup_experience": false }`, host1Team1.ID, host1Team1.DisplayName(), "DummyApp", "dummy_installer.pkg", host1LastInstall.ExecutionID, policy1Team1.ID, policy1Team1.Name), 0) var activityCount int @@ -23331,18 +23333,13 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() require.NotEmpty(t, executionIDs["vim"]) require.NotEmpty(t, executionIDs["test.tar.gz"]) - s.lastActivityOfTypeMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{ + s.lastActivityOfTypeMatches(fleet.ActivityTypeCanceledInstallSoftware{}.ActivityName(), fmt.Sprintf(`{ "host_id": %d, "host_display_name": %q, "software_title": %q, - "software_package": %q, - "install_uuid": %q, - "status": "failed", - "self_service": false, - "source": "deb_packages", - "policy_name": null, - "policy_id": null - }`, ubuntuHost.ID, ubuntuHost.DisplayName(), "vim", "vim.deb", executionIDs["vim"]), 0) + "software_title_id": %d, + "from_setup_experience": true + }`, ubuntuHost.ID, ubuntuHost.DisplayName(), "vim", debVimTitleID), 0) // Record a result for test.tar.gz. s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( diff --git a/server/service/integration_mdm_setup_experience_test.go b/server/service/integration_mdm_setup_experience_test.go index 08103a7385b..9b61f50f823 100644 --- a/server/service/integration_mdm_setup_experience_test.go +++ b/server/service/integration_mdm_setup_experience_test.go @@ -500,7 +500,8 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu "status": "installed", "source": "apps", "policy_id": null, - "policy_name": null + "policy_name": null, + "from_setup_experience": true } `, enrolledHost.ID, getHostResp.Host.DisplayName, statusResp.Results.Software[0].Name, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage.Name, installUUID) @@ -580,7 +581,8 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu "script_name": "%s", "host_display_name": "%s", "script_execution_id": "%s", - "batch_execution_id": null + "batch_execution_id": null, + "from_setup_experience": true } `, enrolledHost.ID, statusResp.Results.Script.Name, getHostResp.Host.DisplayName, execID) @@ -931,7 +933,8 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithFMAAndVersionRollba "status": "installed", "source": "apps", "policy_id": null, - "policy_name": null + "policy_name": null, + "from_setup_experience": true } `, enrolledHost.ID, getHostResp.Host.DisplayName, titleDetail.SoftwareTitle.SoftwarePackage.Name, installUUID) s.lastActivityMatchesExtended(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), expectedActivityDetail, 0, ptr.Bool(true)) @@ -4146,7 +4149,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceAndroid() { req := android_service.PubSubPushRequest{PubSubMessage: *reportMsg} s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubSubToken.Value)) s.lastActivityOfTypeMatches(fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"app_store_id":%q, - "command_uuid":%q, "host_display_name":%q, "host_id":%d, "host_platform":%q, "policy_id":null, "policy_name":null, "self_service":false, "from_auto_update": false, "software_title":%q, + "command_uuid":%q, "from_auto_update": false, "from_setup_experience": true, "host_display_name":%q, "host_id":%d, "host_platform":%q, "policy_id":null, "policy_name":null, "self_service":false, "software_title":%q, "status":%q}`, app1.AdamID, app1CmdUUID, host.DisplayName(), host.ID, host.Platform, app1.Name, fleet.SoftwareInstalled), 0) // the pending install should now be verified @@ -4366,7 +4369,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceAndroidCancelOnUnenroll() { {"enrollment_id": null, "host_display_name": %q, "host_serial": %q, "installed_from_dep": false, "platform": %q}`, host1.DisplayName(), "", host1.Platform), 0) // for some reason the serial is force-set to empty string when we create this activity s.lastActivityOfTypeMatches(fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf(`{"app_store_id":%q, - "command_uuid":%q, "host_display_name":%q, "host_id":%d, "host_platform":%q, "policy_id":null, "policy_name":null, "self_service":false, "from_auto_update": false, "software_title":%q, + "command_uuid":%q, "from_auto_update": false, "from_setup_experience": true, "host_display_name":%q, "host_id":%d, "host_platform":%q, "policy_id":null, "policy_name":null, "self_service":false, "software_title":%q, "status":%q}`, app1.AdamID, app1CmdUUID, host1.DisplayName(), host1.ID, host1.Platform, app1.Name, fleet.SoftwareInstallFailed), 0) // host2 and host3 haven't been unenrolled, app install is still pending diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index d1bab7a02e5..314bf99b911 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -14258,7 +14258,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_auto_update": false}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_setup_experience": false, "from_auto_update": false}`, mdmHost.ID, mdmHost.DisplayName(), errApp.Name, @@ -14344,7 +14344,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_auto_update": false}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_setup_experience": false, "from_auto_update": false}`, mdmHost.ID, mdmHost.DisplayName(), macOSApp.Name, @@ -14420,7 +14420,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_auto_update": false}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_setup_experience": false, "from_auto_update": false}`, mdmHost.ID, mdmHost.DisplayName(), addedApp.Name, @@ -14678,7 +14678,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": %v, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_auto_update": false}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": %v, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_setup_experience": false, "from_auto_update": false}`, installHost.ID, installHost.DisplayName(), app.Name, @@ -15228,7 +15228,7 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { s.lastActivityMatchesExtended( fleet.ActivityTypeRanScript{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": %d, "policy_name": "%s", "batch_execution_id": null}`, + `{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": %d, "policy_name": "%s", "batch_execution_id": null, "from_setup_experience": false}`, mdmHost2.ID, mdmHost2.DisplayName(), savedTmScript.Name, scriptExecID, policy3Team1.ID, policy3Team1.Name, ), 0, @@ -15297,7 +15297,7 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { s.lastActivityMatchesExtended( fleet.ActivityTypeRanScript{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": %d, "policy_name": "%s", "batch_execution_id": null}`, + `{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": %d, "policy_name": "%s", "batch_execution_id": null, "from_setup_experience": false}`, mdmHost2.ID, mdmHost2.DisplayName(), savedTmScript.Name, scriptExecID, policy3Team1.ID, policy3Team1.Name, ), 0, @@ -15414,7 +15414,7 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { s.lastActivityMatchesExtended( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": %v, "policy_id": %d, "policy_name": "%s", "host_platform": "%s", "from_auto_update": false}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": %v, "policy_id": %d, "policy_name": "%s", "host_platform": "%s", "from_setup_experience": false, "from_auto_update": false}`, mdmHost.ID, mdmHost.DisplayName(), macOSApp.Name, @@ -15466,7 +15466,7 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { s.lastActivityMatchesExtended( fleet.ActivityTypeRanScript{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": %d, "policy_name": "%s", "batch_execution_id": null}`, + `{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": %d, "policy_name": "%s", "batch_execution_id": null, "from_setup_experience": false}`, mdmHost2.ID, mdmHost2.DisplayName(), savedTmScript.Name, scriptExecID, policy3Team1.ID, policy3Team1.Name, ), 0, @@ -15532,7 +15532,7 @@ func (s *integrationMDMTestSuite) TestVPPAppPolicyAutomation() { s.lastActivityMatchesExtended( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "host_platform": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": %v, "policy_id": %d, "policy_name": "%s", "from_auto_update": false}`, + `{"host_id": %d, "host_display_name": "%s", "host_platform": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": %v, "policy_id": %d, "policy_name": "%s", "from_setup_experience": false, "from_auto_update": false}`, mdmHost2.ID, mdmHost2.DisplayName(), mdmHost2.Platform, @@ -19696,7 +19696,7 @@ func (s *integrationMDMTestSuite) TestCancelUpcomingActivity() { // cancel the VPP app install, confirm canceled activity s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming/%s", mdmHost.ID, hostActivitiesResp.Activities[0].UUID), nil, http.StatusNoContent) lastCanceledActID = s.lastActivityOfTypeMatches(fleet.ActivityTypeCanceledInstallAppStoreApp{}.ActivityName(), - fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "software_title": "App 1", "software_title_id": %d}`, mdmHost.ID, mdmHost.DisplayName(), vppAppTitleID), 0) + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "software_title": "App 1", "software_title_id": %d, "from_setup_experience": false}`, mdmHost.ID, mdmHost.DisplayName(), vppAppTitleID), 0) // to be able to simulate the host sending a MDM result post-cancelation, // we need to re-activate the MDM command. @@ -19730,7 +19730,7 @@ func (s *integrationMDMTestSuite) TestCancelUpcomingActivity() { // cancel the software install, confirm canceled activity s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming/%s", mdmHost.ID, hostActivitiesResp.Activities[1].UUID), nil, http.StatusNoContent) lastCanceledActID = s.lastActivityOfTypeMatches(fleet.ActivityTypeCanceledInstallSoftware{}.ActivityName(), - fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "software_title": "DummyApp", "software_title_id": %d}`, mdmHost.ID, mdmHost.DisplayName(), swTitleID), 0) + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "software_title": "DummyApp", "software_title_id": %d, "from_setup_experience": false}`, mdmHost.ID, mdmHost.DisplayName(), swTitleID), 0) // record a software install result post-cancelation s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ diff --git a/server/service/integration_vpp_install_test.go b/server/service/integration_vpp_install_test.go index 321b7c51523..76d4b1f42d4 100644 --- a/server/service/integration_vpp_install_test.go +++ b/server/service/integration_vpp_install_test.go @@ -435,7 +435,7 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_setup_experience": false}`, mdmHost.ID, mdmHost.DisplayName(), errApp.Name, @@ -483,7 +483,7 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_setup_experience": false}`, mdmHost.ID, mdmHost.DisplayName(), addedApp.Name, @@ -600,7 +600,7 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_setup_experience": false}`, mdmHost.ID, mdmHost.DisplayName(), addedApp.Name, @@ -650,7 +650,7 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_setup_experience": false}`, mdmHost.ID, mdmHost.DisplayName(), addedApp.Name, @@ -900,7 +900,7 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_setup_experience": false}`, iosHost.ID, iosHost.DisplayName(), iOSApp.Name, @@ -1008,7 +1008,7 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": false, "from_auto_update": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_setup_experience": false}`, ipodHost.ID, ipodHost.DisplayName(), iOSApp.Name, @@ -1164,7 +1164,7 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": true, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_auto_update": false}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "status": "%s", "self_service": true, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_setup_experience": false, "from_auto_update": false}`, data.host.ID, data.host.DisplayName(), data.app.Name, @@ -1847,7 +1847,7 @@ func (s *integrationMDMTestSuite) TestInHouseAppSelfInstall() { // installed activity is now created activityData = fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "command_uuid": %q, "install_uuid": "", "software_title": "ipa_test", "software_package": "", "self_service": true, "status": "installed", - "policy_id": null, "policy_name": null}`, iosHost.ID, iosHost.DisplayName(), installCmdUUID) + "policy_id": null, "policy_name": null, "from_setup_experience": false}`, iosHost.ID, iosHost.DisplayName(), installCmdUUID) s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), activityData, 0) // host has no more upcoming activities @@ -2179,7 +2179,7 @@ func (s *integrationMDMTestSuite) TestVPPAppScheduledUpdates() { s.lastActivityMatches( fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "from_auto_update": false, "status": "%s", "self_service": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "from_auto_update": false, "status": "%s", "self_service": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_setup_experience": false}`, host.ID, host.DisplayName(), "App 1", @@ -2457,7 +2457,7 @@ func (s *integrationMDMTestSuite) TestVPPAppScheduledUpdates() { fleet.ActivityInstalledAppStoreApp{}.ActivityName(), fmt.Sprintf( // See `"from_auto_update": true`. - `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "from_auto_update": true, "status": "%s", "self_service": false, "policy_id": null, "policy_name": null, "host_platform": "%s"}`, + `{"host_id": %d, "host_display_name": "%s", "software_title": "%s", "app_store_id": "%s", "command_uuid": "%s", "from_auto_update": true, "status": "%s", "self_service": false, "policy_id": null, "policy_name": null, "host_platform": "%s", "from_setup_experience": false}`, host.ID, host.DisplayName(), "App 1", diff --git a/server/service/orbit.go b/server/service/orbit.go index 103ee328beb..8d9aee1ad7a 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -855,7 +855,7 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host HostUUID: host.UUID, ExecutionID: result.ExecutionID, ExitCode: result.ExitCode, - }, true); err != nil { + }, svc.NewActivity); err != nil { return ctxerr.Wrap(ctx, err, "update setup experience status") } else if updated { svc.logger.DebugContext(ctx, "setup experience script result updated", "host_uuid", host.UUID, "execution_id", result.ExecutionID) @@ -1318,7 +1318,7 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f HostUUID: hostUUID, ExecutionID: result.InstallUUID, InstallerStatus: result.Status(), - }, true); err != nil { + }, svc.NewActivity); err != nil { return ctxerr.Wrap(ctx, err, "update setup experience status") } else if updated { svc.logger.DebugContext(ctx, diff --git a/server/service/setup_experience.go b/server/service/setup_experience.go index 9cfa7af3fa8..85cc0a06de6 100644 --- a/server/service/setup_experience.go +++ b/server/service/setup_experience.go @@ -257,10 +257,10 @@ func isAllSetupExperienceSoftwareRequired(ctx context.Context, ds fleet.Datastor } func (svc *Service) MaybeCancelPendingSetupExperienceSteps(ctx context.Context, host *fleet.Host) error { - return maybeCancelPendingSetupExperienceSteps(ctx, svc.ds, host) + return maybeCancelPendingSetupExperienceSteps(ctx, svc.ds, host, svc.NewActivity) } -func maybeCancelPendingSetupExperienceSteps(ctx context.Context, ds fleet.Datastore, host *fleet.Host) error { +func maybeCancelPendingSetupExperienceSteps(ctx context.Context, ds fleet.Datastore, host *fleet.Host, newActivityFn fleet.NewActivityFunc) error { // If the host is not MacOS, we do nothing. if host.Platform != "darwin" { return nil @@ -309,6 +309,26 @@ func maybeCancelPendingSetupExperienceSteps(ctx context.Context, ds fleet.Datast if err := ds.CancelPendingSetupExperienceSteps(ctx, hostUUID); err != nil { return ctxerr.Wrap(ctx, err, "cancelling pending setup experience steps") } + + // Emit the canceled_setup_experience activity once at cancellation time. + // Find the software item that failed and triggered this cancellation from the + // already-loaded statuses (no extra DB call). + if newActivityFn != nil { + for _, s := range statuses { + if s.Status == fleet.SetupExperienceStatusFailure && s.IsForSoftware() { + if err := newActivityFn(ctx, nil, fleet.ActivityTypeCanceledSetupExperience{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + SoftwareTitle: s.Name, + SoftwareTitleID: ptr.ValOrZero(s.SoftwareTitleID), + }); err != nil { + return ctxerr.Wrap(ctx, err, "creating canceled setup experience activity") + } + break + } + } + } + return nil } @@ -317,9 +337,8 @@ func maybeCancelPendingSetupExperienceSteps(ctx context.Context, ds fleet.Datast // SetupExperienceSoftwareInstallResult, and SetupExperienceVPPInstallResult), it returns a boolean // indicating whether the datastore was updated and an error if one occurred. If the result is not of a // supported type, it returns false and an error indicated that the type is not supported. -// If the skipPending parameter is true, the datastore will only be updated if the given result -// status is not pending. -func maybeUpdateSetupExperienceStatus(ctx context.Context, ds fleet.Datastore, result interface{}, requireTerminalStatus bool) (bool, error) { +// The datastore will only be updated if the given result status is a terminal status. +func maybeUpdateSetupExperienceStatus(ctx context.Context, ds fleet.Datastore, result any, newActivityFn fleet.NewActivityFunc) (bool, error) { var updated bool var err error var status fleet.SetupExperienceStatusResultStatus @@ -329,7 +348,7 @@ func maybeUpdateSetupExperienceStatus(ctx context.Context, ds fleet.Datastore, r status = v.SetupExperienceStatus() if !status.IsValid() { return false, fmt.Errorf("invalid status: %s", status) - } else if requireTerminalStatus && !status.IsTerminalStatus() { + } else if !status.IsTerminalStatus() { return false, nil } return ds.MaybeUpdateSetupExperienceScriptStatus(ctx, v.HostUUID, v.ExecutionID, status) @@ -339,7 +358,7 @@ func maybeUpdateSetupExperienceStatus(ctx context.Context, ds fleet.Datastore, r hostUUID = v.HostUUID if !status.IsValid() { return false, fmt.Errorf("invalid status: %s", status) - } else if requireTerminalStatus && !status.IsTerminalStatus() { + } else if !status.IsTerminalStatus() { return false, nil } updated, err = ds.MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx, v.HostUUID, v.ExecutionID, status) @@ -351,7 +370,7 @@ func maybeUpdateSetupExperienceStatus(ctx context.Context, ds fleet.Datastore, r hostUUID = v.HostUUID if !status.IsValid() { return false, fmt.Errorf("invalid status: %s", status) - } else if requireTerminalStatus && !status.IsTerminalStatus() { + } else if !status.IsTerminalStatus() { return false, nil } updated, err = ds.MaybeUpdateSetupExperienceVPPStatus(ctx, v.HostUUID, v.CommandUUID, status) @@ -368,9 +387,9 @@ func maybeUpdateSetupExperienceStatus(ctx context.Context, ds fleet.Datastore, r if getHostUUIDErr != nil { return updated, fmt.Errorf("getting host by UUID: %w", getHostUUIDErr) } - cancelErr := maybeCancelPendingSetupExperienceSteps(ctx, ds, host) + cancelErr := maybeCancelPendingSetupExperienceSteps(ctx, ds, host, newActivityFn) if cancelErr != nil { - return updated, fmt.Errorf("cancel setup experience after macos software install failure: %w", cancelErr) + return updated, fmt.Errorf("cancel setup experience after software install failure: %w", cancelErr) } } return updated, err diff --git a/server/service/setup_experience_test.go b/server/service/setup_experience_test.go index ec7719f7f01..c3d3daf828b 100644 --- a/server/service/setup_experience_test.go +++ b/server/service/setup_experience_test.go @@ -232,7 +232,7 @@ func TestMaybeUpdateSetupExperience(t *testing.T) { vppUUID := "vpp-uuid" t.Run("unsupported result type", func(t *testing.T) { - _, err := maybeUpdateSetupExperienceStatus(ctx, ds, map[string]interface{}{"key": "value"}, true) + _, err := maybeUpdateSetupExperienceStatus(ctx, ds, map[string]any{"key": "value"}, nil) require.Error(t, err) require.Contains(t, err.Error(), "unsupported result type") }) @@ -278,7 +278,7 @@ func TestMaybeUpdateSetupExperience(t *testing.T) { ExecutionID: scriptUUID, ExitCode: tt.exitCode, } - updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, true) + updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, nil) require.NoError(t, err) require.Equal(t, tt.alwaysUpdated, updated) require.Equal(t, tt.alwaysUpdated, ds.MaybeUpdateSetupExperienceScriptStatusFuncInvoked) @@ -315,8 +315,6 @@ func TestMaybeUpdateSetupExperience(t *testing.T) { for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { - requireTerminalStatus := true // when this flag is true, we don't expect pending status to update - ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { require.Equal(t, hostUUID, hostUUID) require.Equal(t, executionID, softwareUUID) @@ -336,34 +334,16 @@ func TestMaybeUpdateSetupExperience(t *testing.T) { ExecutionID: softwareUUID, InstallerStatus: tt.status, } - updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, requireTerminalStatus) + activityFnCalled := false + activityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + activityFnCalled = true + return nil + } + updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, activityFn) require.NoError(t, err) require.Equal(t, tt.alwaysUpdated, updated) require.Equal(t, tt.alwaysUpdated, ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked) - - requireTerminalStatus = false // when this flag is false, we do expect pending status to update - - ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hostUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { - require.Equal(t, hostUUID, hostUUID) - require.Equal(t, executionID, softwareUUID) - require.Equal(t, tt.expectStatus, status) - require.True(t, status.IsValid()) - if status.IsTerminalStatus() { - require.True(t, status == fleet.SetupExperienceStatusSuccess || status == fleet.SetupExperienceStatusFailure) - } else { - require.True(t, status == fleet.SetupExperienceStatusPending || status == fleet.SetupExperienceStatusRunning) - } - return true, nil - } - ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked = false - updated, err = maybeUpdateSetupExperienceStatus(ctx, ds, result, requireTerminalStatus) - require.NoError(t, err) - shouldUpdate := tt.alwaysUpdated - if tt.expectStatus == fleet.SetupExperienceStatusPending || tt.expectStatus == fleet.SetupExperienceStatusRunning { - shouldUpdate = true - } - require.Equal(t, shouldUpdate, updated) - require.Equal(t, shouldUpdate, ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked) + require.False(t, activityFnCalled) }) } }) @@ -403,8 +383,6 @@ func TestMaybeUpdateSetupExperience(t *testing.T) { for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { - requireTerminalStatus := true // when this flag is true, we don't expect pending status to update - ds.MaybeUpdateSetupExperienceVPPStatusFunc = func(ctx context.Context, hostUUID string, cmdUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { require.Equal(t, hostUUID, hostUUID) require.Equal(t, cmdUUID, vppUUID) @@ -423,36 +401,230 @@ func TestMaybeUpdateSetupExperience(t *testing.T) { CommandUUID: vppUUID, CommandStatus: tt.status, } - updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, requireTerminalStatus) + activityFnCalled := false + activityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + activityFnCalled = true + return nil + } + updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, activityFn) require.NoError(t, err) require.Equal(t, tt.alwaysUpdated, updated) require.Equal(t, tt.alwaysUpdated, ds.MaybeUpdateSetupExperienceVPPStatusFuncInvoked) + require.False(t, activityFnCalled) + }) + } + }) - requireTerminalStatus = false // when this flag is false, we do expect pending status to update + t.Run("software install failure triggers cancel and activity", func(t *testing.T) { + teamID := uint(1) + failedSoftwareTitleID := uint(42) + failedSoftwareName := "FailedApp" + pendingExecID := "pending-exec-id" - ds.MaybeUpdateSetupExperienceVPPStatusFunc = func(ctx context.Context, hostUUID string, cmdUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { - require.Equal(t, hostUUID, hostUUID) - require.Equal(t, cmdUUID, vppUUID) - require.Equal(t, tt.expected, status) - require.True(t, status.IsValid()) - if status.IsTerminalStatus() { - require.True(t, status == fleet.SetupExperienceStatusSuccess || status == fleet.SetupExperienceStatusFailure) - } else { - require.True(t, status == fleet.SetupExperienceStatusPending || status == fleet.SetupExperienceStatusRunning) - } - return true, nil - } - ds.MaybeUpdateSetupExperienceVPPStatusFuncInvoked = false + ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + require.Equal(t, hostUUID, hUUID) + require.Equal(t, softwareUUID, executionID) + require.Equal(t, fleet.SetupExperienceStatusFailure, status) + return true, nil + } + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + return &fleet.Host{ + ID: 1, + UUID: hostUUID, + Platform: "darwin", + TeamID: &teamID, + }, nil + } + ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) { + require.Equal(t, teamID, tid) + return &fleet.TeamLite{ + ID: teamID, + Config: fleet.TeamConfigLite{ + MDM: fleet.TeamMDM{ + MacOSSetup: fleet.MacOSSetup{ + RequireAllSoftware: true, + }, + }, + }, + }, nil + } - updated, err = maybeUpdateSetupExperienceStatus(ctx, ds, result, requireTerminalStatus) - require.NoError(t, err) - shouldUpdate := tt.alwaysUpdated - if tt.expected == fleet.SetupExperienceStatusPending || tt.expected == fleet.SetupExperienceStatusRunning { - shouldUpdate = true - } - require.Equal(t, shouldUpdate, updated) - require.Equal(t, shouldUpdate, ds.MaybeUpdateSetupExperienceVPPStatusFuncInvoked) - }) + installerID := uint(10) + ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hUUID string, tID uint) ([]*fleet.SetupExperienceStatusResult, error) { + return []*fleet.SetupExperienceStatusResult{ + { + ID: 1, + HostUUID: hostUUID, + Name: failedSoftwareName, + Status: fleet.SetupExperienceStatusFailure, + SoftwareInstallerID: &installerID, + HostSoftwareInstallsExecutionID: &softwareUUID, + SoftwareTitleID: &failedSoftwareTitleID, + }, + { + ID: 2, + HostUUID: hostUUID, + Name: "PendingApp", + Status: fleet.SetupExperienceStatusPending, + SoftwareInstallerID: &installerID, + HostSoftwareInstallsExecutionID: &pendingExecID, + }, + }, nil + } + ds.CancelHostUpcomingActivityFunc = func(ctx context.Context, hID uint, executionID string) (fleet.ActivityDetails, error) { + require.Equal(t, uint(1), hID) + require.Equal(t, pendingExecID, executionID) + return nil, nil + } + ds.CancelPendingSetupExperienceStepsFunc = func(ctx context.Context, hUUID string) error { + require.Equal(t, hostUUID, hUUID) + return nil + } + + var activityFnCalled bool + var recordedActivity fleet.ActivityDetails + activityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + activityFnCalled = true + recordedActivity = activity + return nil + } + + result := fleet.SetupExperienceSoftwareInstallResult{ + HostUUID: hostUUID, + ExecutionID: softwareUUID, + InstallerStatus: fleet.SoftwareInstallFailed, + } + updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, activityFn) + require.NoError(t, err) + require.True(t, updated) + require.True(t, activityFnCalled) + require.True(t, ds.CancelPendingSetupExperienceStepsFuncInvoked) + require.True(t, ds.CancelHostUpcomingActivityFuncInvoked) + + canceledActivity, ok := recordedActivity.(fleet.ActivityTypeCanceledSetupExperience) + require.True(t, ok) + require.Equal(t, uint(1), canceledActivity.HostID) + require.Equal(t, failedSoftwareName, canceledActivity.SoftwareTitle) + require.Equal(t, failedSoftwareTitleID, canceledActivity.SoftwareTitleID) + }) + + t.Run("late arriving result for canceled item does not trigger duplicate activity", func(t *testing.T) { + // See https://github.com/fleetdm/fleet/pull/43437#discussion_r3074297752 + // 1. Software install A fails → triggers cancel of pending VPP install B + emits activity + // 2. Later, B's MDM command result (Error) arrives. The datastore guard returns + // updated=false because B is already in "canceled" state, so the cancel/activity + // path is NOT entered a second time. + + teamID := uint(1) + failedSoftwareTitleID := uint(42) + failedSoftwareName := "FailedApp" + pendingVPPCommandUUID := "pending-vpp-cmd" + installerID := uint(10) + vppTeamID := uint(1) + + // ---- Step 1: Software install A fails ---- + + ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + require.Equal(t, hostUUID, hUUID) + require.Equal(t, softwareUUID, executionID) + require.Equal(t, fleet.SetupExperienceStatusFailure, status) + return true, nil // updated + } + ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFuncInvoked = false + + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + return &fleet.Host{ + ID: 1, + UUID: hostUUID, + Platform: "darwin", + TeamID: &teamID, + }, nil + } + ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) { + return &fleet.TeamLite{ + ID: teamID, + Config: fleet.TeamConfigLite{ + MDM: fleet.TeamMDM{ + MacOSSetup: fleet.MacOSSetup{ + RequireAllSoftware: true, + }, + }, + }, + }, nil + } + ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hUUID string, tID uint) ([]*fleet.SetupExperienceStatusResult, error) { + return []*fleet.SetupExperienceStatusResult{ + { + ID: 1, + HostUUID: hostUUID, + Name: failedSoftwareName, + Status: fleet.SetupExperienceStatusFailure, + SoftwareInstallerID: &installerID, + HostSoftwareInstallsExecutionID: &softwareUUID, + SoftwareTitleID: &failedSoftwareTitleID, + }, + { + ID: 2, + HostUUID: hostUUID, + Name: "PendingVPPApp", + Status: fleet.SetupExperienceStatusPending, + VPPAppTeamID: &vppTeamID, + NanoCommandUUID: &pendingVPPCommandUUID, + }, + }, nil + } + ds.CancelHostUpcomingActivityFunc = func(ctx context.Context, hID uint, executionID string) (fleet.ActivityDetails, error) { + return nil, nil + } + ds.CancelPendingSetupExperienceStepsFunc = func(ctx context.Context, hUUID string) error { + require.Equal(t, hostUUID, hUUID) + return nil + } + ds.CancelPendingSetupExperienceStepsFuncInvoked = false + ds.CancelHostUpcomingActivityFuncInvoked = false + + activityCallCount := 0 + activityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + activityCallCount++ + return nil + } + + result := fleet.SetupExperienceSoftwareInstallResult{ + HostUUID: hostUUID, + ExecutionID: softwareUUID, + InstallerStatus: fleet.SoftwareInstallFailed, + } + updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, activityFn) + require.NoError(t, err) + require.True(t, updated) + require.True(t, ds.CancelPendingSetupExperienceStepsFuncInvoked) + require.Equal(t, 1, activityCallCount, "activity should have been emitted exactly once") + + // ---- Step 2: Late-arriving VPP result for B (already canceled) ---- + // The datastore guard returns (false, nil) because B's row is already "canceled". + + ds.MaybeUpdateSetupExperienceVPPStatusFunc = func(ctx context.Context, hUUID string, cmdUUID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) { + require.Equal(t, hostUUID, hUUID) + require.Equal(t, vppUUID, cmdUUID) + require.Equal(t, fleet.SetupExperienceStatusFailure, status) + return false, nil // guard blocked: row already canceled + } + ds.MaybeUpdateSetupExperienceVPPStatusFuncInvoked = false + + // Reset invoked flags so we can assert they are NOT set again. + ds.CancelPendingSetupExperienceStepsFuncInvoked = false + ds.CancelHostUpcomingActivityFuncInvoked = false + + vppResult := fleet.SetupExperienceVPPInstallResult{ + HostUUID: hostUUID, + CommandUUID: vppUUID, + CommandStatus: fleet.MDMAppleStatusError, } + updated, err = maybeUpdateSetupExperienceStatus(ctx, ds, vppResult, activityFn) + require.NoError(t, err) + require.False(t, updated, "update should be blocked by datastore guard") + require.False(t, ds.CancelPendingSetupExperienceStepsFuncInvoked, "cancel should NOT be called again") + require.False(t, ds.CancelHostUpcomingActivityFuncInvoked, "cancel upcoming activity should NOT be called again") + require.Equal(t, 1, activityCallCount, "activity should still have been emitted only once (no duplicate)") }) } diff --git a/server/worker/apple_mdm.go b/server/worker/apple_mdm.go index 5bc9dbf04be..41c4aba48da 100644 --- a/server/worker/apple_mdm.go +++ b/server/worker/apple_mdm.go @@ -46,6 +46,7 @@ type AppleMDM struct { Commander *apple_mdm.MDMAppleCommander BootstrapPackageStore fleet.MDMBootstrapPackageStore VPPInstaller fleet.AppleMDMVPPInstaller + NewActivityFn fleet.NewActivityFunc } // Name returns the name of the job. @@ -583,6 +584,7 @@ func (a *AppleMDM) installSetupExperienceVPPAppsOnIosIpadOS(ctx context.Context, cmdUUID, err := a.installSoftwareFromVPP(ctx, host, vppApp, true, opts) + failedBeforeCommandSend := err != nil if err != nil { // if we get an error (e.g. no available licenses) while attempting to enqueue the // install, then we should immediately go to an error state so setup experience @@ -598,6 +600,21 @@ func (a *AppleMDM) installSetupExperienceVPPAppsOnIosIpadOS(ctx context.Context, if err := a.Datastore.UpdateSetupExperienceStatusResult(ctx, app); err != nil { return nil, ctxerr.Wrap(ctx, err, "updating setup experience with vpp install command uuid") } + // Emit activity for the VPP app install failure, if one occurred + if failedBeforeCommandSend && a.NewActivityFn != nil { + failActivity := fleet.ActivityInstalledAppStoreApp{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + SoftwareTitle: app.Name, + AppStoreID: ptr.ValOrZero(app.VPPAppAdamID), + Status: string(fleet.SoftwareInstallFailed), + HostPlatform: host.Platform, + FromSetupExperience: true, + } + if actErr := a.NewActivityFn(ctx, nil, failActivity); actErr != nil { + a.Log.WarnContext(ctx, "failed to create activity for VPP app install failure during setup experience", "err", actErr) + } + } } } diff --git a/server/worker/apple_mdm_test.go b/server/worker/apple_mdm_test.go index 06b79f6ce7f..44919de19aa 100644 --- a/server/worker/apple_mdm_test.go +++ b/server/worker/apple_mdm_test.go @@ -1351,6 +1351,71 @@ INSERT INTO setup_experience_status_results ( require.Contains(t, getEnqueuedCommandTypes(t), "DeviceConfigured") }) + t.Run("emits activity on VPP install failure during setup experience", func(t *testing.T) { + mysql.SetTestABMAssets(t, ds, testOrgName) + test.CreateInsertGlobalVPPToken(t, ds) + defer mysql.TruncateTables(t, ds) + + tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "test"}) + require.NoError(t, err) + + h := createEnrolledHost(t, 1, &tm.ID, true, "ios") + + vppApp := &fleet.VPPApp{ + Name: "fail-app", LatestVersion: "1.0.0", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "fail-adam-id", Platform: fleet.IOSPlatform}}, + BundleIdentifier: "com.example.fail", + } + vppAppWithTeam, err := ds.InsertVPPAppWithTeam(ctx, vppApp, &tm.ID) + require.NoError(t, err) + + mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err = q.ExecContext(ctx, ` +INSERT INTO setup_experience_status_results (host_uuid, name, status, vpp_app_team_id) +VALUES (?, ?, ?, ?)`, h.UUID, vppAppWithTeam.Name, fleet.SetupExperienceStatusPending, vppAppWithTeam.VPPAppTeam.AppTeamID) + return err + }) + + appInstallResponses := map[string]installAppResponse{ + vppAppWithTeam.AdamID: {CommandUUID: "bad-cmd", Error: errors.New("no available licenses")}, + } + vppInstaller := &mockVPPInstaller{t: t, ds: ds, appInstallResponses: appInstallResponses} + + var capturedActivities []fleet.ActivityDetails + mdmWorker := &AppleMDM{ + VPPInstaller: vppInstaller, + Datastore: ds, + Log: slogLog, + Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}), + NewActivityFn: func(_ context.Context, _ *fleet.User, activity fleet.ActivityDetails) error { + capturedActivities = append(capturedActivities, activity) + return nil + }, + } + w := NewWorker(ds, slogLog) + w.Register(mdmWorker) + + err = QueueAppleMDMJob(ctx, ds, slogLog, AppleMDMPostDEPEnrollmentTask, h.UUID, h.Platform, nil, "", true, false) + require.NoError(t, err) + + err = w.ProcessJobs(ctx) + require.NoError(t, err) + + // Exactly one activity should have been emitted for the failed VPP install + require.Len(t, capturedActivities, 1) + act, ok := capturedActivities[0].(fleet.ActivityInstalledAppStoreApp) + require.True(t, ok, "expected ActivityInstalledAppStoreApp, got %T", capturedActivities[0]) + + assert.Equal(t, h.ID, act.HostID) + assert.Equal(t, h.DisplayName(), act.HostDisplayName) + assert.Equal(t, vppAppWithTeam.Name, act.SoftwareTitle) + assert.Equal(t, vppAppWithTeam.AdamID, act.AppStoreID) + assert.Equal(t, string(fleet.SoftwareInstallFailed), act.Status) + assert.Equal(t, h.Platform, act.HostPlatform) + assert.True(t, act.FromSetupExperience) + assert.False(t, act.SelfService) + }) + t.Run("treats NotNow status as a finished command status that does not block device release", func(t *testing.T) { mysql.SetTestABMAssets(t, ds, testOrgName) defer mysql.TruncateTables(t, ds)