mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
<!-- 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 -->
411 lines
15 KiB
Go
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")
|
|
}
|