fleet/server/service/setup_experience.go
Victor Lyuboslavsky c4479c6a84
Add require_all_software_windows config option (#43011)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #42853

This PR simply adds the `require_all_software_windows` config option. It
doesn't use it. The logic to use it will be hooked up in subsequent PRs.

The fleetctl TestIntegrationsPreview test is expected to fail since it
builds the server against main and doesn't know about our new config
option.

# 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`.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

## New Fleet configuration settings

- [x] Verified that the setting is exported via `fleetctl
generate-gitops`
- Not exported. generate-gitops does not export
require_all_software_windows (or require_all_software_macos either). The
generateControls function (generate_gitops.go) outputs a "TODO: update
with your setup_experience configuration" placeholder when any setup
experience config exists, rather than exporting individual field values.
This is a pre-existing limitation that applies equally to both fields -
not something introduced by our PR.
- [x] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- Yes. PR #42046 adds require_all_software_windows to both docs/REST
API/rest-api.md and docs/Configuration/yaml-files.md.
- [x] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- Yes, it gets cleared to false - both when setup_experience: is present
without the field, and when setup_experience: is omitted entirely. This
is the same behavior as the existing require_all_software_macos field
- [x] Verified that any relevant UI is disabled when GitOps mode is
enabled
- Covered by #42854 (frontend subtask). The existing macOS checkbox in
InstallSoftwareForm.tsx:271 already checks gitOpsModeEnabled to disable
itself. The Windows checkbox to be added in #42854 will follow the same
pattern.


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

## Summary by CodeRabbit

* **New Features**
* Added a Windows setup experience software requirement setting. When
enabled, Windows devices will cancel the Autopilot setup if any required
software installation fails.

* **Tests**
* Added test coverage for the new Windows software requirement
configuration.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-06 17:39:59 -05:00

411 lines
15 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 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)
}
func maybeCancelPendingSetupExperienceSteps(ctx context.Context, ds fleet.Datastore, host *fleet.Host) 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
}
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")
}
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.
// If the skipPending parameter is true, the datastore will only be updated if the given result
// status is not pending.
func maybeUpdateSetupExperienceStatus(ctx context.Context, ds fleet.Datastore, result interface{}, requireTerminalStatus bool) (bool, error) {
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 requireTerminalStatus && !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 requireTerminalStatus && !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 requireTerminalStatus && !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)
if cancelErr != nil {
return updated, fmt.Errorf("cancel setup experience after macos 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")
}