Files
fleet/server/fleet/setup_experience.go
T
Victor Lyuboslavsky 251093f6b3 Setup experience software policy checks (#47075)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #45309 

If software is linked to policies, we run the policy during setup
experience to determine if software should be installed. We install on
failing policies.

# 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] QA'd all new/changed functionality manually


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

* **New Features**
* Windows/Linux setup experience installers can be gated by team
policies: setup will run a policy check and skip installing if the
policy already passes; if the policy fails, the installer runs as part
of setup.
* After gated setup completes, the host’s policy set is re-evaluated
promptly so subsequent policy actions run immediately.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-10 21:16:37 +01:00

280 lines
12 KiB
Go

package fleet
import (
"errors"
"fmt"
)
type SetupExperienceStatusResultStatus string
const (
SetupExperienceStatusPending SetupExperienceStatusResultStatus = "pending"
SetupExperienceStatusRunning SetupExperienceStatusResultStatus = "running"
SetupExperienceStatusSuccess SetupExperienceStatusResultStatus = "success"
SetupExperienceStatusFailure SetupExperienceStatusResultStatus = "failure"
SetupExperienceStatusCancelled SetupExperienceStatusResultStatus = "cancelled"
)
func (s SetupExperienceStatusResultStatus) IsValid() bool {
switch s {
case SetupExperienceStatusPending, SetupExperienceStatusRunning, SetupExperienceStatusSuccess, SetupExperienceStatusFailure:
return true
default:
return false
}
}
func (s SetupExperienceStatusResultStatus) IsTerminalStatus() bool {
switch s {
case SetupExperienceStatusSuccess, SetupExperienceStatusFailure:
return true
default:
return false
}
}
// SetupExperienceStatusResult represents the status of a particular step in the macOS setup
// experience process for a particular host. These steps can either be a software installer
// installation, a VPP app installation, or a script execution.
type SetupExperienceStatusResult struct {
ID uint `db:"id" json:"-" `
HostUUID string `db:"host_uuid" json:"-" `
Name string `db:"name" json:"name,omitempty" `
Status SetupExperienceStatusResultStatus `db:"status" json:"status,omitempty" `
SoftwareInstallerID *uint `db:"software_installer_id" json:"-" `
HostSoftwareInstallsExecutionID *string `db:"host_software_installs_execution_id" json:"-" `
VPPAppTeamID *uint `db:"vpp_app_team_id" json:"-" `
VPPAppAdamID *string `db:"vpp_app_adam_id" json:"-"`
VPPAppPlatform *string `db:"vpp_app_platform" json:"-"`
NanoCommandUUID *string `db:"nano_command_uuid" json:"-" `
SetupExperienceScriptID *uint `db:"setup_experience_script_id" json:"-" `
ScriptContentID *uint `db:"script_content_id" json:"-"`
ScriptExecutionID *string `db:"script_execution_id" json:"execution_id,omitempty" `
Error *string `db:"error" json:"error" `
// PolicyGated marks a Windows/Linux setup-experience software item whose installer has at least one gating policy (a
// team policy with an install-software automation pointing at the same installer). It is resolved server-side at
// enqueue time and is internal (json:"-"). When set, the item is installed only if some in-scope gating policy
// fails, and skipped if every one passes; the set of gating policies is derived from the installer at decision time.
// False for un-gated items. It only ever qualifies a software-installer row.
PolicyGated bool `db:"policy_gated" json:"-"`
// SoftwareTitleID must be filled through a JOIN
SoftwareTitleID *uint `json:"software_title_id,omitempty" db:"software_title_id"`
// Source must be filled through a JOIN. It indicates the source of the software
// (e.g., "sh_packages", "ps1_packages", "apps", etc.) and is used by the frontend
// to determine appropriate UI display (e.g., "Run" vs "Install" verbs).
Source *string `json:"source,omitempty" db:"source"`
// DisplayName and IconURL are populated by ListSetupExperienceResultsByHostUUID and
// are only used for display purposes in the UI.
DisplayName string `json:"display_name,omitempty" db:"-"`
IconURL string `json:"icon_url,omitempty" db:"-"`
}
func (s *SetupExperienceStatusResult) IsValid() error {
var colsSet uint
if s.SoftwareInstallerID != nil {
colsSet++
if s.NanoCommandUUID != nil || s.ScriptExecutionID != nil {
return fmt.Errorf("invalid setup experience status row, software_installer_id set with incorrect secondary value column: %d", s.ID)
}
}
if s.VPPAppTeamID != nil {
colsSet++
if s.HostSoftwareInstallsExecutionID != nil || s.ScriptExecutionID != nil {
return fmt.Errorf("invalid setup experience status row, vpp_app_team set with incorrect secondary value column: %d", s.ID)
}
}
if s.SetupExperienceScriptID != nil {
colsSet++
if s.HostSoftwareInstallsExecutionID != nil || s.NanoCommandUUID != nil {
return fmt.Errorf("invalid setup experience status row, setip_experience_script_id set with incorrect secondary value column: %d", s.ID)
}
}
if colsSet > 1 {
return fmt.Errorf("invalid setup experience status row, multiple underlying value columns set: %d", s.ID)
}
if colsSet == 0 {
return fmt.Errorf("invalid setup experience status row, no underlying value colunm set: %d", s.ID)
}
// policy_gated only ever qualifies a software-installer row (Windows/Linux gating); it must never appear on a VPP or script row.
if s.PolicyGated && s.SoftwareInstallerID == nil {
return fmt.Errorf("invalid setup experience status row, policy_gated set without software_installer_id: %d", s.ID)
}
return nil
}
func (s *SetupExperienceStatusResult) VPPAppID() (*VPPAppID, error) {
if s.VPPAppAdamID == nil || s.VPPAppPlatform == nil {
return nil, errors.New("not a VPP app")
}
return &VPPAppID{
AdamID: *s.VPPAppAdamID,
Platform: InstallableDevicePlatform(*s.VPPAppPlatform),
}, nil
}
// IsForScript indicates if this result is for a setup experience script step.
func (s *SetupExperienceStatusResult) IsForScript() bool {
return s.SetupExperienceScriptID != nil
}
// IsForSoftware indicates if this result is for a setup experience software step: either a software
// installer or a VPP app.
func (s *SetupExperienceStatusResult) IsForSoftware() bool {
return s.VPPAppTeamID != nil || s.SoftwareInstallerID != nil
}
// IsForSoftwarePackage indicates if this result is for a setup experience software installer step.
func (s *SetupExperienceStatusResult) IsForSoftwarePackage() bool {
return s.SoftwareInstallerID != nil
}
func (s *SetupExperienceStatusResult) IsForVPPApp() bool {
return s.VPPAppTeamID != nil
}
func (s *SetupExperienceStatusResult) ForMyDevicePage(token string) {
// convert api style iconURL to device token URL
if s.IconURL != "" && s.SoftwareTitleID != nil {
if SoftwareTitleIconURLRegex.MatchString(s.IconURL) {
icon := SoftwareTitleIcon{SoftwareTitleID: *s.SoftwareTitleID}
deviceIconURL := icon.IconUrlWithDeviceToken(token)
s.IconURL = deviceIconURL
}
}
}
type SetupExperienceBootstrapPackageResult struct {
Name string `json:"name"`
Status MDMBootstrapPackageStatus `json:"status"`
}
type SetupExperienceConfigurationProfileResult struct {
ProfileUUID string `json:"profile_uuid"`
Name string `json:"name"`
Status MDMDeliveryStatus `json:"status"`
}
type SetupExperienceAccountConfigurationResult struct {
CommandUUID string `json:"command_uuid"`
Status string `json:"status"`
}
type SetupExperienceVPPInstallResult struct {
HostUUID string
CommandUUID string
CommandStatus string
}
func (r SetupExperienceVPPInstallResult) SetupExperienceStatus() SetupExperienceStatusResultStatus {
switch r.CommandStatus {
case MDMAppleStatusAcknowledged:
return SetupExperienceStatusSuccess
case MDMAppleStatusError, MDMAppleStatusCommandFormatError:
return SetupExperienceStatusFailure
default:
// TODO: is this what we want as the default, what about other possible statuses?
return SetupExperienceStatusPending
}
}
type SetupExperienceSoftwareInstallResult struct {
HostUUID string
ExecutionID string
InstallerStatus SoftwareInstallerStatus
}
func (r SetupExperienceSoftwareInstallResult) SetupExperienceStatus() SetupExperienceStatusResultStatus {
switch r.InstallerStatus {
case SoftwareInstalled:
return SetupExperienceStatusSuccess
case SoftwareFailed, SoftwareInstallFailed:
return SetupExperienceStatusFailure
default:
// TODO: is this what we want as the default, what about other possible statuses (uninstall)?
return SetupExperienceStatusPending
}
}
type SetupExperienceScriptResult struct {
HostUUID string
ExecutionID string
ExitCode int
}
func (r SetupExperienceScriptResult) SetupExperienceStatus() SetupExperienceStatusResultStatus {
if r.ExitCode == 0 {
return SetupExperienceStatusSuccess
}
// TODO: what about other possible script statuses? seems like pending/running is never a
// possibility here (exit code can't be null)?
return SetupExperienceStatusFailure
}
// SetupExperienceStatusPayload is the payload we send to Orbit to tell it what the current status
// of the setup experience is for that host.
type SetupExperienceStatusPayload struct {
Script *SetupExperienceStatusResult `json:"script,omitempty"`
Software []*SetupExperienceStatusResult `json:"software,omitempty"`
BootstrapPackage *SetupExperienceBootstrapPackageResult `json:"bootstrap_package,omitempty" renameto:"macos_bootstrap_package"`
ConfigurationProfiles []*SetupExperienceConfigurationProfileResult `json:"configuration_profiles,omitempty"`
AccountConfiguration *SetupExperienceAccountConfigurationResult `json:"account_configuration,omitempty"`
OrgLogoURL string `json:"org_logo_url"`
RequireAllSoftware bool `json:"require_all_software"`
}
// IsSetupExperienceSupported returns whether "Setup experience" is supported for the host's platform.
// TODO: Setup Experience supports a wide range of platforms now but has a feature matrix where not all
// platforms support all features. May be worth refactoring to check for supported features instead
func IsSetupExperienceSupported(hostPlatform string) bool {
return hostPlatform == "darwin" || hostPlatform == "ios" || hostPlatform == "ipados" ||
hostPlatform == "windows" || hostPlatform == "android" || IsLinux(hostPlatform)
}
// DeviceSetupExperienceStatusPayload holds the status of the "Setup experience" for a device.
type DeviceSetupExperienceStatusPayload struct {
// Software holds the status of the software to install on the device.
Software []*SetupExperienceStatusResult `json:"software,omitempty"`
// Scripts holds the status of the scripts to run on the device.
Scripts []*SetupExperienceStatusResult `json:"scripts,omitempty"`
}
// HostUUIDForSetupExperience returns the host "UUID" to use during the "Setup experience"
// for a non-Apple host.
//
// The setup_experience_status_results uses the host's "UUID" as the host identifier because the table
// was created to implement "Setup experience" for macOS devices.
//
// On Windows/Linux devices there might be issues with duplicate hardware UUIDs, so for that reason we will instead
// use the host.OsqueryHostID as UUID. For Windows/Linux devices, the "Setup experience" will be triggered after orbit
// and osquery enrollment, thus host.OsqueryHostID will always be set and unique.
func HostUUIDForSetupExperience(host *Host) (string, error) {
if host.Platform == string(MacOSPlatform) || host.Platform == string(IOSPlatform) || host.Platform == string(IPadOSPlatform) ||
host.Platform == string(AndroidPlatform) {
return host.UUID, nil
}
// Currently it seems this field is always set when orbit or osquery enroll,
// to be safe we return an error when that's the case (instead of panicking).
if host.OsqueryHostID == nil {
return "", errors.New("missing osquery_host_id")
}
return *host.OsqueryHostID, nil
}
type SetupExperienceCount struct {
Installers uint `db:"installers"`
Scripts uint `db:"scripts"`
VPP uint `db:"vpp"`
}
var SetupExperienceSupportedPlatforms = []string{
"macos",
"ios",
"ipados",
"windows",
"linux",
"android",
}