fleet/ee/server/service/setup_experience.go
Lucas Manuel Rodriguez 29475ab55e
API endpoints for Linux setup experience (#32493)
For #32040.

---

Backend changes to unblock the development of the orbit and frontend
changes.

New GET and PUT APIs for setting/getting software for Linux Setup
Experience:
```
curl -k -X GET -H "Authorization: Bearer $TEST_TOKEN" https://localhost:8080/api/latest/fleet/setup_experience/linux/software?team_id=8&per_page=3000
curl -k -X PUT -H "Authorization: Bearer $TEST_TOKEN" https://localhost:8080/api/latest/fleet/setup_experience/linux/software -d '{"team_id":8,"software_title_ids":[3000, 3001, 3007]}'
```

New setup_experience/init API called by orbit to trigger the Linux setup
experience on the device:
```
curl -v -k -X POST -H "Content-Type: application/json" "https://localhost:8080/api/fleet/orbit/setup_experience/init" -d '{"orbit_node_key": "ynYEtFsvv9xZ7rX619UE8of1I28H+GCj"}'
```

Get status API to call on "My device":
```
curl -v -k -X POST "https://localhost:8080/api/latest/fleet/device/7d940b6e-130a-493b-b58a-2b6e9f9f8bfc/setup_experience/status"
```

---

- [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.

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

## New Fleet configuration settings

- [X] Verified that the setting is exported via `fleetctl
generate-gitops`
- [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)
- [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)

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

## Summary by CodeRabbit

- New Features
  - Added Linux support for Setup Experience alongside macOS.
- Introduced platform-specific admin APIs to configure and retrieve
Setup Experience software (macOS/Linux).
- Added device API to report Setup Experience status and an Orbit API to
initialize Setup Experience on non-macOS devices.
- Setup Experience now gates policy queries on Linux until setup is
complete.
- New activity log entry when Setup Experience software is edited
(includes platform and team).

- Documentation
- Updated audit logs reference to include the new “edited setup
experience software” event.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-04 12:58:47 -03:00

280 lines
9.4 KiB
Go

package service
import (
"context"
"errors"
"io"
"net/http"
"path/filepath"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/log/level"
)
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
}
var teamName string
if teamID == 0 {
teamName = ""
} else {
team, err := svc.ds.Team(ctx, teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "load team")
}
teamName = team.Name
}
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
}
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())
}
// 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())
}
if err := svc.ds.SetSetupExperienceScript(ctx, script); 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", "The team does not exist.").WithStatus(http.StatusNotFound)
}
return ctxerr.Wrap(ctx, err, "create setup experience script")
}
// NOTE: there is no activity specified 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
}
if err := svc.ds.DeleteSetupExperienceScript(ctx, teamID); err != nil {
return ctxerr.Wrap(ctx, err, "delete setup experience script")
}
// NOTE: there is no activity specified for delete setup experience script
return nil
}
func (svc *Service) SetupExperienceNextStep(ctx context.Context, host *fleet.Host) (bool, error) {
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)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "retrieving setup experience status results for next step")
}
var installersPending, appsPending, scriptsPending []*fleet.SetupExperienceStatusResult
var installersRunning, appsRunning, scriptsRunning int
for _, status := range statuses {
if err := status.IsValid(); err != nil {
return false, ctxerr.Wrap(ctx, err, "invalid row")
}
switch {
case status.SoftwareInstallerID != nil:
switch status.Status {
case fleet.SetupExperienceStatusPending:
installersPending = append(installersPending, status)
case fleet.SetupExperienceStatusRunning:
installersRunning++
}
case status.VPPAppTeamID != nil:
switch status.Status {
case fleet.SetupExperienceStatusPending:
appsPending = append(appsPending, status)
case fleet.SetupExperienceStatusRunning:
appsRunning++
}
case status.SetupExperienceScriptID != nil:
switch status.Status {
case fleet.SetupExperienceStatusPending:
scriptsPending = append(scriptsPending, status)
case fleet.SetupExperienceStatusRunning:
scriptsRunning++
}
}
}
switch {
case len(installersPending) > 0:
// enqueue installers
for _, installer := range installersPending {
installUUID, err := svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, *installer.SoftwareInstallerID, fleet.HostSoftwareInstallOptions{
SelfService: false,
ForSetupExperience: true,
})
if err != nil {
return false, ctxerr.Wrap(ctx, err, "queueing setup experience install request")
}
installer.HostSoftwareInstallsExecutionID = &installUUID
installer.Status = fleet.SetupExperienceStatusRunning
if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, installer); err != nil {
return false, ctxerr.Wrap(ctx, err, "updating setup experience result with install uuid")
}
}
case installersRunning == 0 && len(appsPending) > 0:
// enqueue vpp apps
for _, app := range appsPending {
vppAppID, err := app.VPPAppID()
if err != nil {
return false, ctxerr.Wrap(ctx, err, "constructing vpp app details for installation")
}
if app.SoftwareTitleID == nil {
return false, ctxerr.Errorf(ctx, "setup experience software title id missing from vpp app install request: %d", app.ID)
}
vppApp := &fleet.VPPApp{
TitleID: *app.SoftwareTitleID,
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: *vppAppID,
},
}
cmdUUID, err := svc.installSoftwareFromVPP(ctx, host, vppApp, true, fleet.HostSoftwareInstallOptions{
SelfService: false,
ForSetupExperience: true,
})
app.NanoCommandUUID = &cmdUUID
app.Status = fleet.SetupExperienceStatusRunning
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.
level.Warn(svc.logger).Log("msg", "got an error when attempting to enqueue VPP app install", "err", err, "adam_id", app.VPPAppAdamID)
app.Status = fleet.SetupExperienceStatusFailure
app.Error = ptr.String(err.Error())
}
if err := svc.ds.UpdateSetupExperienceStatusResult(ctx, app); err != nil {
return false, ctxerr.Wrap(ctx, err, "updating setup experience with vpp install command uuid")
}
}
case installersRunning == 0 && appsRunning == 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 installersRunning == 0 && appsRunning == 0 && scriptsRunning == 0:
// finished
return true, nil
}
return false, nil
}