<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #46567 and Resolves #46737 Solution for the agressive polling: - no WNS (although we could add it later as another avenue for notifications) - fleetd advertises a sync capability, persisted as `mdm_windows_enrollments.fleetd_sync_capable` - The management session relaxes the DMClient poll (`poll_schedule_relaxed`) - When an MDM command is queued, `has_pending_commands` flips, the next orbit check-in returns `WindowsMDMSyncRequest`, and fleetd runs `deviceenroller` to deliver it immediately - older fleetd versions keep the 1-minute poll Docs: https://github.com/fleetdm/fleet/pull/46780 Changes to osquery_perf and any additional changes after loadtesting will be done in a separate PR. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Timeouts are implemented and retries are limited to avoid infinite loops ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually ## Database migrations - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). ## fleetd/orbit/Fleet Desktop - [x] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) - [x] If the change applies to only one platform, confirmed that `runtime.GOOS` is used as needed to isolate changes - [x] Verified that fleetd runs on macOS, Linux and Windows - [x] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * On-demand Windows MDM sync: servers can request immediate delivery of queued MDM commands to Windows clients; Orbit triggers client-side sync on Windows. * **Enhancements** * Orbit throttles per-device on-demand sync to avoid excessive runs. * Server reconciles and persists device poll schedule (fast vs relaxed) and exposes consolidated host MDM state (awaiting-configuration + has-pending-commands). * **Tests** * Added tests covering host config state, pending-command flows, poll-schedule toggling, and on-demand sync behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
472 lines
18 KiB
Go
472 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
)
|
|
|
|
type putSetupExperienceSoftwareRequest struct {
|
|
Platform string `json:"platform"`
|
|
TeamID uint `json:"team_id" renameto:"fleet_id"`
|
|
TitleIDs []uint `json:"software_title_ids"`
|
|
}
|
|
|
|
func (r *putSetupExperienceSoftwareRequest) ValidateRequest() error {
|
|
return validateSetupExperiencePlatform(r.Platform)
|
|
}
|
|
|
|
type putSetupExperienceSoftwareResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r putSetupExperienceSoftwareResponse) Error() error { return r.Err }
|
|
|
|
func putSetupExperienceSoftware(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*putSetupExperienceSoftwareRequest)
|
|
platform := transformPlatformForSetupExperience(req.Platform)
|
|
err := svc.SetSetupExperienceSoftware(ctx, platform, req.TeamID, req.TitleIDs)
|
|
if err != nil {
|
|
return &putSetupExperienceSoftwareResponse{Err: err}, nil
|
|
}
|
|
return &putSetupExperienceSoftwareResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) SetSetupExperienceSoftware(ctx context.Context, platform string, teamID uint, titleIDs []uint) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
type getSetupExperienceSoftwareRequest struct {
|
|
// Platforms can be a comma separated list
|
|
Platforms string `query:"platform,optional"`
|
|
fleet.ListOptions
|
|
TeamID uint `query:"team_id" renameto:"fleet_id"`
|
|
}
|
|
|
|
func (r *getSetupExperienceSoftwareRequest) ValidateRequest() error {
|
|
return validateSetupExperiencePlatform(r.Platforms)
|
|
}
|
|
|
|
type getSetupExperienceSoftwareResponse struct {
|
|
SoftwareTitles []fleet.SoftwareTitleListResult `json:"software_titles"`
|
|
Count int `json:"count"`
|
|
Meta *fleet.PaginationMetadata `json:"meta"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getSetupExperienceSoftwareResponse) Error() error { return r.Err }
|
|
|
|
func getSetupExperienceSoftware(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getSetupExperienceSoftwareRequest)
|
|
platform := transformPlatformListForSetupExperience(req.Platforms)
|
|
titles, count, meta, err := svc.ListSetupExperienceSoftware(ctx, platform, req.TeamID, req.ListOptions)
|
|
if err != nil {
|
|
return &getSetupExperienceSoftwareResponse{Err: err}, nil
|
|
}
|
|
return &getSetupExperienceSoftwareResponse{SoftwareTitles: titles, Count: count, Meta: meta}, nil
|
|
}
|
|
|
|
func (svc *Service) ListSetupExperienceSoftware(ctx context.Context, platform string, teamID uint, opts fleet.ListOptions) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, 0, nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
type getSetupExperienceScriptRequest struct {
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
Alt string `query:"alt,optional"`
|
|
}
|
|
|
|
type getSetupExperienceScriptResponse struct {
|
|
*fleet.Script
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getSetupExperienceScriptResponse) Error() error { return r.Err }
|
|
|
|
func getSetupExperienceScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getSetupExperienceScriptRequest)
|
|
downloadRequested := req.Alt == "media"
|
|
// // TODO: do we want to allow end users to specify team_id=0? if so, we'll need convert it to nil here so that we can
|
|
// // use it in the auth layer where team_id=0 is not allowed?
|
|
script, content, err := svc.GetSetupExperienceScript(ctx, req.TeamID, downloadRequested)
|
|
if err != nil {
|
|
return getSetupExperienceScriptResponse{Err: err}, nil
|
|
}
|
|
|
|
if downloadRequested {
|
|
return fleet.DownloadFileResponse{
|
|
Content: content,
|
|
Filename: fmt.Sprintf("%s %s", time.Now().Format(time.DateOnly), script.Name),
|
|
}, nil
|
|
}
|
|
|
|
return getSetupExperienceScriptResponse{Script: script}, nil
|
|
}
|
|
|
|
func (svc *Service) GetSetupExperienceScript(ctx context.Context, teamID *uint, withContent bool) (*fleet.Script, []byte, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
type setSetupExperienceScriptRequest struct {
|
|
TeamID *uint
|
|
Script *multipart.FileHeader
|
|
}
|
|
|
|
func (setSetupExperienceScriptRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
var decoded setSetupExperienceScriptRequest
|
|
|
|
err := parseMultipartForm(ctx, r, platform_http.MaxMultipartFormSize)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to parse multipart form",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
val := r.MultipartForm.Value["fleet_id"]
|
|
if len(val) > 0 {
|
|
fleetID, err := strconv.ParseUint(val[0], 10, 64)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode fleet_id in multipart form: %s", err.Error())}
|
|
}
|
|
// // TODO: do we want to allow end users to specify team_id=0? if so, we'll need to convert it to nil here so that we can
|
|
// // use it in the auth layer where team_id=0 is not allowed?
|
|
decoded.TeamID = ptr.Uint(uint(fleetID)) // nolint:gosec // ignore G115
|
|
}
|
|
|
|
fhs, ok := r.MultipartForm.File["script"]
|
|
if !ok || len(fhs) < 1 {
|
|
return nil, &fleet.BadRequestError{Message: "no file headers for script"}
|
|
}
|
|
decoded.Script = fhs[0]
|
|
|
|
return &decoded, nil
|
|
}
|
|
|
|
type setSetupExperienceScriptResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r setSetupExperienceScriptResponse) Error() error { return r.Err }
|
|
|
|
func setSetupExperienceScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*setSetupExperienceScriptRequest)
|
|
|
|
scriptFile, err := req.Script.Open()
|
|
if err != nil {
|
|
return setSetupExperienceScriptResponse{Err: err}, nil
|
|
}
|
|
defer scriptFile.Close()
|
|
|
|
if err := svc.SetSetupExperienceScript(ctx, req.TeamID, filepath.Base(req.Script.Filename), scriptFile); err != nil {
|
|
return setSetupExperienceScriptResponse{Err: err}, nil
|
|
}
|
|
|
|
return setSetupExperienceScriptResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) SetSetupExperienceScript(ctx context.Context, teamID *uint, name string, r io.Reader) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
type deleteSetupExperienceScriptRequest struct {
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type deleteSetupExperienceScriptResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteSetupExperienceScriptResponse) Error() error { return r.Err }
|
|
|
|
func deleteSetupExperienceScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteSetupExperienceScriptRequest)
|
|
// // TODO: do we want to allow end users to specify team_id=0? if so, we'll need convert it to nil here so that we can
|
|
// // use it in the auth layer where team_id=0 is not allowed?
|
|
if err := svc.DeleteSetupExperienceScript(ctx, req.TeamID); err != nil {
|
|
return deleteSetupExperienceScriptResponse{Err: err}, nil
|
|
}
|
|
|
|
return deleteSetupExperienceScriptResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
func (svc *Service) SetupExperienceNextStep(ctx context.Context, host *fleet.Host) (bool, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return false, fleet.ErrMissingLicense
|
|
}
|
|
|
|
func (svc *Service) IsAllSetupExperienceSoftwareRequired(ctx context.Context, host *fleet.Host) (bool, error) {
|
|
return isAllSetupExperienceSoftwareRequired(ctx, svc.ds, host)
|
|
}
|
|
|
|
func isAllSetupExperienceSoftwareRequired(ctx context.Context, ds fleet.Datastore, host *fleet.Host) (bool, error) {
|
|
// Only macOS and Windows support canceling setup if software fails.
|
|
if host.Platform != "darwin" && host.Platform != "windows" {
|
|
return false, nil
|
|
}
|
|
|
|
teamID := host.TeamID
|
|
if teamID == nil || *teamID == 0 {
|
|
ac, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "getting app config")
|
|
}
|
|
if host.Platform == "windows" {
|
|
return ac.MDM.MacOSSetup.RequireAllSoftwareWindows, nil
|
|
}
|
|
return ac.MDM.MacOSSetup.RequireAllSoftware, nil
|
|
}
|
|
|
|
team, err := ds.TeamLite(ctx, *teamID)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "load team")
|
|
}
|
|
if host.Platform == "windows" {
|
|
return team.Config.MDM.MacOSSetup.RequireAllSoftwareWindows, nil
|
|
}
|
|
return team.Config.MDM.MacOSSetup.RequireAllSoftware, nil
|
|
}
|
|
|
|
func (svc *Service) MaybeCancelPendingSetupExperienceSteps(ctx context.Context, host *fleet.Host) error {
|
|
return maybeCancelPendingSetupExperienceSteps(ctx, svc.ds, host, svc.NewActivity)
|
|
}
|
|
|
|
func maybeCancelPendingSetupExperienceSteps(ctx context.Context, ds fleet.Datastore, host *fleet.Host, newActivityFn fleet.NewActivityFunc) error {
|
|
// Only macOS and Windows support canceling setup experience steps.
|
|
if host.Platform != "darwin" && host.Platform != "windows" {
|
|
return nil
|
|
}
|
|
|
|
requireAllSoftware, err := isAllSetupExperienceSoftwareRequired(ctx, ds, host)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking if all software is required")
|
|
}
|
|
if !requireAllSoftware {
|
|
return nil
|
|
}
|
|
|
|
// Gate the cancel cascade on the host being actively in a Fleet-tracked ESP. The authoritative
|
|
// signal is mdm_windows_enrollments.awaiting_configuration: it transitions to Pending only when
|
|
// the device enrolls via WindowsMDMEnrollTypeAutomatic while the device is in OOBE, advances to Active once ESP commands are enqueued, and returns to
|
|
// None on completion/failure/timeout. All non-ESP enrollment paths stay at None, which is
|
|
// exactly the set of cases that must skip the cancellation cascade and activity emission:
|
|
// - post-OOBE BYOD via work/school account in Settings
|
|
// - post-OOBE manual orbit install that triggers programmatic Windows MDM enrollment
|
|
// - post-OOBE manual orbit install with no Windows MDM at all (no enrollment row)
|
|
// - any host that previously completed/timed-out ESP
|
|
//
|
|
// The primary lookup is keyed on mdm_windows_enrollments.host_uuid, which osquery's
|
|
// directIngestMDMDeviceIDWindows populates lazily on its first poll after enrollment. A
|
|
// fast-failing install can race that ingest, so when the primary lookup misses we fall back
|
|
// to the most-recent unlinked enrollment whose device_name matches host.ComputerName. Without
|
|
// the fallback an OOBE host that fails an install before osquery links the enrollment would
|
|
// be misread as having no MDM enrollment and would skip cancellation when it should fire.
|
|
// Follow-up bug: https://github.com/fleetdm/fleet/issues/45380
|
|
if host.Platform == "windows" {
|
|
var awaiting fleet.WindowsMDMAwaitingConfiguration
|
|
state, err := ds.GetMDMWindowsHostConfigState(ctx, host.UUID)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return ctxerr.Wrap(ctx, err, "load windows host config state for setup-experience cancel gate")
|
|
}
|
|
if state != nil {
|
|
awaiting = state.AwaitingConfiguration
|
|
}
|
|
if fleet.IsNotFound(err) && host.ComputerName != "" {
|
|
device, err := ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx, host.ComputerName)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return ctxerr.Wrap(ctx, err, "load windows enrollment by device name for awaiting_configuration fallback")
|
|
}
|
|
if device != nil {
|
|
awaiting = device.AwaitingConfiguration
|
|
}
|
|
}
|
|
if awaiting != fleet.WindowsMDMAwaitingConfigurationPending &&
|
|
awaiting != fleet.WindowsMDMAwaitingConfigurationActive {
|
|
return nil
|
|
}
|
|
}
|
|
hostUUID, err := fleet.HostUUIDForSetupExperience(host)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "failed to get host's UUID for the setup experience")
|
|
}
|
|
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")
|
|
}
|
|
|
|
for _, status := range statuses {
|
|
if err := status.IsValid(); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "invalid row")
|
|
}
|
|
if status.Status != fleet.SetupExperienceStatusPending && status.Status != fleet.SetupExperienceStatusRunning {
|
|
continue
|
|
}
|
|
// Cancel any upcoming software installs, vpp installs or script runs.
|
|
var executionID string
|
|
switch {
|
|
case status.HostSoftwareInstallsExecutionID != nil:
|
|
executionID = *status.HostSoftwareInstallsExecutionID
|
|
case status.NanoCommandUUID != nil:
|
|
executionID = *status.NanoCommandUUID
|
|
case status.ScriptExecutionID != nil:
|
|
executionID = *status.ScriptExecutionID
|
|
default:
|
|
continue
|
|
}
|
|
if _, err := ds.CancelHostUpcomingActivity(ctx, host.ID, executionID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "cancelling upcoming setup experience activity")
|
|
}
|
|
}
|
|
// Cancel any pending setup experience steps for the host in the database.
|
|
if err := ds.CancelPendingSetupExperienceSteps(ctx, hostUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "cancelling pending setup experience steps")
|
|
}
|
|
|
|
// Emit the canceled_setup_experience activity once at cancellation time.
|
|
// Find the software item that failed and triggered this cancellation from the
|
|
// already-loaded statuses (no extra DB call).
|
|
if newActivityFn != nil {
|
|
for _, s := range statuses {
|
|
if s.Status == fleet.SetupExperienceStatusFailure && s.IsForSoftware() {
|
|
if err := newActivityFn(ctx, nil, fleet.ActivityTypeCanceledSetupExperience{
|
|
HostID: host.ID,
|
|
HostDisplayName: host.DisplayName(),
|
|
SoftwareTitle: s.Name,
|
|
SoftwareTitleID: ptr.ValOrZero(s.SoftwareTitleID),
|
|
}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "creating canceled setup experience activity")
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// maybeUpdateSetupExperienceStatus attempts to update the status of a setup experience result in
|
|
// the database. If the given result is of a supported type (namely SetupExperienceScriptResult,
|
|
// SetupExperienceSoftwareInstallResult, and SetupExperienceVPPInstallResult), it returns a boolean
|
|
// indicating whether the datastore was updated and an error if one occurred. If the result is not of a
|
|
// supported type, it returns false and an error indicated that the type is not supported.
|
|
// The datastore will only be updated if the given result status is a terminal status.
|
|
func maybeUpdateSetupExperienceStatus(ctx context.Context, ds fleet.Datastore, result any, newActivityFn fleet.NewActivityFunc) (bool, error) {
|
|
var updated bool
|
|
var err error
|
|
var status fleet.SetupExperienceStatusResultStatus
|
|
var hostUUID string
|
|
switch v := result.(type) {
|
|
case fleet.SetupExperienceScriptResult:
|
|
status = v.SetupExperienceStatus()
|
|
if !status.IsValid() {
|
|
return false, fmt.Errorf("invalid status: %s", status)
|
|
} else if !status.IsTerminalStatus() {
|
|
return false, nil
|
|
}
|
|
return ds.MaybeUpdateSetupExperienceScriptStatus(ctx, v.HostUUID, v.ExecutionID, status)
|
|
|
|
case fleet.SetupExperienceSoftwareInstallResult:
|
|
status = v.SetupExperienceStatus()
|
|
hostUUID = v.HostUUID
|
|
if !status.IsValid() {
|
|
return false, fmt.Errorf("invalid status: %s", status)
|
|
} else if !status.IsTerminalStatus() {
|
|
return false, nil
|
|
}
|
|
updated, err = ds.MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx, v.HostUUID, v.ExecutionID, status)
|
|
|
|
case fleet.SetupExperienceVPPInstallResult:
|
|
// NOTE: this case is also implemented in the CommandAndReportResults method of
|
|
// MDMAppleCheckinAndCommandService
|
|
status = v.SetupExperienceStatus()
|
|
hostUUID = v.HostUUID
|
|
if !status.IsValid() {
|
|
return false, fmt.Errorf("invalid status: %s", status)
|
|
} else if !status.IsTerminalStatus() {
|
|
return false, nil
|
|
}
|
|
updated, err = ds.MaybeUpdateSetupExperienceVPPStatus(ctx, v.HostUUID, v.CommandUUID, status)
|
|
|
|
default:
|
|
return false, fmt.Errorf("unsupported result type: %T", result)
|
|
}
|
|
|
|
// For software / vpp installs, if we updated the status to failure and the host is macOS,
|
|
// we may need to cancel the rest of the setup experience.
|
|
if updated && err == nil && status == fleet.SetupExperienceStatusFailure {
|
|
// Look up the host by UUID to get its platform and team.
|
|
host, getHostUUIDErr := ds.HostByIdentifier(ctx, hostUUID)
|
|
if getHostUUIDErr != nil {
|
|
return updated, fmt.Errorf("getting host by UUID: %w", getHostUUIDErr)
|
|
}
|
|
cancelErr := maybeCancelPendingSetupExperienceSteps(ctx, ds, host, newActivityFn)
|
|
if cancelErr != nil {
|
|
return updated, fmt.Errorf("cancel setup experience after software install failure: %w", cancelErr)
|
|
}
|
|
}
|
|
return updated, err
|
|
}
|
|
|
|
func validateSetupExperiencePlatform(platforms string) error {
|
|
for platform := range strings.SplitSeq(platforms, ",") {
|
|
if platform != "" && !slices.Contains(fleet.SetupExperienceSupportedPlatforms, platform) {
|
|
quotedPlatforms := strings.Join(fleet.SetupExperienceSupportedPlatforms, "\", \"")
|
|
quotedPlatforms = fmt.Sprintf("\"%s\"", quotedPlatforms)
|
|
return badRequestf("platform %q unsupported, platform must be one of %s", platform, quotedPlatforms)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func transformPlatformForSetupExperience(platform string) string {
|
|
if platform == "" || platform == "macos" {
|
|
return "darwin"
|
|
}
|
|
return platform
|
|
}
|
|
|
|
func transformPlatformListForSetupExperience(platforms string) string {
|
|
if platforms == "" {
|
|
return "darwin"
|
|
}
|
|
return strings.ReplaceAll(platforms, "macos", "darwin")
|
|
}
|