Files
fleet/ee/server/service/setup_experience.go
Jordan Montgomery 89f67544b4 Add support for fleet vars in scripts(controls scripts, software scripts/script-only packages and setup experience scripts) (#49781)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #49511  and #46837 as a whole

# 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] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

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


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

* **New Features**
* Added support for Fleet built-in variables in host scripts, software
installer scripts, setup-experience scripts, and maintained-app
installer scripts.
* Variables are resolved per host at execution time; saved content
remains unexpanded.
* **Bug Fixes**
* Requests now validate Fleet variables up-front, with clear
script-specific error messages for unsupported variables.
* Added improved messaging when variable resolution fails during
execution.
* Enforced Fleet Premium licensing for script/installer flows that use
Fleet variables.
* **Documentation**
* Documented supported variables and Premium requirements, including
usage examples.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-27 11:24:42 +02:00

588 lines
25 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"errors"
"io"
"net/http"
"path/filepath"
"strconv"
"time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
)
// setupExperienceGatingPolicyTimeout bounds how long a policy-gated setup-experience item waits for its in-scope gating
// policies to produce a result before failing open (installing). A gating policy can fail to ever yield a fresh
// pass/fail (denylisted by osquery for bad performance, watchdog-killed, a query that errors, etc.) and all of those
// surface identically as "no result".
const setupExperienceGatingPolicyTimeout = 30 * time.Minute
func (svc *Service) SetSetupExperienceSoftware(ctx context.Context, platform string, teamID uint, titleIDs []uint) error {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: &teamID}, fleet.ActionWrite); err != nil {
return err
}
macosHasManualAgentInstall := false
var teamName string
if teamID == 0 {
teamName = ""
ac, err := svc.ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting app config")
}
macosHasManualAgentInstall = ac.MDM.MacOSSetup.ManualAgentInstall.Value
} else {
team, err := svc.ds.TeamLite(ctx, teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "load team")
}
teamName = team.Name
macosHasManualAgentInstall = team.Config.MDM.MacOSSetup.ManualAgentInstall.Value
}
if macosHasManualAgentInstall && fleet.IsMacOSPlatform(platform) && len(titleIDs) != 0 {
return fleet.NewUserMessageError(errors.New("Couldnt add setup experience software. To add software, first disable macos_manual_agent_install."), http.StatusUnprocessableEntity)
}
if err := svc.ds.SetSetupExperienceSoftwareTitles(ctx, platform, teamID, titleIDs); err != nil {
return ctxerr.Wrap(ctx, err, "setting setup experience titles")
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityEditedSetupExperienceSoftware{
Platform: platform,
TeamID: teamID,
TeamName: teamName,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for set setup experience software")
}
return nil
}
func (svc *Service) ListSetupExperienceSoftware(ctx context.Context, platform string, teamID uint, opts fleet.ListOptions) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) {
if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{
TeamID: &teamID,
}, fleet.ActionRead); err != nil {
return nil, 0, nil, err
}
titles, count, meta, err := svc.ds.ListSetupExperienceSoftwareTitles(ctx, platform, teamID, opts)
if err != nil {
return nil, 0, nil, ctxerr.Wrap(ctx, err, "retrieving list of software setup experience titles")
}
return titles, count, meta, nil
}
func (svc *Service) GetSetupExperienceScript(ctx context.Context, teamID *uint, withContent bool) (*fleet.Script, []byte, error) {
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, nil, err
}
script, err := svc.ds.GetSetupExperienceScript(ctx, teamID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "get setup experience script")
}
var content []byte
if withContent {
content, err = svc.ds.GetAnyScriptContents(ctx, script.ScriptContentID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "get setup experience script contents")
}
}
return script, content, nil
}
func (svc *Service) SetSetupExperienceScript(ctx context.Context, teamID *uint, name string, r io.Reader) error {
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil {
return err
}
var teamName *string
if teamID == nil {
ac, err := svc.ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting app config")
}
if ac.MDM.MacOSSetup.ManualAgentInstall.Value {
return fleet.NewUserMessageError(errors.New("Couldnt add setup experience script. To add script, first disable macos_manual_agent_install."), http.StatusUnprocessableEntity)
}
} else {
team, err := svc.ds.TeamLite(ctx, *teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "load team")
}
if team.Config.MDM.MacOSSetup.ManualAgentInstall.Value {
return fleet.NewUserMessageError(errors.New("Couldnt add setup experience script. To add script, first disable macos_manual_agent_install."), http.StatusUnprocessableEntity)
}
teamName = &team.Name
}
b, err := io.ReadAll(r)
if err != nil {
return ctxerr.Wrap(ctx, err, "read setup experience script contents")
}
script := &fleet.Script{
TeamID: teamID,
Name: name,
ScriptContents: string(b),
}
if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{script.ScriptContents}); err != nil {
return fleet.NewInvalidArgumentError("script", err.Error())
}
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{script.ScriptContents}); err != nil {
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
return ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
}
return fleet.NewInvalidArgumentError("script", err.Error())
}
if err := fleet.ValidateFleetVariablesInScript(script.ScriptContents, license.IsPremium(ctx)); err != nil {
return err
}
// setup experience is only supported for macOS currently so we need to override the file
// extension check in the general script validation
if filepath.Ext(script.Name) != ".sh" {
return fleet.NewInvalidArgumentError("script", "File type not supported. Only .sh file type is allowed.")
}
// now we can do our normal script validation
if err := script.ValidateNewScript(); err != nil {
return fleet.NewInvalidArgumentError("script", err.Error())
}
changed, err := svc.ds.SetSetupExperienceScript(ctx, script)
if err != nil {
var (
existsErr fleet.AlreadyExistsError
fkErr fleet.ForeignKeyError
)
if errors.As(err, &existsErr) {
err = fleet.NewInvalidArgumentError("script", err.Error()).WithStatus(http.StatusConflict) // TODO: confirm error message with product/frontend
} else if errors.As(err, &fkErr) {
err = fleet.NewInvalidArgumentError("team_id/fleet_id", "The fleet does not exist.").WithStatus(http.StatusNotFound)
}
return ctxerr.Wrap(ctx, err, "create setup experience script")
}
if !changed {
return nil
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityCreatedSetupExperienceScript{
FleetID: teamID,
FleetName: teamName,
ScriptName: name,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for set setup experience script")
}
return nil
}
func (svc *Service) DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error {
if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil {
return err
}
// Load the script first so we can skip the activity when there is nothing to delete (GitOps
// clears a script on every apply even when none was set) and so we can include its name.
script, err := svc.ds.GetSetupExperienceScript(ctx, teamID)
if err != nil {
if fleet.IsNotFound(err) {
return nil
}
return ctxerr.Wrap(ctx, err, "get setup experience script for delete")
}
var teamName *string
if teamID != nil {
team, err := svc.ds.TeamLite(ctx, *teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "load team for setup experience activity")
}
teamName = &team.Name
}
if err := svc.ds.DeleteSetupExperienceScript(ctx, teamID); err != nil {
return ctxerr.Wrap(ctx, err, "delete setup experience script")
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityDeletedSetupExperienceScript{
FleetID: teamID,
FleetName: teamName,
ScriptName: script.Name,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for delete setup experience script")
}
return nil
}
func (svc *Service) SetupExperienceNextStep(ctx context.Context, host *fleet.Host) (bool, error) {
// NOTE: currently, the Android platform does not go through the step-by-step setup experience flow as it
// doesn't support any on-device UI (such as the screen showing setup progress) nor any
// ordering of installs - all software to install is provided as part of the Android policy
// when the host enrolls in Fleet.
// See https://github.com/fleetdm/fleet/issues/33761#issuecomment-3548996114
hostUUID, err := fleet.HostUUIDForSetupExperience(host)
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, ptr.ValOrZero(host.TeamID))
if err != nil {
return false, ctxerr.Wrap(ctx, err, "retrieving setup experience status results for next step")
}
// 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 {
return false, ctxerr.Wrap(ctx, err, "invalid row")
}
switch {
case status.IsForSoftware():
switch status.Status {
case fleet.SetupExperienceStatusPending:
softwarePending = append(softwarePending, status)
case fleet.SetupExperienceStatusRunning:
softwareRunning++
}
case status.IsForScript():
switch status.Status {
case fleet.SetupExperienceStatusPending:
scriptsPending = append(scriptsPending, status)
case fleet.SetupExperienceStatusRunning:
scriptsRunning++
}
}
}
// Re-check any policy-gated software item that is running but awaiting its policy result (it has a policy but no install
// enqueued yet). This must run on every poll because, unlike an installing item, no install-completion callback will
// re-trigger this flow for it. Such an item counts toward softwareRunning, so it also correctly blocks the next item from
// starting until it resolves (skip or install).
for _, status := range statuses {
if status.IsForSoftware() && status.Status == fleet.SetupExperienceStatusRunning &&
status.PolicyGated && status.HostSoftwareInstallsExecutionID == nil {
if err := svc.advancePolicyGatedSetupExperienceItem(ctx, host, status); err != nil {
return false, err
}
// One software item is in flight at a time; defer further progress to the next poll or install callback.
return false, nil
}
}
switch {
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).
//
// Un-gated items are started before policy-gated ones: a gated item may sit in "running" while it waits for the host's
// labels and the gating policy result, and we don't want that wait to delay the un-gated installs (which need neither).
// Alphabetical order is preserved within each group (softwarePending is already ordered by sesr.id).
sw := softwarePending[0]
for _, pending := range softwarePending {
if !pending.PolicyGated {
sw = pending
break
}
}
switch {
case sw.SoftwareInstallerID != nil:
if sw.PolicyGated {
// Policy-gated (Windows/Linux): run the associated policy as a gate. Flip to running (awaiting policy) and act on
// a fresh result if one is already available; otherwise wait for the next poll. The install, when needed, is
// performed through the normal setup-experience path below so it inherits the retry count and the
// RequireAllSoftwareWindows handling.
if err := svc.advancePolicyGatedSetupExperienceItem(ctx, host, sw); err != nil {
return false, err
}
return false, 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")
}
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 sw.VPPAppTeamID != nil:
vppAppID, err := sw.VPPAppID()
if err != nil {
return false, ctxerr.Wrap(ctx, err, "constructing vpp app details for installation")
}
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: *sw.SoftwareTitleID,
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: *vppAppID,
},
}
cmdUUID, err := svc.installSoftwareFromVPP(ctx, host, vppApp, true, fleet.HostSoftwareInstallOptions{
SelfService: false,
ForSetupExperience: true,
})
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", sw.VPPAppAdamID)
sw.Status = fleet.SetupExperienceStatusFailure
sw.Error = ptr.String(err.Error())
// Persist the failure before cancelling other steps, so that
// maybeCancelPendingSetupExperienceSteps can find the failed
// item from its loaded statuses.
if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, sw); err != nil {
return false, ctxerr.Wrap(ctx, err, "updating setup experience with vpp install failure")
}
failActivity := fleet.ActivityInstalledAppStoreApp{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
SoftwareTitle: sw.Name,
AppStoreID: ptr.ValOrZero(sw.VPPAppAdamID),
Status: string(fleet.SoftwareInstallFailed),
HostPlatform: host.Platform,
FromSetupExperience: true,
}
if actErr := svc.NewActivity(ctx, nil, failActivity); actErr != nil {
svc.logger.WarnContext(ctx, "failed to create activity for VPP app install failure during setup experience", "err", actErr)
}
// 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)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "checking if all software is required after vpp app install failure")
}
if requireAllSoftware {
err := svc.MaybeCancelPendingSetupExperienceSteps(ctx, host)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "cancelling remaining setup experience steps after vpp app install failure")
}
}
} else {
sw.NanoCommandUUID = &cmdUUID
sw.Status = fleet.SetupExperienceStatusRunning
if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, sw); err != nil {
return false, ctxerr.Wrap(ctx, err, "updating setup experience with vpp install command uuid")
}
}
}
case softwareRunning == 0 && len(scriptsPending) > 0:
// enqueue scripts
for _, script := range scriptsPending {
if script.ScriptContentID == nil {
return false, ctxerr.Errorf(ctx, "setup experience script missing content id: %d", *script.SetupExperienceScriptID)
}
req := &fleet.HostScriptRequestPayload{
HostID: host.ID,
ScriptName: script.Name,
ScriptContentID: *script.ScriptContentID,
// because the script execution request is associated with setup experience,
// it will be enqueued with a higher priority and will run before other
// items in the queue.
SetupExperienceScriptID: script.SetupExperienceScriptID,
}
res, err := svc.ds.NewHostScriptExecutionRequest(ctx, req)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "queueing setup experience script execution request")
}
script.ScriptExecutionID = &res.ExecutionID
script.Status = fleet.SetupExperienceStatusRunning
if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, script); err != nil {
return false, ctxerr.Wrap(ctx, err, "updating setup experience script execution id")
}
}
case softwareRunning == 0 && scriptsRunning == 0:
// finished: if any item was policy-gated, reset the host policy clock now (the host has left setup experience) so its full
// policy set re-runs promptly instead of waiting up to a full policy update interval.
if err := svc.resetPolicyClockAfterGatedSetup(ctx, host, statuses); err != nil {
return false, err
}
return true, nil
}
return false, nil
}
// advancePolicyGatedSetupExperienceItem drives a policy-gated Windows/Linux setup-experience software item. The item's installer
// can be gated by several policies (all those whose install-software automation points at it); they gate as a set: the install is
// skipped only if EVERY in-scope gating policy passes, and run if ANY of them fails (the app is missing or outdated for at least
// one gate). A failing gate installs through the normal setup-experience path so the install inherits the setup-experience retry
// count and RequireAllSoftwareWindows handling. The item is held running until enough fresh (this-enrollment) results arrive, or
// until setupExperienceGatingPolicyTimeout elapses with an in-scope policy still unreported, at which point it fails open and
// installs (so a denylisted or never-reporting gating policy cannot wedge setup experience).
//
// Order matters: scope is resolved before any result is trusted. A policy's scope (platform + include/exclude labels) is only
// knowable once the host has reported its labels for this enrollment, and a result reported before scope is known cannot be
// trusted (e.g. an exclude-label policy can run on the first read, before its exclusion is computed). So we (1) wait for labels,
// (2) compute the in-scope gating policies and install if none apply, and only then (3) aggregate their results.
func (svc *Service) advancePolicyGatedSetupExperienceItem(ctx context.Context, host *fleet.Host, sw *fleet.SetupExperienceStatusResult) error {
// Set running on the struct only (not yet persisted); the single persist below holds the softwareRunning==0 guard so
// no other item starts while we wait.
alreadyRunning := sw.Status == fleet.SetupExperienceStatusRunning
sw.Status = fleet.SetupExperienceStatusRunning
// keepWaiting holds the item in "running" awaiting label/policy results. It persists the pending->running transition only once;
// on later polls nothing has changed, so it skips the redundant UPDATE to avoid write amplification while waiting.
keepWaiting := func() error {
if alreadyRunning {
return nil
}
return svc.ds.UpdateSetupExperienceStatusResult(ctx, sw)
}
// (1) The gate is only meaningful once the host has reported its labels for this enrollment: a policy's scope
// (platform plus include/exclude labels) decides whether it applies, and a freshly enrolled host hasn't computed
// dynamic label membership yet. Until then neither the scope check nor a result can be trusted.. Every host receives
// the platform='' built-in dynamic labels (e.g. "All Linux"), so label_updated_at is guaranteed to advance on the
// first post-enroll checkin; until then, keep waiting.
if host.LabelUpdatedAt.Before(host.LastEnrolledAt) {
return keepWaiting()
}
// (2) All policies gating this item's installer, narrowed to those in scope for the host now that labels are computed. If none
// apply (every gating policy's platform/label scope excludes the host) the gate doesn't apply -> install. Resolving scope here,
// before consuming any result, also means a result reported by a now-out-of-scope policy can't wrongly influence the gate.
policyIDs, err := svc.ds.GetSetupExperiencePolicyIDsForInstaller(ctx, *sw.SoftwareInstallerID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get setup experience gating policy ids for installer")
}
inScope, err := svc.ds.PolicyQueriesForHostFiltered(ctx, host, policyIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "check gating policy deliverability")
}
if len(inScope) == 0 {
svc.logger.InfoContext(ctx, "setup experience: no gating policy applies to host; installing item",
"host_id", host.ID, "software_installer_id", *sw.SoftwareInstallerID)
return svc.enqueueSetupExperienceSoftwareInstall(ctx, host, sw)
}
// (3) Aggregate the in-scope policies' fresh results: install as soon as any fails; otherwise the install is skipped only once
// every in-scope policy has reported a pass. While some have not reported yet (and none has failed), keep waiting.
anyPending := false
for idStr := range inScope {
// Policy IDs are MySQL int unsigned (32-bit); parse with bitSize 32 so the uint() conversion below is a safe widening
// (matches how the rest of the codebase parses ID strings, e.g. transport.go).
policyID, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
return ctxerr.Wrap(ctx, err, "parse gating policy id")
}
passes, err := svc.ds.GetSetupExperiencePolicyResult(ctx, host.ID, uint(policyID), host.LastEnrolledAt)
if err != nil {
return ctxerr.Wrap(ctx, err, "get setup experience policy result")
}
switch {
case passes == nil:
anyPending = true
case !*passes:
// At least one in-scope gating policy failed (app missing or outdated): install via the normal setup-experience path.
svc.logger.DebugContext(ctx, "setup experience gating policy failed; installing item",
"host_id", host.ID, "policy_id", policyID, "software_installer_id", *sw.SoftwareInstallerID)
return svc.enqueueSetupExperienceSoftwareInstall(ctx, host, sw)
}
}
if anyPending {
// Fail open if the in-scope gating policies have not produced a result within the bound: a policy query that is
// denylisted, watchdog-killed, or erroring never yields a fresh pass/fail, and waiting forever would wedge setup
// experience (block every later item, and on Linux there is no ESP timeout to cancel it). Anchored on LastEnrolledAt,
// which is fixed for this enrollment and is the same cutoff used for result freshness.
if svc.clock.Now().Sub(host.LastEnrolledAt) > setupExperienceGatingPolicyTimeout {
svc.logger.WarnContext(ctx, "setup experience: gating policy produced no result within the wait bound; installing item (fail open)",
"host_id", host.ID, "software_installer_id", *sw.SoftwareInstallerID)
return svc.enqueueSetupExperienceSoftwareInstall(ctx, host, sw)
}
return keepWaiting()
}
// Every in-scope gating policy passed (app present and up-to-date): skip the install (terminal success).
sw.Status = fleet.SetupExperienceStatusSuccess
svc.logger.DebugContext(ctx, "setup experience: all gating policies passed; skipping install",
"host_id", host.ID, "software_installer_id", *sw.SoftwareInstallerID)
return svc.ds.UpdateSetupExperienceStatusResult(ctx, sw)
}
// resetPolicyClockAfterGatedSetup resets the host's "last reported policies" clock when setup experience finishes, if any of its
// items was policy-gated (Windows/Linux). During setup only the gated policy subset is distributed/reported, which advances the
// host-wide policy_updated_at and would otherwise delay the host's remaining policies by up to a full policy update interval once
// setup ends.
func (svc *Service) resetPolicyClockAfterGatedSetup(ctx context.Context, host *fleet.Host, statuses []*fleet.SetupExperienceStatusResult) error {
gated := false
for _, s := range statuses {
if s.PolicyGated {
gated = true
break
}
}
if !gated {
return nil
}
if err := svc.ds.ClearHostPolicyUpdatedAt(ctx, host.ID); err != nil {
return ctxerr.Wrap(ctx, err, "reset host policy clock after setup experience")
}
return nil
}
// enqueueSetupExperienceSoftwareInstall enqueues a software installer item the same way an un-gated setup-experience item is
// enqueued (ForSetupExperience priority, the item owns its host_software_installs execution), and marks the item running.
func (svc *Service) enqueueSetupExperienceSoftwareInstall(ctx context.Context, host *fleet.Host, sw *fleet.SetupExperienceStatusResult) error {
installUUID, err := svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, *sw.SoftwareInstallerID, fleet.HostSoftwareInstallOptions{
SelfService: false,
ForSetupExperience: true,
})
if err != nil {
return ctxerr.Wrap(ctx, err, "queueing setup experience install request")
}
sw.HostSoftwareInstallsExecutionID = &installUUID
sw.Status = fleet.SetupExperienceStatusRunning
if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, sw); err != nil {
return ctxerr.Wrap(ctx, err, "updating setup experience result with install uuid")
}
return nil
}