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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+46
-16
@@ -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"`
|
||||
@@ -1865,3 +1876,22 @@ func (a ActivityTypeClearedPasscode) ActivityName() string {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -3960,7 +3960,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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14265,7 +14265,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,
|
||||
@@ -14351,7 +14351,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,
|
||||
@@ -14427,7 +14427,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,
|
||||
@@ -14685,7 +14685,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,
|
||||
@@ -15235,7 +15235,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,
|
||||
@@ -15304,7 +15304,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,
|
||||
@@ -15421,7 +15421,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,
|
||||
@@ -15473,7 +15473,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,
|
||||
@@ -15539,7 +15539,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,
|
||||
@@ -19706,7 +19706,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.
|
||||
@@ -19740,7 +19740,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(`{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -952,7 +952,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)
|
||||
@@ -1415,7 +1415,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,
|
||||
|
||||
@@ -266,10 +266,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 {
|
||||
// Only macOS and Windows support canceling setup experience steps.
|
||||
if host.Platform != "darwin" && host.Platform != "windows" {
|
||||
return nil
|
||||
@@ -318,6 +318,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
|
||||
}
|
||||
|
||||
@@ -326,9 +346,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
|
||||
@@ -338,7 +357,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)
|
||||
@@ -348,7 +367,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)
|
||||
@@ -360,7 +379,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)
|
||||
@@ -377,9 +396,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
|
||||
|
||||
@@ -305,7 +305,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")
|
||||
})
|
||||
@@ -351,7 +351,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)
|
||||
@@ -388,8 +388,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)
|
||||
@@ -409,34 +407,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)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -476,8 +456,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)
|
||||
@@ -496,36 +474,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)
|
||||
|
||||
requireTerminalStatus = false // when this flag is false, we do 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)
|
||||
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
|
||||
|
||||
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)
|
||||
require.False(t, activityFnCalled)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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.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
|
||||
}
|
||||
|
||||
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)")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user