Clean up setup experience cancellation behavior (#43437)

Fixes #34288.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests

- [ ] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Setup experience cancellations now create explicit cancellation
activities for skipped/failed software and VPP app installs, plus a new
"Canceled setup experience" activity type and a from_setup_experience
flag. Activity text and host activity views now indicate "during setup
experience" when applicable.
* **Tests**
* Added and updated tests for cancellation activity creation, VPP
license-failure handling, and WasFromAutomation/from_setup_experience
behaviors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ian Littman
2026-04-14 09:39:26 -05:00
committed by GitHub
parent a3b7e29e26
commit 3675f8ff90
31 changed files with 1208 additions and 221 deletions
+3 -3
View File
@@ -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
+19 -37
View File
@@ -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
}
+378
View File
@@ -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())
})
}
+21 -3
View File
@@ -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: