diff --git a/changes/41741-order b/changes/41741-order new file mode 100644 index 0000000000..9d40681a17 --- /dev/null +++ b/changes/41741-order @@ -0,0 +1 @@ +- Updated ordering of setup experience software to take display names into account. diff --git a/ee/server/service/devices.go b/ee/server/service/devices.go index 5d0e9621b0..ce2c9d32dd 100644 --- a/ee/server/service/devices.go +++ b/ee/server/service/devices.go @@ -12,6 +12,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" ) func (svc *Service) ListDevicePolicies(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { @@ -313,7 +314,7 @@ func (svc *Service) getHostSetupExperienceStatus(ctx context.Context, host *flee } // Get current status of the setup experience. - results, err := svc.ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID) + results, err := svc.ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID, ptr.ValOrZero(host.TeamID)) if err != nil { return nil, ctxerr.Wrap(ctx, err, "listing setup experience results") } diff --git a/ee/server/service/orbit.go b/ee/server/service/orbit.go index f39fe3db12..dcd09e371f 100644 --- a/ee/server/service/orbit.go +++ b/ee/server/service/orbit.go @@ -129,7 +129,7 @@ func (svc *Service) GetOrbitSetupExperienceStatus(ctx context.Context, orbitNode } // get status of software installs and script execution - res, err := svc.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID) + res, err := svc.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID, ptr.ValOrZero(host.TeamID)) if err != nil { return nil, ctxerr.Wrap(ctx, err, "listing setup experience results") } @@ -153,19 +153,15 @@ func (svc *Service) GetOrbitSetupExperienceStatus(ctx context.Context, orbitNode // then re-enqueue any cancelled setup experience steps. if hasFailedSoftwareInstall { if resetFailedSetupSteps { - teamID := uint(0) - if host.TeamID != nil { - teamID = *host.TeamID - } // If so, call the enqueue function with a flag to retain successful steps. if requireAllSoftware { svc.logger.InfoContext(ctx, "re-enqueueing cancelled setup experience steps after a previous software install failure", "host_uuid", host.UUID) - _, err := svc.ds.ResetSetupExperienceItemsAfterFailure(ctx, host.Platform, host.PlatformLike, host.UUID, teamID) + _, err := svc.ds.ResetSetupExperienceItemsAfterFailure(ctx, host.Platform, host.PlatformLike, host.UUID, ptr.ValOrZero(host.TeamID)) if err != nil { return nil, ctxerr.Wrap(ctx, err, "re-enqueueing cancelled setup experience steps after a previous software install failure") } // Re-fetch the setup experience results after re-enqueuing. - res, err = svc.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID) + res, err = svc.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID, ptr.ValOrZero(host.TeamID)) if err != nil { return nil, ctxerr.Wrap(ctx, err, "listing setup experience results") } @@ -277,7 +273,7 @@ func (svc *Service) failCancelledSetupExperienceInstalls( HostDisplayName: hostDisplayName, SoftwareTitle: r.Name, SoftwarePackage: softwarePackage, - InstallUUID: *r.HostSoftwareInstallsExecutionID, + InstallUUID: ptr.ValOrZero(r.HostSoftwareInstallsExecutionID), Status: "failed", SelfService: false, Source: source, diff --git a/ee/server/service/setup_experience.go b/ee/server/service/setup_experience.go index f1c1df5a5b..0ca6ff92b2 100644 --- a/ee/server/service/setup_experience.go +++ b/ee/server/service/setup_experience.go @@ -186,13 +186,19 @@ func (svc *Service) SetupExperienceNextStep(ctx context.Context, host *fleet.Hos if err != nil { return false, ctxerr.Wrap(ctx, err, "failed to get host's UUID for the setup experience") } - statuses, err := svc.ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID) + statuses, err := svc.ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID, ptr.ValOrZero(host.TeamID)) if err != nil { return false, ctxerr.Wrap(ctx, err, "retrieving setup experience status results for next step") } - var installersPending, appsPending, scriptsPending []*fleet.SetupExperienceStatusResult - var installersRunning, appsRunning, scriptsRunning int + // Software (installers and VPP apps) are treated as a single group, + // ordered alphabetically by display name (falling back to name). This + // ordering is determined at enqueue time by enqueueSetupExperienceItems, + // which inserts them with auto-incremented IDs in the correct order. + // ListSetupExperienceResultsByHostUUID returns rows ordered by sesr.id. + // Scripts always run after all software is done. + var softwarePending, scriptsPending []*fleet.SetupExperienceStatusResult + var softwareRunning, scriptsRunning int for _, status := range statuses { if err := status.IsValid(); err != nil { @@ -200,21 +206,14 @@ func (svc *Service) SetupExperienceNextStep(ctx context.Context, host *fleet.Hos } switch { - case status.SoftwareInstallerID != nil: + case status.IsForSoftware(): switch status.Status { case fleet.SetupExperienceStatusPending: - installersPending = append(installersPending, status) + softwarePending = append(softwarePending, status) case fleet.SetupExperienceStatusRunning: - installersRunning++ + softwareRunning++ } - case status.VPPAppTeamID != nil: - switch status.Status { - case fleet.SetupExperienceStatusPending: - appsPending = append(appsPending, status) - case fleet.SetupExperienceStatusRunning: - appsRunning++ - } - case status.SetupExperienceScriptID != nil: + case status.IsForScript(): switch status.Status { case fleet.SetupExperienceStatusPending: scriptsPending = append(scriptsPending, status) @@ -225,38 +224,41 @@ func (svc *Service) SetupExperienceNextStep(ctx context.Context, host *fleet.Hos } switch { - case len(installersPending) > 0: - // enqueue installers - for _, installer := range installersPending { - installUUID, err := svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, *installer.SoftwareInstallerID, fleet.HostSoftwareInstallOptions{ + case len(softwarePending) > 0 && softwareRunning == 0: + // Enqueue only the first pending software item (installer or VPP app). + // On the next call, this item will be in "running" state and the next + // pending item will be picked up. This ensures software is installed + // one at a time in the alphabetical display-name order determined at + // enqueue time (rows are ordered by sesr.id). + sw := softwarePending[0] + + switch { + case sw.SoftwareInstallerID != nil: + installUUID, err := svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, *sw.SoftwareInstallerID, fleet.HostSoftwareInstallOptions{ SelfService: false, ForSetupExperience: true, }) if err != nil { return false, ctxerr.Wrap(ctx, err, "queueing setup experience install request") } - installer.HostSoftwareInstallsExecutionID = &installUUID - installer.Status = fleet.SetupExperienceStatusRunning - if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, installer); err != nil { + sw.HostSoftwareInstallsExecutionID = &installUUID + sw.Status = fleet.SetupExperienceStatusRunning + if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, sw); err != nil { return false, ctxerr.Wrap(ctx, err, "updating setup experience result with install uuid") } - } - case installersRunning == 0 && len(appsPending) > 0: - // enqueue vpp apps - var skipRemainingVPPInstalls bool - enqueueVPPApps: - for _, app := range appsPending { - vppAppID, err := app.VPPAppID() + + case sw.VPPAppTeamID != nil: + vppAppID, err := sw.VPPAppID() if err != nil { return false, ctxerr.Wrap(ctx, err, "constructing vpp app details for installation") } - if app.SoftwareTitleID == nil { - return false, ctxerr.Errorf(ctx, "setup experience software title id missing from vpp app install request: %d", app.ID) + if sw.SoftwareTitleID == nil { + return false, ctxerr.Errorf(ctx, "setup experience software title id missing from vpp app install request: %d", sw.ID) } vppApp := &fleet.VPPApp{ - TitleID: *app.SoftwareTitleID, + TitleID: *sw.SoftwareTitleID, VPPAppTeam: fleet.VPPAppTeam{ VPPAppID: *vppAppID, }, @@ -267,16 +269,13 @@ func (svc *Service) SetupExperienceNextStep(ctx context.Context, host *fleet.Hos ForSetupExperience: true, }) - app.NanoCommandUUID = &cmdUUID - app.Status = fleet.SetupExperienceStatusRunning - 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 // isn't blocked. - svc.logger.WarnContext(ctx, "got an error when attempting to enqueue VPP app install", "err", err, "adam_id", app.VPPAppAdamID) - app.Status = fleet.SetupExperienceStatusFailure - app.Error = ptr.String(err.Error()) + 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()) // 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) @@ -288,17 +287,16 @@ func (svc *Service) SetupExperienceNextStep(ctx context.Context, host *fleet.Hos if err != nil { return false, ctxerr.Wrap(ctx, err, "cancelling remaining setup experience steps after vpp app install failure") } - skipRemainingVPPInstalls = true } + } else { + sw.NanoCommandUUID = &cmdUUID + sw.Status = fleet.SetupExperienceStatusRunning } - if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, app); err != nil { + if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, sw); err != nil { return false, ctxerr.Wrap(ctx, err, "updating setup experience with vpp install command uuid") } - if skipRemainingVPPInstalls { - break enqueueVPPApps - } } - case installersRunning == 0 && appsRunning == 0 && len(scriptsPending) > 0: + case softwareRunning == 0 && len(scriptsPending) > 0: // enqueue scripts for _, script := range scriptsPending { if script.ScriptContentID == nil { @@ -323,7 +321,7 @@ func (svc *Service) SetupExperienceNextStep(ctx context.Context, host *fleet.Hos return false, ctxerr.Wrap(ctx, err, "updating setup experience script execution id") } } - case installersRunning == 0 && appsRunning == 0 && scriptsRunning == 0: + case softwareRunning == 0 && scriptsRunning == 0: // finished return true, nil } diff --git a/ee/server/service/setup_experience_test.go b/ee/server/service/setup_experience_test.go index 432c35852c..4b753b3685 100644 --- a/ee/server/service/setup_experience_test.go +++ b/ee/server/service/setup_experience_test.go @@ -52,7 +52,7 @@ func TestSetupExperienceNextStep(t *testing.T) { } var mockListSetupExperience []*fleet.SetupExperienceStatusResult - ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error) { + ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hostUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) { return mockListSetupExperience, nil } diff --git a/server/datastore/mysql/setup_experience.go b/server/datastore/mysql/setup_experience.go index 8804699f09..a1d20c391f 100644 --- a/server/datastore/mysql/setup_experience.go +++ b/server/datastore/mysql/setup_experience.go @@ -97,21 +97,29 @@ WHERE host_uuid = ? AND %s` stmtClearSetupStatus = fmt.Sprintf(stmtClearSetupStatus, "TRUE") } - // stmtSoftwareInstallers query currently supports installers for macOS and Linux. - stmtSoftwareInstallers := ` -INSERT INTO setup_experience_status_results ( - host_uuid, - name, - status, - software_installer_id -) SELECT - ?, - st.name, - 'pending', - si.id + // Build combined software query (installers + VPP apps) before the transaction. + fleetPlatform := fleet.PlatformFromHost(hostPlatformLike) + + var softwareUnionParts []string + var softwareArgs []any + + includeSoftwareInstallers := fleetPlatform != "ios" && fleetPlatform != "ipados" + includeVPPApps := fleetPlatform == "darwin" || fleetPlatform == "ios" || fleetPlatform == "ipados" + + if includeSoftwareInstallers { + installerSelect := ` +SELECT + ? AS host_uuid, + st.name AS name, + 'pending' AS status, + si.id AS software_installer_id, + NULL AS vpp_app_team_id, + COALESCE(stdn.display_name, st.name) AS sort_name FROM software_installers si INNER JOIN software_titles st ON si.title_id = st.id +LEFT JOIN software_title_display_names stdn + ON stdn.software_title_id = st.id AND stdn.team_id = ? WHERE install_during_setup = true AND global_or_team_id = ? AND si.is_active = TRUE @@ -138,41 +146,66 @@ AND ( ) ) ) -AND %s ORDER BY st.name ASC -` - if resetFailedSetupSteps { - stmtSoftwareInstallers = fmt.Sprintf(stmtSoftwareInstallers, "si.id NOT IN (SELECT software_installer_id FROM setup_experience_status_results WHERE host_uuid = ? AND status = 'success' AND software_installer_id IS NOT NULL)") - } else { - stmtSoftwareInstallers = fmt.Sprintf(stmtSoftwareInstallers, "TRUE") +AND %s` + if resetFailedSetupSteps { + installerSelect = fmt.Sprintf(installerSelect, "si.id NOT IN (SELECT software_installer_id FROM setup_experience_status_results WHERE host_uuid = ? AND status = 'success' AND software_installer_id IS NOT NULL)") + } else { + installerSelect = fmt.Sprintf(installerSelect, "TRUE") + } + softwareUnionParts = append(softwareUnionParts, installerSelect) + softwareArgs = append(softwareArgs, hostUUID, teamID, teamID, fleetPlatform, hostPlatformLike, hostPlatformLike) + if resetFailedSetupSteps { + softwareArgs = append(softwareArgs, hostUUID) + } } - stmtVPPApps := ` -INSERT INTO setup_experience_status_results ( - host_uuid, - name, - status, - vpp_app_team_id -) SELECT - ?, - st.name, - 'pending', - vat.id + if includeVPPApps { + vppSelect := ` +SELECT + ? AS host_uuid, + st.name AS name, + 'pending' AS status, + NULL AS software_installer_id, + vat.id AS vpp_app_team_id, + COALESCE(stdn.display_name, st.name) AS sort_name FROM vpp_apps va INNER JOIN vpp_apps_teams vat ON vat.adam_id = va.adam_id AND vat.platform = va.platform INNER JOIN software_titles st ON va.title_id = st.id +LEFT JOIN software_title_display_names stdn + ON stdn.software_title_id = st.id AND stdn.team_id = ? WHERE vat.install_during_setup = true AND vat.global_or_team_id = ? AND va.platform = ? -AND %s -ORDER BY st.name ASC -` - if resetFailedSetupSteps { - stmtVPPApps = fmt.Sprintf(stmtVPPApps, "vat.id NOT IN (SELECT vpp_app_team_id FROM setup_experience_status_results WHERE host_uuid = ? AND status = 'success' AND vpp_app_team_id IS NOT NULL)") - } else { - stmtVPPApps = fmt.Sprintf(stmtVPPApps, "TRUE") +AND %s` + if resetFailedSetupSteps { + vppSelect = fmt.Sprintf(vppSelect, "vat.id NOT IN (SELECT vpp_app_team_id FROM setup_experience_status_results WHERE host_uuid = ? AND status = 'success' AND vpp_app_team_id IS NOT NULL)") + } else { + vppSelect = fmt.Sprintf(vppSelect, "TRUE") + } + softwareUnionParts = append(softwareUnionParts, vppSelect) + softwareArgs = append(softwareArgs, hostUUID, teamID, teamID, fleetPlatform) + if resetFailedSetupSteps { + softwareArgs = append(softwareArgs, hostUUID) + } + } + + var stmtSoftwareCombined string + if len(softwareUnionParts) > 0 { + stmtSoftwareCombined = fmt.Sprintf(` +INSERT INTO setup_experience_status_results ( + host_uuid, + name, + status, + software_installer_id, + vpp_app_team_id +) +SELECT host_uuid, name, status, software_installer_id, vpp_app_team_id FROM ( + %s +) AS combined +ORDER BY sort_name ASC, COALESCE(software_installer_id, vpp_app_team_id, 0)`, strings.Join(softwareUnionParts, " UNION ALL ")) } stmtSetupScripts := ` @@ -198,37 +231,15 @@ WHERE global_or_team_id = ?` return ctxerr.Wrap(ctx, err, "removing stale setup experience entries") } - // Software installers - fleetPlatform := fleet.PlatformFromHost(hostPlatformLike) - args := []any{hostUUID, teamID, fleetPlatform, hostPlatformLike, hostPlatformLike} - if resetFailedSetupSteps { - args = append(args, hostUUID) - } - if fleetPlatform != "ios" && fleetPlatform != "ipados" { - res, err := tx.ExecContext(ctx, stmtSoftwareInstallers, args...) + // Combined software (installers + VPP apps) + if stmtSoftwareCombined != "" { + res, err := tx.ExecContext(ctx, stmtSoftwareCombined, softwareArgs...) if err != nil { - return ctxerr.Wrap(ctx, err, "inserting setup experience software installers") + return ctxerr.Wrap(ctx, err, "inserting setup experience software items") } inserts, err := res.RowsAffected() if err != nil { - return ctxerr.Wrap(ctx, err, "retrieving number of inserted software installers") - } - totalInsertions += uint(inserts) // nolint: gosec - } - - // VPP apps - if fleetPlatform == "darwin" || fleetPlatform == "ios" || fleetPlatform == "ipados" { - args := []any{hostUUID, teamID, fleetPlatform} - if resetFailedSetupSteps { - args = append(args, hostUUID) - } - res, err := tx.ExecContext(ctx, stmtVPPApps, args...) - if err != nil { - return ctxerr.Wrap(ctx, err, "inserting setup experience vpp apps") - } - inserts, err := res.RowsAffected() - if err != nil { - return ctxerr.Wrap(ctx, err, "retrieving number of inserted vpp apps") + return ctxerr.Wrap(ctx, err, "retrieving number of inserted software items") } totalInsertions += uint(inserts) // nolint: gosec } @@ -531,7 +542,7 @@ func questionMarks(number int) string { return strings.Join(slices.Repeat([]string{"?"}, number), ",") } -func (ds *Datastore) ListSetupExperienceResultsByHostUUID(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error) { +func (ds *Datastore) ListSetupExperienceResultsByHostUUID(ctx context.Context, hostUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) { const stmt = ` SELECT sesr.id, @@ -571,6 +582,7 @@ LEFT JOIN host_script_results hsr ON hsr.execution_id = sesr.script_execution_id LEFT JOIN vpp_apps_teams vat ON vat.id = sesr.vpp_app_team_id LEFT JOIN vpp_apps va ON vat.adam_id = va.adam_id AND vat.platform = va.platform WHERE host_uuid = ? +ORDER BY sesr.id ` var results []*fleet.SetupExperienceStatusResult if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, stmt, hostUUID); err != nil { @@ -588,28 +600,12 @@ WHERE host_uuid = ? // load custom display name and custom icon for the software installers, if any if len(titleIDs) > 0 { - // NOTE: as documented in fleet.HostUUIDForSetupExperience, the setup experience "host_uuid" - // is NOT always the host.uuid (on Windows and Linux, specifically). So if the host's team is - // not found, we simply don't load the icons and display names, anyway we only need those - // on macOS currently as it's the only place where the setup experience UI is shown. - - // we need the host's team to load the custom icons and display names - const hostTeam = `SELECT team_id FROM hosts WHERE uuid = ? LIMIT 1` - var hostTeamID sql.Null[uint] - if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostTeamID, hostTeam, hostUUID); err != nil { - if errors.Is(err, sql.ErrNoRows) { - // host not found, skip loading icons and display names - return results, nil - } - return nil, ctxerr.Wrap(ctx, err, "get host team ID for setup experience results") - } - - icons, err := ds.GetSoftwareIconsByTeamAndTitleIds(ctx, hostTeamID.V, titleIDs) + icons, err := ds.GetSoftwareIconsByTeamAndTitleIds(ctx, teamID, titleIDs) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get software icons by team and title IDs") } - displayNames, err := ds.getDisplayNamesByTeamAndTitleIds(ctx, hostTeamID.V, titleIDs) + displayNames, err := ds.getDisplayNamesByTeamAndTitleIds(ctx, teamID, titleIDs) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get software display names by team and title IDs") } diff --git a/server/datastore/mysql/setup_experience_test.go b/server/datastore/mysql/setup_experience_test.go index 027c6529cd..41a7a6e0ef 100644 --- a/server/datastore/mysql/setup_experience_test.go +++ b/server/datastore/mysql/setup_experience_test.go @@ -33,6 +33,7 @@ func TestSetupExperience(t *testing.T) { {"TestGetSetupExperienceScriptByID", testGetSetupExperienceScriptByID}, {"TestUpdateSetupExperienceScriptWhileEnqueued", testUpdateSetupExperienceScriptWhileEnqueued}, {"TestEnqueueSetupExperienceItemsWindows", testEnqueueSetupExperienceItemsWindows}, + {"EnqueueSetupExperienceItemsWithDisplayName", testEnqueueSetupExperienceItemsWithDisplayName}, } for _, c := range cases { @@ -666,6 +667,271 @@ func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) { } } +// testEnqueueSetupExperienceItemsWithDisplayName verifies that when a custom +// display name is set for a software title, the enqueue function uses it to +// determine the alphabetical install order (instead of the default +// software_titles.name). This ordering also orders the steps in the +// setup experience UI. The UI uses the display name if it is set, and +// the name if not. +func testEnqueueSetupExperienceItemsWithDisplayName(t *testing.T, ds *Datastore) { + ctx := context.Background() + test.CreateInsertGlobalVPPToken(t, ds) + + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team_display_name_test"}) + require.NoError(t, err) + + user := test.NewUser(t, ds, "DisplayNameUser", "displaynameuser@example.com", true) + + // Create two software installers with titles that sort in a known order: + // "AAA_Software" < "ZZZ_Software" (alphabetically) + // We will then assign custom display names that invert this order: + // "AAA_Software" → "Zulu Custom" + // "ZZZ_Software" → "Alpha Custom" + // After enqueue, the rows ordered by id (insert order) should reflect + // the display-name alphabetical order: + // id=N → ZZZ_Software (display name "Alpha Custom", sorts first) + // id=N+1 → AAA_Software (display name "Zulu Custom", sorts second) + // But the `name` column still stores the original st.name. + // Note that the setup experience UI will also follow this ordering; + // it will display "Alpha Custom" and then "Zulu Custom". + + tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello1"), t.TempDir) + require.NoError(t, err) + installerID1, titleID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install1", + UninstallScript: "uninstall1", + InstallerFile: tfr1, + StorageID: "storage_dn_1", + Filename: "file_dn_1", + Title: "AAA_Software", + Version: "1.0", + Source: "apps", + UserID: user.ID, + TeamID: &team.ID, + Platform: string(fleet.MacOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + tfr2, err := fleet.NewTempFileReader(strings.NewReader("hello2"), t.TempDir) + require.NoError(t, err) + installerID2, titleID2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install2", + UninstallScript: "uninstall2", + InstallerFile: tfr2, + StorageID: "storage_dn_2", + Filename: "file_dn_2", + Title: "ZZZ_Software", + Version: "2.0", + Source: "apps", + UserID: user.ID, + TeamID: &team.ID, + Platform: string(fleet.MacOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + // Mark both installers for setup experience + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id IN (?, ?)", installerID1, installerID2) + return err + }) + + // Set custom display names that invert the alphabetical order + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + if err := updateSoftwareTitleDisplayName(ctx, q, &team.ID, titleID1, "Zulu Custom"); err != nil { + return err + } + return updateSoftwareTitleDisplayName(ctx, q, &team.ID, titleID2, "Alpha Custom") + }) + + // Create two VPP apps with titles that sort in a known order, then invert with display names. + vppApp1 := &fleet.VPPApp{ + Name: "AAA_VPP_App", + BundleIdentifier: "com.aaa.vpp", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "dn_adam_1", Platform: fleet.MacOSPlatform}}, + } + vpp1, err := ds.InsertVPPAppWithTeam(ctx, vppApp1, &team.ID) + require.NoError(t, err) + + vppApp2 := &fleet.VPPApp{ + Name: "ZZZ_VPP_App", + BundleIdentifier: "com.zzz.vpp", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "dn_adam_2", Platform: fleet.MacOSPlatform}}, + } + vpp2, err := ds.InsertVPPAppWithTeam(ctx, vppApp2, &team.ID) + require.NoError(t, err) + + // Mark both VPP apps for setup experience + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, "UPDATE vpp_apps_teams SET install_during_setup = 1 WHERE adam_id IN (?, ?)", vpp1.AdamID, vpp2.AdamID) + return err + }) + + // Set custom display names for VPP apps (invert order) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + if err := updateSoftwareTitleDisplayName(ctx, q, &team.ID, vppApp1.TitleID, "Zulu VPP Custom"); err != nil { + return err + } + return updateSoftwareTitleDisplayName(ctx, q, &team.ID, vppApp2.TitleID, "Alpha VPP Custom") + }) + + // Create a host assigned to the team and enqueue setup experience. + // The host must be on the team so that ListSetupExperienceResultsByHostUUID + // can look up the team's display names. + hostUUID := "host-display-name-test-" + uuid.NewString() + host1, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "macos-dn-test", + OsqueryHostID: ptr.String("osquery-dn-test"), + NodeKey: ptr.String("node-key-dn-test"), + UUID: hostUUID, + Platform: "darwin", + HardwareSerial: "dn-serial-1", + }) + require.NoError(t, err) + err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host1.ID})) + require.NoError(t, err) + + anythingEnqueued, err := ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostUUID, team.ID) + require.NoError(t, err) + require.True(t, anythingEnqueued) + + // --- Verify all rows are globally ordered by display name --- + // enqueueSetupExperienceItems inserts software (installers and VPP apps) + // together in a single query ordered by COALESCE(display_name, st.name), + // so the auto-incremented id reflects the global display-name order. + // ListSetupExperienceResultsByHostUUID returns rows ordered by sesr.id, + // preserving that insert order. Scripts are inserted last. + // + // Expected order (all software globally sorted by display name): + // 0. ZZZ_Software (installer, display name "Alpha Custom") + // 1. ZZZ_VPP_App (VPP app, display name "Alpha VPP Custom") + // 2. AAA_Software (installer, display name "Zulu Custom") + // 3. AAA_VPP_App (VPP app, display name "Zulu VPP Custom") + allResults, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID, team.ID) + require.NoError(t, err) + require.Len(t, allResults, 4, "expected 4 results total (2 installers + 2 VPP apps)") + + assert.Equal(t, "ZZZ_Software", allResults[0].Name, "row 0: ZZZ_Software (display name 'Alpha Custom')") + assert.Equal(t, "Alpha Custom", allResults[0].DisplayName, "row 0: display name should be 'Alpha Custom'") + assert.NotNil(t, allResults[0].SoftwareInstallerID, "row 0: should be a software installer") + + assert.Equal(t, "ZZZ_VPP_App", allResults[1].Name, "row 1: ZZZ_VPP_App (display name 'Alpha VPP Custom')") + assert.Equal(t, "Alpha VPP Custom", allResults[1].DisplayName, "row 1: display name should be 'Alpha VPP Custom'") + assert.NotNil(t, allResults[1].VPPAppTeamID, "row 1: should be a VPP app") + assert.Less(t, allResults[0].ID, allResults[1].ID) + + assert.Equal(t, "AAA_Software", allResults[2].Name, "row 2: AAA_Software (display name 'Zulu Custom')") + assert.Equal(t, "Zulu Custom", allResults[2].DisplayName, "row 2: display name should be 'Zulu Custom'") + assert.NotNil(t, allResults[2].SoftwareInstallerID, "row 2: should be a software installer") + assert.Less(t, allResults[1].ID, allResults[2].ID) + + assert.Equal(t, "AAA_VPP_App", allResults[3].Name, "row 3: AAA_VPP_App (display name 'Zulu VPP Custom')") + assert.Equal(t, "Zulu VPP Custom", allResults[3].DisplayName, "row 3: display name should be 'Zulu VPP Custom'") + assert.NotNil(t, allResults[3].VPPAppTeamID, "row 3: should be a VPP app") + + // --- Verify fallback: no display name → order uses st.name --- + // Add a third installer and a third VPP app, both without custom display + // names, then re-enqueue for a new host and verify the globally + // interleaved order. Items without a display name fall back to st.name. + tfr3, err := fleet.NewTempFileReader(strings.NewReader("hello3"), t.TempDir) + require.NoError(t, err) + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install3", + UninstallScript: "uninstall3", + InstallerFile: tfr3, + StorageID: "storage_dn_3", + Filename: "file_dn_3", + Title: "MMM_NoDisplayName", + Version: "3.0", + Source: "apps", + UserID: user.ID, + TeamID: &team.ID, + Platform: string(fleet.MacOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, "UPDATE software_installers SET install_during_setup = 1 WHERE id NOT IN (?, ?)", installerID1, installerID2) + return err + }) + + vppApp3 := &fleet.VPPApp{ + Name: "MMM_VPP_NoDisplayName", + BundleIdentifier: "com.mmm.vpp", + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "dn_adam_3", Platform: fleet.MacOSPlatform}}, + } + vpp3, err := ds.InsertVPPAppWithTeam(ctx, vppApp3, &team.ID) + require.NoError(t, err) + + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, "UPDATE vpp_apps_teams SET install_during_setup = 1 WHERE adam_id = ?", vpp3.AdamID) + return err + }) + + // Re-enqueue for a new host (also on the team) to pick up all installers and VPP apps. + hostUUID2 := "host-display-name-fallback-" + uuid.NewString() + host2, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "macos-dn-test-2", + OsqueryHostID: ptr.String("osquery-dn-test-2"), + NodeKey: ptr.String("node-key-dn-test-2"), + UUID: hostUUID2, + Platform: "darwin", + HardwareSerial: "dn-serial-2", + }) + require.NoError(t, err) + err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host2.ID})) + require.NoError(t, err) + + anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", hostUUID2, team.ID) + require.NoError(t, err) + require.True(t, anythingEnqueued) + + // Verify the globally interleaved order across installers and VPP apps. + // The combined INSERT in enqueueSetupExperienceItems orders by + // COALESCE(display_name, st.name), and ListSetupExperienceResultsByHostUUID + // returns rows ordered by sesr.id (i.e. insert order). + // + // Expected global order (sorted by COALESCE(display_name, st.name)): + // 0. ZZZ_Software (installer, display name "Alpha Custom") + // 1. ZZZ_VPP_App (VPP app, display name "Alpha VPP Custom") + // 2. MMM_NoDisplayName (installer, no display name → falls back to st.name) + // 3. MMM_VPP_NoDisplayName (VPP app, no display name → falls back to st.name) + // 4. AAA_Software (installer, display name "Zulu Custom") + // 5. AAA_VPP_App (VPP app, display name "Zulu VPP Custom") + fallbackResults, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID2, team.ID) + require.NoError(t, err) + require.Len(t, fallbackResults, 6, "expected 6 results total (3 installers + 3 VPP apps)") + + assert.Equal(t, "ZZZ_Software", fallbackResults[0].Name, "row 0: ZZZ_Software (display name 'Alpha Custom')") + assert.Equal(t, "Alpha Custom", fallbackResults[0].DisplayName) + assert.NotNil(t, fallbackResults[0].SoftwareInstallerID) + + assert.Equal(t, "ZZZ_VPP_App", fallbackResults[1].Name, "row 1: ZZZ_VPP_App (display name 'Alpha VPP Custom')") + assert.Equal(t, "Alpha VPP Custom", fallbackResults[1].DisplayName) + assert.NotNil(t, fallbackResults[1].VPPAppTeamID) + assert.Less(t, fallbackResults[0].ID, fallbackResults[1].ID) + + assert.Equal(t, "MMM_NoDisplayName", fallbackResults[2].Name, "row 2: MMM_NoDisplayName (no display name, falls back to st.name)") + assert.Empty(t, fallbackResults[2].DisplayName) + assert.NotNil(t, fallbackResults[2].SoftwareInstallerID) + assert.Less(t, fallbackResults[1].ID, fallbackResults[2].ID) + + assert.Equal(t, "MMM_VPP_NoDisplayName", fallbackResults[3].Name, "row 3: MMM_VPP_NoDisplayName (no display name, falls back to st.name)") + assert.Empty(t, fallbackResults[3].DisplayName) + assert.NotNil(t, fallbackResults[3].VPPAppTeamID) + + assert.Equal(t, "AAA_Software", fallbackResults[4].Name, "row 4: AAA_Software (display name 'Zulu Custom')") + assert.Equal(t, "Zulu Custom", fallbackResults[4].DisplayName) + assert.NotNil(t, fallbackResults[4].SoftwareInstallerID) + + assert.Equal(t, "AAA_VPP_App", fallbackResults[5].Name, "row 5: AAA_VPP_App (display name 'Zulu VPP Custom')") + assert.Equal(t, "Zulu VPP Custom", fallbackResults[5].DisplayName) + assert.NotNil(t, fallbackResults[5].VPPAppTeamID) + assert.Less(t, fallbackResults[4].ID, fallbackResults[5].ID) +} + type setupExperienceInsertTestRows struct { HostUUID string `db:"host_uuid"` Name string `db:"name"` @@ -1242,7 +1508,7 @@ func testSetupExperienceStatusResults(t *testing.T, ds *Datastore) { insertSetupExperienceStatusResult(r) } - res, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID) + res, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID, 0) require.NoError(t, err) require.Len(t, res, 3) for i, s := range expRes { @@ -1439,14 +1705,14 @@ func testUpdateSetupExperienceScriptWhileEnqueued(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, anythingEnqueued) - host1OriginalItems, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam1UUID) + host1OriginalItems, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam1UUID, team1.ID) require.NoError(t, err) require.Len(t, host1OriginalItems, 1) require.Equal(t, fleet.SetupExperienceStatusPending, host1OriginalItems[0].Status) require.NotNil(t, host1OriginalItems[0].SetupExperienceScriptID) require.Equal(t, team1OriginalScript.ID, *host1OriginalItems[0].SetupExperienceScriptID) - host2OriginalItems, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam2UUID) + host2OriginalItems, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam2UUID, team2.ID) require.NoError(t, err) require.Len(t, host2OriginalItems, 1) require.Equal(t, fleet.SetupExperienceStatusPending, host2OriginalItems[0].Status) @@ -1463,13 +1729,13 @@ func testUpdateSetupExperienceScriptWhileEnqueued(t *testing.T, ds *Datastore) { require.Equal(t, team1OriginalScript.ScriptContentID, team1UpdatedScript.ScriptContentID) require.Equal(t, team1OriginalScript.ID, team1UpdatedScript.ID) - host1NewItems, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam1UUID) + host1NewItems, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam1UUID, team1.ID) require.NoError(t, err) require.Len(t, host1NewItems, 1) require.Equal(t, team1OriginalScript.ID, *host1NewItems[0].SetupExperienceScriptID) // Should not have perturbed Host 2's enqueued execution either - host2NewItems, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam2UUID) + host2NewItems, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam2UUID, team2.ID) require.NoError(t, err) require.Len(t, host2NewItems, 1) require.Equal(t, team2OriginalScript.ID, *host2NewItems[0].SetupExperienceScriptID) @@ -1484,12 +1750,12 @@ func testUpdateSetupExperienceScriptWhileEnqueued(t *testing.T, ds *Datastore) { require.NotEqual(t, team1OriginalScript.ScriptContentID, team1UpdatedScript.ScriptContentID) require.NotEqual(t, team1OriginalScript.ID, team1UpdatedScript.ID) - host1NewItems, err = ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam1UUID) + host1NewItems, err = ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam1UUID, team1.ID) require.NoError(t, err) require.Len(t, host1NewItems, 0) // Should not have affected host 2's enqueued execution - host2NewItems, err = ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam2UUID) + host2NewItems, err = ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam2UUID, team2.ID) require.NoError(t, err) require.Len(t, host2NewItems, 1) require.Equal(t, team2OriginalScript.ID, *host2NewItems[0].SetupExperienceScriptID) @@ -1499,7 +1765,7 @@ func testUpdateSetupExperienceScriptWhileEnqueued(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, anythingEnqueued) - host1NewItems, err = ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam1UUID) + host1NewItems, err = ds.ListSetupExperienceResultsByHostUUID(ctx, hostTeam1UUID, team1.ID) require.NoError(t, err) require.Len(t, host1NewItems, 1) require.Equal(t, team1UpdatedScript.ID, *host1NewItems[0].SetupExperienceScriptID) diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 46799b33fb..af67054fc7 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -1750,7 +1750,7 @@ func testBatchSetSoftwareInstallersSetupExperienceSideEffects(t *testing.T, ds * _, err = ds.EnqueueSetupExperienceItems(ctx, "darwin", "darwin", host1.UUID, *host1.TeamID) require.NoError(t, err) - statuses, err := ds.ListSetupExperienceResultsByHostUUID(ctx, host1.UUID) + statuses, err := ds.ListSetupExperienceResultsByHostUUID(ctx, host1.UUID, team.ID) require.NoError(t, err) require.Len(t, statuses, 2) @@ -1800,7 +1800,7 @@ func testBatchSetSoftwareInstallersSetupExperienceSideEffects(t *testing.T, ds * }) require.NoError(t, err) - statuses, err = ds.ListSetupExperienceResultsByHostUUID(ctx, host1.UUID) + statuses, err = ds.ListSetupExperienceResultsByHostUUID(ctx, host1.UUID, team.ID) require.NoError(t, err) require.Len(t, statuses, 2) @@ -1845,7 +1845,7 @@ func testBatchSetSoftwareInstallersSetupExperienceSideEffects(t *testing.T, ds * require.NoError(t, err) - statuses, err = ds.ListSetupExperienceResultsByHostUUID(ctx, host1.UUID) + statuses, err = ds.ListSetupExperienceResultsByHostUUID(ctx, host1.UUID, team.ID) require.NoError(t, err) require.Len(t, statuses, 2) @@ -1917,7 +1917,7 @@ func testBatchSetSoftwareInstallersSetupExperienceSideEffects(t *testing.T, ds * }) require.NoError(t, err) - statuses, err = ds.ListSetupExperienceResultsByHostUUID(ctx, host1.UUID) + statuses, err = ds.ListSetupExperienceResultsByHostUUID(ctx, host1.UUID, team.ID) require.NoError(t, err) require.Len(t, statuses, 2) diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index eb4a2a61a4..6e8bb9147f 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2405,7 +2405,7 @@ type Datastore interface { TeamIDsWithSetupExperienceIdPEnabled(ctx context.Context) ([]uint, error) // ListSetupExperienceResultsByHostUUID lists the setup experience results for a host by its UUID. - ListSetupExperienceResultsByHostUUID(ctx context.Context, hostUUID string) ([]*SetupExperienceStatusResult, error) + ListSetupExperienceResultsByHostUUID(ctx context.Context, hostUUID string, teamID uint) ([]*SetupExperienceStatusResult, error) // UpdateSetupExperienceStatusResult updates the given setup experience status result. UpdateSetupExperienceStatusResult(ctx context.Context, status *SetupExperienceStatusResult) error diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 15528f700f..2b45b8c0b5 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1531,7 +1531,7 @@ type GetTeamsWithInstallerByHashFunc func(ctx context.Context, sha256 string, ur type TeamIDsWithSetupExperienceIdPEnabledFunc func(ctx context.Context) ([]uint, error) -type ListSetupExperienceResultsByHostUUIDFunc func(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error) +type ListSetupExperienceResultsByHostUUIDFunc func(ctx context.Context, hostUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) type UpdateSetupExperienceStatusResultFunc func(ctx context.Context, status *fleet.SetupExperienceStatusResult) error @@ -9885,11 +9885,11 @@ func (s *DataStore) TeamIDsWithSetupExperienceIdPEnabled(ctx context.Context) ([ return s.TeamIDsWithSetupExperienceIdPEnabledFunc(ctx) } -func (s *DataStore) ListSetupExperienceResultsByHostUUID(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error) { +func (s *DataStore) ListSetupExperienceResultsByHostUUID(ctx context.Context, hostUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) { s.mu.Lock() s.ListSetupExperienceResultsByHostUUIDFuncInvoked = true s.mu.Unlock() - return s.ListSetupExperienceResultsByHostUUIDFunc(ctx, hostUUID) + return s.ListSetupExperienceResultsByHostUUIDFunc(ctx, hostUUID, teamID) } func (s *DataStore) UpdateSetupExperienceStatusResult(ctx context.Context, status *fleet.SetupExperienceStatusResult) error { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 999bdf6139..ec822bb2e6 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -22896,7 +22896,7 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() // so pull it out manually ubuntuHostUUID, err := fleet.HostUUIDForSetupExperience(ubuntuHost) require.NoError(t, err) - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID, team.ID) require.NoError(t, err) require.Len(t, results, 2) executionIDs := make(map[string]string) // installer name -> install execution ID @@ -22905,10 +22905,10 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID } } - require.NotEmpty(t, executionIDs["vim"]) + require.Empty(t, executionIDs["vim"]) // hasn't run yet due to alphanumeric ordering require.NotEmpty(t, executionIDs["test.tar.gz"]) - // Record a result for vim. + // Record a result for test.tar.gz. s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( fmt.Sprintf(`{ "orbit_node_key": %q, @@ -22937,6 +22937,16 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name) require.EqualValues(t, "running", getDeviceStatusResponse.Results.Software[1].Status) + // get execution ID for vim + results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID, team.ID) + require.NoError(t, err) + require.Len(t, results, 2) + for _, result := range results { + if result.HostSoftwareInstallsExecutionID != nil { + executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID + } + } + // Record a result for vim s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( fmt.Sprintf(`{ @@ -23021,7 +23031,7 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() // so pull it out manually fedoraHostUUID, err := fleet.HostUUIDForSetupExperience(fedoraHost) require.NoError(t, err) - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, fedoraHostUUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, fedoraHostUUID, team.ID) require.NoError(t, err) require.Len(t, results, 2) executionIDs := make(map[string]string) // installer name -> install execution ID @@ -23031,9 +23041,9 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() } } require.NotEmpty(t, executionIDs["ruby"]) - require.NotEmpty(t, executionIDs["test.tar.gz"]) + require.Empty(t, executionIDs["test.tar.gz"]) // hasn't run yet due to alphanumeric ordering - // Record a result for vim. + // Record a result for ruby. s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( fmt.Sprintf(`{ "orbit_node_key": %q, @@ -23062,6 +23072,16 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[1].Name) require.EqualValues(t, "running", getDeviceStatusResponse.Results.Software[1].Status) + // get exec ID for test.tar.gz + results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, fedoraHostUUID, team.ID) + require.NoError(t, err) + require.Len(t, results, 2) + for _, result := range results { + if result.HostSoftwareInstallsExecutionID != nil { + executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID + } + } + // Record a result for test.tar.gz. s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( fmt.Sprintf(`{ @@ -23130,7 +23150,7 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() // so pull it out manually ubuntuHostUUID, err := fleet.HostUUIDForSetupExperience(ubuntuHost) require.NoError(t, err) - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID, team.ID) require.NoError(t, err) require.Len(t, results, 2) executionIDs := make(map[string]string) // installer name -> install execution ID @@ -23139,7 +23159,7 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID } } - require.NotEmpty(t, executionIDs["vim"]) + require.Empty(t, executionIDs["vim"]) // hasn't run yet due to alphanumeric ordering require.NotEmpty(t, executionIDs["test.tar.gz"]) // Cancel the software install for test.tar.gz. @@ -23158,9 +23178,19 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name }) require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name) - require.EqualValues(t, "failure", getDeviceStatusResponse.Results.Software[0].Status) + require.Equal(t, fleet.SetupExperienceStatusFailure, getDeviceStatusResponse.Results.Software[0].Status) require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name) - require.EqualValues(t, "running", getDeviceStatusResponse.Results.Software[1].Status) + require.Equal(t, fleet.SetupExperienceStatusPending, getDeviceStatusResponse.Results.Software[1].Status) + + // Get execution ID for vim + results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID, team.ID) + require.NoError(t, err) + require.Len(t, results, 2) + for _, result := range results { + if result.HostSoftwareInstallsExecutionID != nil { + executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID + } + } // Record a result for vim. s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( @@ -23187,9 +23217,9 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name }) require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name) - require.EqualValues(t, "failure", getDeviceStatusResponse.Results.Software[0].Status) + require.Equal(t, fleet.SetupExperienceStatusFailure, getDeviceStatusResponse.Results.Software[0].Status) require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name) - require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[1].Status) + require.Equal(t, fleet.SetupExperienceStatusSuccess, getDeviceStatusResponse.Results.Software[1].Status) }) t.Run("ubuntu-software-edited-during-se", func(t *testing.T) { @@ -23224,7 +23254,7 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() // so pull it out manually ubuntuHostUUID, err := fleet.HostUUIDForSetupExperience(ubuntuHost) require.NoError(t, err) - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID, team.ID) require.NoError(t, err) require.Len(t, results, 2) executionIDs := make(map[string]string) // installer name -> install execution ID @@ -23233,13 +23263,39 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID } } - require.NotEmpty(t, executionIDs["vim"]) + require.Empty(t, executionIDs["vim"]) // hasn't run yet due to alphanumeric ordering require.NotEmpty(t, executionIDs["test.tar.gz"]) + // Record a result for test.tar.gz. + s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( + fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "install_script_exit_code": 0, + "install_script_output": "ok" + }`, + *ubuntuHost.OrbitNodeKey, + executionIDs["test.tar.gz"], + ), + ), http.StatusNoContent) + + // Get status of the "Setup experience" for the Ubuntu host. + getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{} + s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status", + getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse, + ) + require.NoError(t, getDeviceStatusResponse.Err) + require.NotNil(t, getDeviceStatusResponse.Results) + require.Len(t, getDeviceStatusResponse.Results.Software, 2) + require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name) + require.Equal(t, fleet.SetupExperienceStatusSuccess, getDeviceStatusResponse.Results.Software[0].Status) + require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name) + require.Equal(t, fleet.SetupExperienceStatusRunning, getDeviceStatusResponse.Results.Software[1].Status) + // Modify the vim installer, which should cause the setup experience item to fail. // update should succeed s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ - SelfService: ptr.Bool(true), + SelfService: new(true), InstallScript: ptr.String("some updated install script"), PreInstallQuery: ptr.String("some new pre install query"), PostInstallScript: ptr.String("some new post install script"), @@ -23248,7 +23304,6 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() TeamID: &team.ID, }, http.StatusOK, "") - // Get status of the "Setup experience" for the Ubuntu host. getDeviceStatusResponse = getDeviceSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/v1/fleet/device/fleet-desktop-token-ubuntu/setup_experience/status", getDeviceSetupExperienceStatusRequest{}, http.StatusOK, &getDeviceStatusResponse, @@ -23260,9 +23315,21 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name }) require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name) - require.EqualValues(t, "running", getDeviceStatusResponse.Results.Software[0].Status) + require.Equal(t, fleet.SetupExperienceStatusSuccess, getDeviceStatusResponse.Results.Software[0].Status) require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name) - require.EqualValues(t, "failure", getDeviceStatusResponse.Results.Software[1].Status) + require.Equal(t, fleet.SetupExperienceStatusFailure, getDeviceStatusResponse.Results.Software[1].Status) + + // Get execution ID for vim + results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID, team.ID) + require.NoError(t, err) + require.Len(t, results, 2) + for _, result := range results { + if result.HostSoftwareInstallsExecutionID != nil { + executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID + } + } + require.NotEmpty(t, executionIDs["vim"]) + require.NotEmpty(t, executionIDs["test.tar.gz"]) s.lastActivityOfTypeMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{ "host_id": %d, @@ -23303,9 +23370,9 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() return getDeviceStatusResponse.Results.Software[i].Name < getDeviceStatusResponse.Results.Software[j].Name }) require.Equal(t, "test.tar.gz", getDeviceStatusResponse.Results.Software[0].Name) - require.EqualValues(t, "success", getDeviceStatusResponse.Results.Software[0].Status) + require.Equal(t, fleet.SetupExperienceStatusSuccess, getDeviceStatusResponse.Results.Software[0].Status) require.Equal(t, "vim", getDeviceStatusResponse.Results.Software[1].Name) - require.EqualValues(t, "failure", getDeviceStatusResponse.Results.Software[1].Status) + require.Equal(t, fleet.SetupExperienceStatusFailure, getDeviceStatusResponse.Results.Software[1].Status) }) // Transfer the Ubuntu host to "No team". @@ -23378,7 +23445,7 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftware() // so pull it out manually ubuntuHostUUID, err := fleet.HostUUIDForSetupExperience(ubuntuHost) require.NoError(t, err) - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID, team.ID) require.NoError(t, err) require.Len(t, results, 1) require.NotNil(t, results[0].HostSoftwareInstallsExecutionID) @@ -23512,7 +23579,7 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftwareWit // so pull it out manually ubuntuHostUUID, err := fleet.HostUUIDForSetupExperience(ubuntuHost) require.NoError(t, err) - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID, team.ID) require.NoError(t, err) require.Len(t, results, 2) executionIDs := make(map[string]string) // installer name -> install execution ID @@ -23521,10 +23588,10 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftwareWit executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID } } - require.NotEmpty(t, executionIDs["vim"]) + require.Empty(t, executionIDs["vim"]) // hasn't run yet due to alphanumeric ordering require.NotEmpty(t, executionIDs["test.tar.gz"]) - // Record a result for vim. + // Record a result for test.tar.gz. s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( fmt.Sprintf(`{ "orbit_node_key": %q, @@ -23553,6 +23620,16 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceLinuxWithSoftwareWit require.Equal(t, "vim", orbitRes.Results.Software[1].Name) require.EqualValues(t, "running", orbitRes.Results.Software[1].Status) + // Get execution ID for vim + results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID, team.ID) + require.NoError(t, err) + require.Len(t, results, 2) + for _, result := range results { + if result.HostSoftwareInstallsExecutionID != nil { + executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID + } + } + // Record a result for vim. s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( fmt.Sprintf(`{ @@ -23756,7 +23833,7 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftware( // so pull it out manually windowsHostUUID, err := fleet.HostUUIDForSetupExperience(windowsHost1) require.NoError(t, err) - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, windowsHostUUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, windowsHostUUID, team.ID) require.NoError(t, err) require.Len(t, results, 2) executionIDs := make(map[string]string) // installer name -> install execution ID @@ -23766,7 +23843,7 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftware( } } require.NotEmpty(t, executionIDs["Fleet osquery"]) - require.NotEmpty(t, executionIDs["Hello world"]) + require.Empty(t, executionIDs["Hello world"]) // hasn't run yet due to alphanumeric ordering // Record a result for Fleet osquery. s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( @@ -23797,6 +23874,18 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftware( require.Equal(t, "Hello world", getDeviceStatusResponse.Results.Software[1].Name) require.EqualValues(t, "running", getDeviceStatusResponse.Results.Software[1].Status) + // Get execution ID for Hello world + results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, windowsHostUUID, team.ID) + require.NoError(t, err) + require.Len(t, results, 2) + for _, result := range results { + if result.HostSoftwareInstallsExecutionID != nil { + executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID + } + } + require.NotEmpty(t, executionIDs["Fleet osquery"]) + require.NotEmpty(t, executionIDs["Hello world"]) + // Record a result for Hello world. s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( fmt.Sprintf(`{ @@ -23975,7 +24064,7 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftware( // so pull it out manually windowsHostUUID, err := fleet.HostUUIDForSetupExperience(windowsHost2) require.NoError(t, err) - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, windowsHostUUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, windowsHostUUID, team.ID) require.NoError(t, err) require.Len(t, results, 2) executionIDs := make(map[string]string) // installer name -> install execution ID @@ -23985,7 +24074,7 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftware( } } require.NotEmpty(t, executionIDs["Fleet osquery"]) - require.NotEmpty(t, executionIDs["Hello world"]) + require.Empty(t, executionIDs["Hello world"]) // hasn't run yet due to alphanumeric ordering // Record a failing result for Fleet osquery. s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( @@ -24016,6 +24105,18 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftware( require.Equal(t, "Hello world", getDeviceStatusResponse.Results.Software[1].Name) require.EqualValues(t, "running", getDeviceStatusResponse.Results.Software[1].Status) + // Get execution ID for Hello world + results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, windowsHostUUID, team.ID) + require.NoError(t, err) + require.Len(t, results, 2) + for _, result := range results { + if result.HostSoftwareInstallsExecutionID != nil { + executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID + } + } + require.NotEmpty(t, executionIDs["Fleet osquery"]) + require.NotEmpty(t, executionIDs["Hello world"]) + // Record a result for Hello world. s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( fmt.Sprintf(`{ @@ -24176,9 +24277,9 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftwareW // The setup_experience/status endpoint doesn't return the various IDs for executions, // so pull it out manually - ubuntuHostUUID, err := fleet.HostUUIDForSetupExperience(windowsHost) + windowsHostUUID, err := fleet.HostUUIDForSetupExperience(windowsHost) require.NoError(t, err) - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, ubuntuHostUUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, windowsHostUUID, team.ID) require.NoError(t, err) require.Len(t, results, 2) executionIDs := make(map[string]string) // installer name -> install execution ID @@ -24187,8 +24288,9 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftwareW executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID } } - require.NotEmpty(t, executionIDs["Fleet osquery"]) - require.NotEmpty(t, executionIDs["Hello world"]) + + require.NotEmpty(t, executionIDs["Fleet osquery"]) // has started running because it's first alphabetically + require.Empty(t, executionIDs["Hello world"]) // not running yet // Record a result for Fleet osquery. s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage( @@ -24203,7 +24305,7 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftwareW ), ), http.StatusNoContent) - // Again get status of the "Setup experience" for the Windos host. + // Again get status of the "Setup experience" for the Windows host. orbitRes = fleet.GetOrbitSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", fleet.GetOrbitSetupExperienceStatusRequest{OrbitNodeKey: *windowsHost.OrbitNodeKey}, http.StatusOK, &orbitRes, @@ -24211,9 +24313,16 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftwareW require.NotNil(t, orbitRes.Results) require.NotNil(t, orbitRes.Results) require.Len(t, orbitRes.Results.Software, 2) - sort.Slice(orbitRes.Results.Software, func(i, j int) bool { - return orbitRes.Results.Software[i].Name < orbitRes.Results.Software[j].Name - }) + + results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, windowsHostUUID, team.ID) + require.NoError(t, err) + require.Len(t, results, 2) + for _, result := range results { + if result.HostSoftwareInstallsExecutionID != nil { + executionIDs[result.Name] = *result.HostSoftwareInstallsExecutionID + } + } + require.Equal(t, "Fleet osquery", orbitRes.Results.Software[0].Name) require.EqualValues(t, "success", orbitRes.Results.Software[0].Status) require.Equal(t, "Hello world", orbitRes.Results.Software[1].Name) @@ -24232,7 +24341,7 @@ func (s *integrationEnterpriseTestSuite) TestSetupExperienceWindowsWithSoftwareW ), ), http.StatusNoContent) - // One last time get status of the "Setup experience" for the Ubuntu host. + // One last time get status of the "Setup experience" for the Windows host. orbitRes = fleet.GetOrbitSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", fleet.GetOrbitSetupExperienceStatusRequest{OrbitNodeKey: *windowsHost.OrbitNodeKey}, http.StatusOK, &orbitRes, diff --git a/server/service/integration_mdm_setup_experience_test.go b/server/service/integration_mdm_setup_experience_test.go index e49cf47772..09c60eeaa7 100644 --- a/server/service/integration_mdm_setup_experience_test.go +++ b/server/service/integration_mdm_setup_experience_test.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "os" + "slices" "sort" "strings" "testing" @@ -365,7 +366,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu // The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull // it out manually - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, *enrolledHost.TeamID) require.NoError(t, err) require.Len(t, results, 2) var installUUID string @@ -471,7 +472,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Script.Status) // Get script exec ID - results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID) + results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, *enrolledHost.TeamID) require.NoError(t, err) require.Len(t, results, 2) var execID string @@ -836,7 +837,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithFMAAndVersionRollba require.Equal(t, fmaTitleID, *fmaResult.SoftwareTitleID) // Pull the execution ID out of the DB (the status endpoint doesn't surface it). - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, tm.ID) require.NoError(t, err) require.Len(t, results, 1) require.NotNil(t, results[0].HostSoftwareInstallsExecutionID) @@ -1235,29 +1236,53 @@ func (s *integrationMDMTestSuite) TestSetupExperienceVPPInstallError() { } require.ElementsMatch(t, []string{"N1", "Fleetd configuration", "Fleet root certificate authority (CA)"}, profNames) require.ElementsMatch(t, []fleet.MDMDeliveryStatus{fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerifying}, profStatuses) - - // the software and script are still pending require.NotNil(t, statusResp.Results.Script) require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status) require.Len(t, statusResp.Results.Software, 2) - require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) + require.Equal(t, "App 5", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) - require.Equal(t, "App 5", statusResp.Results.Software[1].Name) + require.Equal(t, "DummyApp", statusResp.Results.Software[1].Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].Status) + // Get status: the VPP app install should have run and failed. + statusResp = fleet.GetOrbitSetupExperienceStatusResponse{} + s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) + require.Nil(t, statusResp.Results.BootstrapPackage) // no bootstrap package involved + require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved + require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile + require.Len(t, statusResp.Results.Software, 2) + // App 5 has no licenses available, so we should get a status failed here... + require.Equal(t, "App 5", statusResp.Results.Software[0].Name) + require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[0].Status) + // ...but setup experience should still continue with the next app in the list + require.Equal(t, "DummyApp", statusResp.Results.Software[1].Name) + require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].Status) + // Script goes last + require.NotNil(t, statusResp.Results.Script) + require.Equal(t, "script.sh", statusResp.Results.Script.Name) + require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status) + + // The status for DummyApp should be "running" now, since it's started installing + // but we haven't sent back an installation status from orbit yet + statusResp = fleet.GetOrbitSetupExperienceStatusResponse{} + s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) + require.Len(t, statusResp.Results.Software, 2) + require.Equal(t, "DummyApp", statusResp.Results.Software[1].Name) + require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[1].Status) + // The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull - // it out manually - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID) + // exec ID for "DummyApp" out manually + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, team.ID) require.NoError(t, err) require.Len(t, results, 3) var installUUID string for _, r := range results { if r.HostSoftwareInstallsExecutionID != nil && r.SoftwareInstallerID != nil && - r.Name == statusResp.Results.Software[0].Name { + r.Name == statusResp.Results.Software[1].Name { installUUID = *r.HostSoftwareInstallsExecutionID break } @@ -1267,7 +1292,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceVPPInstallError() { // Need to get the software title to get the package name var getSoftwareTitleResp getSoftwareTitleResponse - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *statusResp.Results.Software[0].SoftwareTitleID), nil, http.StatusOK, &getSoftwareTitleResp, "team_id", fmt.Sprintf("%d", *enrolledHost.TeamID)) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *statusResp.Results.Software[1].SoftwareTitleID), nil, http.StatusOK, &getSoftwareTitleResp, "team_id", fmt.Sprintf("%d", *enrolledHost.TeamID)) require.NotNil(t, getSoftwareTitleResp.SoftwareTitle) require.NotNil(t, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage) @@ -1280,37 +1305,21 @@ func (s *integrationMDMTestSuite) TestSetupExperienceVPPInstallError() { "install_script_output": "ok" }`, *enrolledHost.OrbitNodeKey, installUUID)), http.StatusNoContent) + // Script is already running, poll again to confirm status statusResp = fleet.GetOrbitSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) - require.Nil(t, statusResp.Results.BootstrapPackage) // no bootstrap package involved - require.Nil(t, statusResp.Results.AccountConfiguration) // no SSO involved - require.Len(t, statusResp.Results.ConfigurationProfiles, 3) // fleetd config, root CA, custom profile - require.NotNil(t, statusResp.Results.Script) - require.Equal(t, "script.sh", statusResp.Results.Script.Name) - require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status) - require.Len(t, statusResp.Results.Software, 2) - require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) - require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) - // App 5 has no licenses available, so we should get a status failed here and setup experience - // should continue - require.Equal(t, "App 5", statusResp.Results.Software[1].Name) - require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[1].Status) - - // Software installations are done, now we should run the script - statusResp = fleet.GetOrbitSetupExperienceStatusResponse{} - s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) - require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) - require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) + require.Equal(t, "App 5", statusResp.Results.Software[0].Name) + require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) require.NotNil(t, statusResp.Results.Script) require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Script.Status) - require.Equal(t, "App 5", statusResp.Results.Software[1].Name) - require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[1].Status) + require.Equal(t, "DummyApp", statusResp.Results.Software[1].Name) + require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[1].Status) // Get script exec ID - results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID) + results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, team.ID) require.NoError(t, err) require.Len(t, results, 3) var execID string @@ -1330,12 +1339,12 @@ func (s *integrationMDMTestSuite) TestSetupExperienceVPPInstallError() { // release of the device, as all setup experience steps are now complete. statusResp = fleet.GetOrbitSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) - require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) - require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) + require.Equal(t, "App 5", statusResp.Results.Software[0].Name) + require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) - require.Equal(t, "App 5", statusResp.Results.Software[1].Name) - require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[1].Status) + require.Equal(t, "DummyApp", statusResp.Results.Software[1].Name) + require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[1].Status) require.NotNil(t, statusResp.Results.Script) require.Equal(t, "script.sh", statusResp.Results.Script.Name) @@ -1436,7 +1445,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowUpdateScript() { // The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull // it out manually (for now only the software install has its execution id) - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID, tm.ID) require.NoError(t, err) require.Len(t, results, 2) @@ -1527,7 +1536,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowUpdateScript() { // The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull // them out manually - results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID) + results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID, tm.ID) require.NoError(t, err) require.Len(t, results, 1) var installUUIDs []string @@ -1634,7 +1643,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowCancelScript() { // The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull // it out manually (for now only the software install has its execution id) - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID, *host.TeamID) require.NoError(t, err) require.Len(t, results, 2) @@ -1681,7 +1690,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowCancelScript() { // The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull // it out manually (this time get the script exec ID) - results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID) + results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, host.UUID, *host.TeamID) require.NoError(t, err) require.Len(t, results, 2) @@ -1878,6 +1887,10 @@ func (s *integrationMDMTestSuite) TestSetupExperienceWithLotsOfVPPApps() { expectedApps := []*fleet.VPPApp{macOSApp1, macOSApp2, macOSApp3, macOSApp4, macOSApp5, macOSApp6} + slices.SortFunc(expectedApps, func(a, b *fleet.VPPApp) int { + return strings.Compare(a.Name, b.Name) + }) + expectedAppsByName := map[string]*fleet.VPPApp{ macOSApp1.Name: macOSApp1, macOSApp2.Name: macOSApp2, @@ -2144,18 +2157,18 @@ func (s *integrationMDMTestSuite) TestSetupExperienceWithLotsOfVPPApps() { if software.Name == macOSApp1.Name { require.Equal(t, fleet.SetupExperienceStatusSuccess, software.Status) } else { - require.Equal(t, fleet.SetupExperienceStatusRunning, software.Status) + require.Equal(t, fleet.SetupExperienceStatusPending, software.Status) } require.NotNil(t, software.SoftwareTitleID) require.NotZero(t, *software.SoftwareTitleID) } - // All apps should have an install record at this point + // Only 2 apps have an installation attempt at this point mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { var count int err := sqlx.GetContext(context.Background(), q, &count, "SELECT COUNT(*) FROM host_vpp_software_installs") require.NoError(t, err) - require.Equal(t, 6, count) + require.Equal(t, 2, count) return nil }) @@ -2163,7 +2176,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceWithLotsOfVPPApps() { macOSApp1.Name: {}, } - for _, app := range expectedApps { + for _, app := range expectedApps[1:] { opts.softwareResultList = append(opts.softwareResultList, fleet.Software{ Name: app.Name, BundleIdentifier: app.BundleIdentifier, @@ -2186,9 +2199,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceWithLotsOfVPPApps() { require.True(t, ok) _, shouldBeInstalled := installedApps[software.Name] if shouldBeInstalled { - require.Equal(t, fleet.SetupExperienceStatusSuccess, software.Status, software.Name, software.Status) - } else { - require.Equal(t, fleet.SetupExperienceStatusRunning, software.Status) + require.Equalf(t, fleet.SetupExperienceStatusSuccess, software.Status, "software %s should have succeeded", software.Name) } require.NotNil(t, software.SoftwareTitleID) require.NotZero(t, *software.SoftwareTitleID) @@ -3269,7 +3280,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequireSoftware() { // The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull // them out manually - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, *enrolledHost.TeamID) require.NoError(t, err) require.Len(t, results, 4) var installUUIDs []string @@ -3278,7 +3289,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequireSoftware() { installUUIDs = append(installUUIDs, *r.HostSoftwareInstallsExecutionID) } } - require.Equal(t, len(installUUIDs), 3) + require.Len(t, installUUIDs, 1) // debugPrintActivities := func(activities []*fleet.UpcomingActivity) []string { // var res []string @@ -3320,10 +3331,14 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequireSoftware() { // Since no results have been recorded, this shouldn't change any database state. statusResp = fleet.GetOrbitSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) - // Software is now running, script is still pending + // First software is now running, other software and script are still pending require.Equal(t, len(statusResp.Results.Software), 3) - for _, softwareResult := range statusResp.Results.Software { - require.Equal(t, fleet.SetupExperienceStatusRunning, softwareResult.Status) + for i, softwareResult := range statusResp.Results.Software { + if i == 0 { + require.Equal(t, fleet.SetupExperienceStatusRunning, softwareResult.Status) + continue + } + require.Equal(t, fleet.SetupExperienceStatusPending, softwareResult.Status) } require.NotNil(t, statusResp.Results.Script) require.Equal(t, "script.sh", statusResp.Results.Script.Name) @@ -3338,6 +3353,17 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequireSoftware() { "install_script_output": "ok" }`, *enrolledHost.OrbitNodeKey, installUUIDs[0])), http.StatusNoContent) + results, err = s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, *enrolledHost.TeamID) + require.NoError(t, err) + require.Len(t, results, 4) + installUUIDs = []string{} + for _, r := range results { + if r.HostSoftwareInstallsExecutionID != nil { + installUUIDs = append(installUUIDs, *r.HostSoftwareInstallsExecutionID) + } + } + require.Len(t, installUUIDs, 2) + // status still shows script as pending statusResp = fleet.GetOrbitSetupExperienceStatusResponse{} s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) @@ -3350,9 +3376,8 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequireSoftware() { require.Len(t, statusResp.Results.Software, 3) require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) - // Other two software should still be "running" require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[1].Status) - require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[2].Status) + require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[2].Status) // Record a failure for the second software. s.Do("POST", "/api/fleet/orbit/software_install/result", @@ -3458,9 +3483,28 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequiredSoftwareVPP // Add the app with 1 licenses available s.Do("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: "4", SelfService: true}, http.StatusOK) - // Add the VPP app to setup experience + // Set custom display names on the VPP apps so they sort alphabetically + // after DummyApp, with the no-license app sorting first among the VPP + // apps. This ensures the installer (DummyApp) installs first, then the + // VPP app with no license fails immediately, triggering the + // "require all software" cancel-on-failure flow after a successful + // installer install. + // Alphabetical order by display name: DummyApp < VPP AAA No License < VPP ZZZ Has License + // + // This also exercises the fix for #41741: setup experience ordering + // should use the custom display name when set. vppTitleID := getSoftwareTitleID(t, s.ds, "App 5", "apps") vppTitleID2 := getSoftwareTitleID(t, s.ds, "App 4", "apps") + + var updateAppResp updateAppStoreAppResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", vppTitleID), + &updateAppStoreAppRequest{TeamID: &team.ID, DisplayName: ptr.String("VPP ZZZ Has License")}, + http.StatusOK, &updateAppResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", vppTitleID2), + &updateAppStoreAppRequest{TeamID: &team.ID, DisplayName: ptr.String("VPP AAA No License")}, + http.StatusOK, &updateAppResp) + + // Add the VPP apps to setup experience installerTitleID := getSoftwareTitleID(t, s.ds, "DummyApp", "apps") var swInstallResp putSetupExperienceSoftwareResponse s.DoJSON("PUT", "/api/v1/fleet/setup_experience/software", putSetupExperienceSoftwareRequest{TeamID: team.ID, TitleIDs: []uint{vppTitleID, vppTitleID2, installerTitleID}}, http.StatusOK, &swInstallResp) @@ -3562,18 +3606,30 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequiredSoftwareVPP require.Equal(t, "script.sh", statusResp.Results.Script.Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status) require.Len(t, statusResp.Results.Software, 3) + // Ordered by display name: DummyApp (no display name), App 4 (display name + // "VPP AAA No License"), App 5 (display name "VPP ZZZ Has License"). + // Note: Name stores the original st.name, ordering uses display name. require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[0].Status) require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) require.Equal(t, "App 4", statusResp.Results.Software[1].Name) + require.Equal(t, "VPP AAA No License", statusResp.Results.Software[1].DisplayName) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].Status) require.Equal(t, "App 5", statusResp.Results.Software[2].Name) + require.Equal(t, "VPP ZZZ Has License", statusResp.Results.Software[2].DisplayName) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[2].Status) + // call /status endpoint again, DummyApp (first by display name) should be running + statusResp = fleet.GetOrbitSetupExperienceStatusResponse{} + s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp) + require.Len(t, statusResp.Results.Software, 3) + require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name, "DummyApp should be first by display name") + require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Software[0].Status) + // The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull // it out manually - results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID) + results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID, team.ID) require.NoError(t, err) require.Len(t, results, 4) var installUUID string @@ -3614,11 +3670,15 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequiredSoftwareVPP require.Len(t, statusResp.Results.Software, 3) require.Equal(t, "DummyApp", statusResp.Results.Software[0].Name) require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status) - // App 4 has no licenses available, so it should fail and because we have "requre_all_software_macos" set, - // the other software and the script should be marked as failed too. + // App 4 (display name "VPP AAA No License") has no licenses available, so + // it should fail immediately. Because we have "require_all_software_macos" + // set, the remaining software (App 5) and the script should be marked as + // failed too. require.Equal(t, "App 4", statusResp.Results.Software[1].Name) + require.Equal(t, "VPP AAA No License", statusResp.Results.Software[1].DisplayName) require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[1].Status) require.Equal(t, "App 5", statusResp.Results.Software[2].Name) + require.Equal(t, "VPP ZZZ Has License", statusResp.Results.Software[2].DisplayName) require.Equal(t, fleet.SetupExperienceStatusFailure, statusResp.Results.Software[2].Status) // Reset the setup experience items. @@ -3634,8 +3694,10 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequiredSoftwareVPP require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID) require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID) require.Equal(t, "App 4", statusResp.Results.Software[1].Name) + require.Equal(t, "VPP AAA No License", statusResp.Results.Software[1].DisplayName) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[1].Status) require.Equal(t, "App 5", statusResp.Results.Software[2].Name) + require.Equal(t, "VPP ZZZ Has License", statusResp.Results.Software[2].DisplayName) require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Software[2].Status) } @@ -3872,7 +3934,9 @@ func (s *integrationMDMTestSuite) TestSetupExperienceMacOSCustomDisplayNameIcon( require.NoError(t, res.Body.Close()) require.NoError(t, deviceResp.Err) - // the software is now running (because previous call to /status kickstarted the process), and script is pending + // Software is installed one at a time in alphabetical order by display + // name (falling back to name). The previous call to /status kickstarted + // the first item; the second remains pending until the first finishes. require.Len(t, deviceResp.Results.Scripts, 1) require.Equal(t, "script.sh", deviceResp.Results.Scripts[0].Name) require.Equal(t, fleet.SetupExperienceStatusPending, deviceResp.Results.Scripts[0].Status) @@ -3885,7 +3949,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceMacOSCustomDisplayNameIcon( require.Empty(t, deviceResp.Results.Software[0].IconURL) require.Equal(t, "EchoApp", deviceResp.Results.Software[1].Name) require.Equal(t, "My Custom EchoApp", deviceResp.Results.Software[1].DisplayName) - require.Equal(t, fleet.SetupExperienceStatusRunning, deviceResp.Results.Software[1].Status) + require.Equal(t, fleet.SetupExperienceStatusPending, deviceResp.Results.Software[1].Status) require.NotNil(t, deviceResp.Results.Software[1].SoftwareTitleID) require.Equal(t, echoTitleID, *deviceResp.Results.Software[1].SoftwareTitleID) require.NotEmpty(t, deviceResp.Results.Software[1].IconURL) diff --git a/server/service/osquery.go b/server/service/osquery.go index 72ad4c3a44..820e12831c 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -894,7 +894,7 @@ func (svc *Service) hostIsInSetupExperience(ctx context.Context, host *fleet.Hos if err != nil { return false, ctxerr.Wrap(ctx, err, "failed to get host's UUID for the setup experience") } - inSetupExperience, err := svc.hasSetupExperiencePendingOrRunningItems(ctx, hostUUID) + inSetupExperience, err := svc.hasSetupExperiencePendingOrRunningItems(ctx, hostUUID, ptr.ValOrZero(host.TeamID)) if err != nil && !fleet.IsNotFound(err) { return false, ctxerr.Wrap(ctx, err, "check setup experience pending or running items") } @@ -904,8 +904,8 @@ func (svc *Service) hostIsInSetupExperience(ctx context.Context, host *fleet.Hos } } -func (svc *Service) hasSetupExperiencePendingOrRunningItems(ctx context.Context, hostUUID string) (bool, error) { - statuses, err := svc.ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID) +func (svc *Service) hasSetupExperiencePendingOrRunningItems(ctx context.Context, hostUUID string, teamID uint) (bool, error) { + statuses, err := svc.ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID, teamID) if err != nil { return false, ctxerr.Wrap(ctx, err, "retrieving setup experience results") } diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index b067339115..0f67a5e97a 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -1705,7 +1705,7 @@ func TestDetailQueriesWithEmptyStrings(t *testing.T) { } return host, nil } - ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error) { + ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hostUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) { return nil, nil } @@ -1943,7 +1943,7 @@ func TestDetailQueries(t *testing.T) { ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostuuid string) (bool, error) { return false, nil } - ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error) { + ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hostUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) { return nil, nil } @@ -2476,7 +2476,7 @@ func TestDistributedQueryResults(t *testing.T) { EnableSoftwareInventory: true, }}, nil } - ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hostUUID string) ([]*fleet.SetupExperienceStatusResult, error) { + ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hostUUID string, teamID uint) ([]*fleet.SetupExperienceStatusResult, error) { return nil, nil } diff --git a/server/service/setup_experience.go b/server/service/setup_experience.go index 2e8dedc3db..9cfa7af3fa 100644 --- a/server/service/setup_experience.go +++ b/server/service/setup_experience.go @@ -277,7 +277,7 @@ func maybeCancelPendingSetupExperienceSteps(ctx context.Context, ds fleet.Datast if err != nil { return ctxerr.Wrap(ctx, err, "failed to get host's UUID for the setup experience") } - statuses, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID) + statuses, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID, ptr.ValOrZero(host.TeamID)) if err != nil { return ctxerr.Wrap(ctx, err, "retrieving setup experience status results for next step") } diff --git a/server/worker/apple_mdm.go b/server/worker/apple_mdm.go index 168b7347a0..51548748f8 100644 --- a/server/worker/apple_mdm.go +++ b/server/worker/apple_mdm.go @@ -127,7 +127,7 @@ func (a *AppleMDM) runPostManualEnrollment(ctx context.Context, args appleMDMArg // We shouldn't have any setup experience steps if we're not on a premium license, // but best to check anyway plus it saves some db queries. if license.IsPremium(ctx) { - _, err := a.installSetupExperienceVPPAppsOnIosIpadOS(ctx, args.HostUUID) + _, err := a.installSetupExperienceVPPAppsOnIosIpadOS(ctx, args.HostUUID, ptr.ValOrZero(args.TeamID)) if err != nil { return ctxerr.Wrap(ctx, err, "installing setup experience VPP apps on iOS/iPadOS") } @@ -189,7 +189,7 @@ func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs) } } } else { - commandUUIDs, err := a.installSetupExperienceVPPAppsOnIosIpadOS(ctx, args.HostUUID) + commandUUIDs, err := a.installSetupExperienceVPPAppsOnIosIpadOS(ctx, args.HostUUID, ptr.ValOrZero(args.TeamID)) if err != nil { return ctxerr.Wrap(ctx, err, "installing setup experience VPP apps on iOS/iPadOS") } @@ -482,7 +482,7 @@ func (a *AppleMDM) runPostDEPReleaseDevice(ctx context.Context, args appleMDMArg } if !isMacOS(args.Platform) { - setupExperienceStatuses, err := a.Datastore.ListSetupExperienceResultsByHostUUID(ctx, args.HostUUID) + setupExperienceStatuses, err := a.Datastore.ListSetupExperienceResultsByHostUUID(ctx, args.HostUUID, ptr.ValOrZero(args.TeamID)) if err != nil { return ctxerr.Wrap(ctx, err, "retrieving setup experience status results for host pending DEP release") } @@ -523,8 +523,8 @@ func (a *AppleMDM) installFleetd(ctx context.Context, hostUUID string) (string, return cmdUUID, nil } -func (a *AppleMDM) installSetupExperienceVPPAppsOnIosIpadOS(ctx context.Context, hostUUID string) ([]string, error) { - statuses, err := a.Datastore.ListSetupExperienceResultsByHostUUID(ctx, hostUUID) +func (a *AppleMDM) installSetupExperienceVPPAppsOnIosIpadOS(ctx context.Context, hostUUID string, teamID uint) ([]string, error) { + statuses, err := a.Datastore.ListSetupExperienceResultsByHostUUID(ctx, hostUUID, teamID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "retrieving setup experience status results for next step") } @@ -583,9 +583,6 @@ func (a *AppleMDM) installSetupExperienceVPPAppsOnIosIpadOS(ctx context.Context, cmdUUID, err := a.installSoftwareFromVPP(ctx, host, vppApp, true, opts) - app.NanoCommandUUID = &cmdUUID - app.Status = fleet.SetupExperienceStatusRunning - 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 @@ -594,6 +591,8 @@ func (a *AppleMDM) installSetupExperienceVPPAppsOnIosIpadOS(ctx context.Context, app.Status = fleet.SetupExperienceStatusFailure app.Error = ptr.String(err.Error()) } else { + app.NanoCommandUUID = &cmdUUID + app.Status = fleet.SetupExperienceStatusRunning commandUUIDs = append(commandUUIDs, cmdUUID) } if err := a.Datastore.UpdateSetupExperienceStatusResult(ctx, app); err != nil { diff --git a/server/worker/apple_mdm_test.go b/server/worker/apple_mdm_test.go index e297846e62..06b79f6ce7 100644 --- a/server/worker/apple_mdm_test.go +++ b/server/worker/apple_mdm_test.go @@ -1108,7 +1108,7 @@ INSERT INTO setup_experience_status_results ( } require.ElementsMatch(t, expectedAdamIDs, installedAdamIDs) - results, err := ds.ListSetupExperienceResultsByHostUUID(ctx, h.UUID) + results, err := ds.ListSetupExperienceResultsByHostUUID(ctx, h.UUID, tm.ID) require.NoError(t, err) require.Len(t, results, len(expectedAppInstalls)) for _, result := range results { @@ -1279,7 +1279,7 @@ INSERT INTO setup_experience_status_results ( } require.ElementsMatch(t, expectedAdamIDs, installedAdamIDs) - results, err := ds.ListSetupExperienceResultsByHostUUID(ctx, h.UUID) + results, err := ds.ListSetupExperienceResultsByHostUUID(ctx, h.UUID, tm.ID) require.NoError(t, err) require.Len(t, results, len(expectedAppInstalls)) for _, result := range results {