Files
fleet/ee/server/service/software_installers.go
T
Carlo a442d7af3a Python script-only packages: follow-on QA fixes (#50143)
**Related issues:** Resolves #50068, Resolves #50106, Resolves #50107,
Resolves #50108, Resolves #50110, Resolves #50114

Follow-on fixes from QA of #41470 (Python script-only packages):

- Software-installer validation errors are action-neutral, so the Add
and Edit flows each show the correct single verb, and the
unsupported-file error names a content/format mismatch instead of
blaming the extension (#50068, #50107).
- `.py` packages accept `setup_experience_platform` (`darwin`/`linux`),
matching `.sh` (#50106).
- A failed-to-run install script (exit code `-1`) now renders a
diagnostic instead of empty output, and orbit surfaces the underlying
execve error (#50108).
- The install-rejection message for `.sh`/`.py` packages says "macOS and
Linux hosts" instead of "linux" (#50110).
- Orbit writes each script's temp file with an extension matching its
shebang (`.py`/`.sh`/`.ps1`), so tracebacks reference the right file
type (#50114).

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`.

## Testing

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

## fleetd/orbit/Fleet Desktop

- [x] If the change applies to only one platform, confirmed that
`runtime.GOOS` is used as needed to isolate changes.
- [x] Verified compatibility with the latest released version of Fleet
(orbit-only change; the server↔agent `SoftwareInstallDetails` contract
is unchanged).


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

* **Bug Fixes**
* Improved installer validation and rejection messaging for
unsupported/invalid package contents (including correcting “add” vs
“edit” wording and avoiding duplicated phrasing).
* Added clearer diagnostics when install scripts fail to start
(including empty output cases).
* Corrected handling of script-only packages so Python scripts use the
proper script type/extension, reducing misleading tracebacks.
* Updated platform availability messaging so `.sh`/`.py` packages
display macOS+Linux support.
* **New Features**
* Python script-only packages can now specify macOS and Linux setup
experience platforms.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-30 14:40:24 -04:00

4502 lines
174 KiB
Go

package service
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"path"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/pkg/retry"
"github.com/fleetdm/fleet/v4/server/authz"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/installersize"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
maintained_apps "github.com/fleetdm/fleet/v4/server/mdm/maintainedapps"
nanomdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/variables"
"github.com/fleetdm/fleet/v4/server/worker"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
)
const softwareInstallerTokenMaxLength = 36 // UUID length
func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: payload.TeamID}, fleet.ActionWrite); err != nil {
return nil, err
}
if payload.AutomaticInstall {
// Currently, same write permissions are applied on software and policies,
// but leaving this here in case it changes in the future.
if err := svc.authz.Authorize(ctx, &fleet.Policy{PolicyData: fleet.PolicyData{TeamID: payload.TeamID}}, fleet.ActionWrite); err != nil {
return nil, err
}
}
// validate labels before we do anything else
validatedLabels, err := ValidateSoftwareLabels(ctx, svc, payload.TeamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating software labels")
}
payload.ValidatedLabels = validatedLabels
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
payload.UserID = vc.UserID()
// make sure all scripts use unix-style newlines to prevent errors when
// running them, browsers use windows-style newlines, which breaks the
// shebang when the file is directly executed.
payload.InstallScript = file.Dos2UnixNewlines(payload.InstallScript)
payload.PostInstallScript = file.Dos2UnixNewlines(payload.PostInstallScript)
payload.UninstallScript = file.Dos2UnixNewlines(payload.UninstallScript)
failOnBlankScript := !strings.HasSuffix(payload.Filename, ".ipa")
if _, err := svc.addMetadataToSoftwarePayload(ctx, payload, failOnBlankScript); err != nil {
return nil, ctxerr.Wrap(ctx, err, "adding metadata to payload")
}
// Validate iOS/iPadOS managed app configuration up-front. For non-.ipa extensions, silently drop.
if payload.Extension == "ipa" {
if len(payload.Configuration) > 0 {
if err := fleet.ValidateAppleAppConfiguration(payload.Configuration); err != nil {
return nil, err
}
}
} else {
payload.Configuration = nil
}
// A script package's install script is the uploaded file, validated in
// addScriptPackageMetadata, so only post-install/uninstall are checked here.
scriptsToValidate := []struct {
name string
content string
}{
{"post-install script", payload.PostInstallScript},
{"uninstall script", payload.UninstallScript},
}
if !fleet.IsScriptPackage(payload.Extension) {
scriptsToValidate = append(scriptsToValidate, struct {
name string
content string
}{"install script", payload.InstallScript})
}
for _, scriptVal := range scriptsToValidate {
if err := fleet.ValidateSoftwareInstallerScript(scriptVal.content, payload.Platform); err != nil {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add. %s validation failed: %s", scriptVal.name, err.Error()),
}
}
}
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{payload.InstallScript, payload.PostInstallScript, payload.UninstallScript}); err != nil {
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
return nil, ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
}
// Redo per-script to report which script references the undefined custom host vital.
var argErr *fleet.InvalidArgumentError
argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "install script", &payload.InstallScript, argErr)
argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "post-install script", &payload.PostInstallScript, argErr)
argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "uninstall script", &payload.UninstallScript, argErr)
if argErr != nil {
return nil, argErr
}
return nil, ctxerr.Wrap(ctx, err, "transient server issue validating custom host vitals")
}
if err := validateFleetVariablesOnInstallerScripts(ctx, &payload.InstallScript, &payload.PostInstallScript, &payload.UninstallScript); err != nil {
return nil, err
}
if payload.AutomaticInstall && payload.AutomaticInstallQuery == "" {
switch {
//
// For "msi", addMetadataToSoftwarePayload fails before this point if product code cannot be extracted.
//
case payload.Extension == "exe" || payload.Extension == "tar.gz" || fleet.IsScriptPackage(payload.Extension):
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add. Fleet can't create a policy to detect existing installations for .%s packages. Please add the software, add a custom policy, and enable the install software policy automation.", payload.Extension),
}
case payload.Extension == "pkg" && payload.BundleIdentifier == "":
// For pkgs without bundle identifier the request usually fails before reaching this point,
// but addMetadataToSoftwarePayload may not fail if the package has "package IDs" but not a "bundle identifier",
// in which case we want to fail here because we cannot generate a policy without a bundle identifier.
return nil, &fleet.BadRequestError{
Message: "Couldn't add. Policy couldn't be created because bundle identifier can't be extracted.",
}
}
}
if err := svc.storeSoftware(ctx, payload); err != nil {
return nil, ctxerr.Wrap(ctx, err, "storing software installer")
}
// Update $PACKAGE_ID/$UPGRADE_CODE in uninstall script
if err := preProcessUninstallScript(payload); err != nil {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add software: %s", err),
}
}
if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{payload.InstallScript, payload.PostInstallScript, payload.UninstallScript}); err != nil {
// We redo the validation on each script to find out which script has the missing secret.
// This is done to provide a more informative error message to the UI user.
var argErr *fleet.InvalidArgumentError
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "install script", &payload.InstallScript, argErr)
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "post-install script", &payload.PostInstallScript, argErr)
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "uninstall script", &payload.UninstallScript, argErr)
if argErr != nil {
return nil, argErr
}
// We should not get to this point. If we did, it means we have another issue, such as large read replica latency.
return nil, ctxerr.Wrap(ctx, err, "transient server issue validating embedded secrets")
}
installerID, titleID, err := svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "matching or creating software installer")
}
svc.logger.DebugContext(ctx, "software installer uploaded", "installer_id", installerID)
var teamName *string
if payload.TeamID != nil && *payload.TeamID != 0 {
t, err := svc.ds.TeamLite(ctx, *payload.TeamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting team name on upload software installer")
}
teamName = &t.Name
}
actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels)
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
TeamName: teamName,
TeamID: payload.TeamID,
SelfService: payload.SelfService,
SoftwareTitleID: titleID,
LabelsIncludeAny: actLabelsInclAny,
LabelsExcludeAny: actLabelsExclAny,
LabelsIncludeAll: actLabelsInclAll,
}); err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating activity for added software")
}
// get values for response object
var tmID uint
if payload.TeamID != nil {
tmID = *payload.TeamID
}
if payload.Extension == "ipa" {
addedInstaller, err := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &tmID, titleID)
if err != nil {
return nil, err
}
// Wrap iOS / iPadOS plist as a JSON string for the response.
if len(addedInstaller.Configuration) > 0 {
wrapped, err := json.Marshal(string(addedInstaller.Configuration))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "wrapping configuration for response")
}
addedInstaller.Configuration = wrapped
}
return addedInstaller, nil
}
// Return the package just added, not the title's first-added one.
addedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctxdb.RequirePrimary(ctx, true), &tmID, titleID, installerID, true)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting added software installer")
}
if payload.AutomaticInstall && payload.AddedAutomaticInstallPolicy != nil {
policyAct := fleet.ActivityTypeCreatedPolicy{
ID: payload.AddedAutomaticInstallPolicy.ID,
Name: payload.AddedAutomaticInstallPolicy.Name,
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), policyAct); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for create automatic install policy for custom package")
}
}
return addedInstaller, nil
}
func ValidateSoftwareLabels(ctx context.Context, svc fleet.Service, teamID *uint, labelsIncludeAny, labelsExcludeAny, labelsIncludeAll []string) (*fleet.LabelIdentsWithScope, error) {
if authctx, ok := authz_ctx.FromContext(ctx); !ok {
return nil, fleet.NewAuthRequiredError("validate software labels: missing authorization context")
} else if !authctx.Checked() {
return nil, fleet.NewAuthRequiredError("validate software labels: method requires previous authorization")
}
var count int
for _, set := range [][]string{labelsIncludeAny, labelsExcludeAny, labelsIncludeAll} {
if len(set) > 0 {
count++
}
}
if count > 1 {
return nil, &fleet.BadRequestError{Message: `Only one of "labels_include_all", "labels_include_any" or "labels_exclude_any" can be included.`}
}
var names []string
var scope fleet.LabelScope
switch {
case len(labelsIncludeAny) > 0:
names = labelsIncludeAny
scope = fleet.LabelScopeIncludeAny
case len(labelsExcludeAny) > 0:
names = labelsExcludeAny
scope = fleet.LabelScopeExcludeAny
case len(labelsIncludeAll) > 0:
names = labelsIncludeAll
scope = fleet.LabelScopeIncludeAll
}
if len(names) == 0 {
// nothing to validate, return empty result
return &fleet.LabelIdentsWithScope{}, nil
}
byName, err := svc.BatchValidateLabels(ctx, teamID, names)
if err != nil {
var missingLabelErr *fleet.MissingLabelError
if errors.As(err, &missingLabelErr) {
return nil, &fleet.BadRequestError{
InternalErr: missingLabelErr,
Message: fmt.Sprintf("Couldn't update. Label %q doesn't exist. Please remove the label from the software.", missingLabelErr.MissingLabelName),
}
}
return nil, err
}
return &fleet.LabelIdentsWithScope{
LabelScope: scope,
ByName: byName,
}, nil
}
func preProcessUninstallScript(payload *fleet.UploadSoftwareInstallerPayload) error {
if len(payload.PackageIDs) == 0 {
// do nothing, this could be a FMA which won't include the installer when editing the scripts
return nil
}
// dmg and zip don't use template variable substitution
switch payload.Extension {
case "dmg", "zip":
return nil
}
// Only validate and substitute $PACKAGE_ID if it appears in the script
if file.PackageIDRegex.MatchString(payload.UninstallScript) {
if err := file.ValidatePackageIdentifiers(payload.PackageIDs, ""); err != nil {
return err
}
var packageID string
switch payload.Extension {
case "pkg":
var sb strings.Builder
_, _ = sb.WriteString("(\n")
for _, pkgID := range payload.PackageIDs {
_, _ = sb.WriteString(fmt.Sprintf(" '%s'\n", pkgID))
}
_, _ = sb.WriteString(")") // no ending newline
packageID = sb.String()
default:
packageID = fmt.Sprintf("'%s'", payload.PackageIDs[0])
}
payload.UninstallScript = file.PackageIDRegex.ReplaceAllString(payload.UninstallScript, fmt.Sprintf("%s${suffix}", packageID))
}
// Only validate and substitute $UPGRADE_CODE if the template variable appears in the script
if file.UpgradeCodeRegex.MatchString(payload.UninstallScript) {
if payload.UpgradeCode == "" {
return errors.New("$UPGRADE_CODE variable was used in uninstall script but package does not have an UpgradeCode")
}
if err := file.ValidatePackageIdentifiers(nil, payload.UpgradeCode); err != nil {
return err
}
payload.UninstallScript = file.UpgradeCodeRegex.ReplaceAllString(payload.UninstallScript, fmt.Sprintf("'%s'${suffix}", payload.UpgradeCode))
}
return nil
}
func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: payload.TeamID}, fleet.ActionWrite); err != nil {
return nil, err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
payload.UserID = vc.UserID()
if payload.TeamID == nil {
return nil, &fleet.BadRequestError{Message: "fleet_id is required; enter 0 for unassigned"}
}
var teamName *string
if *payload.TeamID != 0 {
t, err := svc.ds.TeamLite(ctx, *payload.TeamID)
if err != nil {
return nil, err
}
teamName = &t.Name
}
var scripts []string
if payload.InstallScript != nil {
scripts = append(scripts, *payload.InstallScript)
}
if payload.PostInstallScript != nil {
scripts = append(scripts, *payload.PostInstallScript)
}
if payload.UninstallScript != nil {
scripts = append(scripts, *payload.UninstallScript)
}
if err := svc.ds.ValidateEmbeddedSecrets(ctx, scripts); err != nil {
// We redo the validation on each script to find out which script has the missing secret.
// This is done to provide a more informative error message to the UI user.
var argErr *fleet.InvalidArgumentError
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "install script", payload.InstallScript, argErr)
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "post-install script", payload.PostInstallScript, argErr)
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "uninstall script", payload.UninstallScript, argErr)
if argErr != nil {
return nil, argErr
}
// We should not get to this point. If we did, it means we have another issue, such as large read replica latency.
return nil, ctxerr.Wrap(ctx, err, "transient server issue validating embedded secrets")
}
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, scripts); err != nil {
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
return nil, ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
}
var argErr *fleet.InvalidArgumentError
argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "install script", payload.InstallScript, argErr)
argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "post-install script", payload.PostInstallScript, argErr)
argErr = svc.validateReferencedCustomHostVitalsOnScript(ctx, "uninstall script", payload.UninstallScript, argErr)
if argErr != nil {
return nil, argErr
}
return nil, ctxerr.Wrap(ctx, err, "transient server issue validating custom host vitals")
}
if err := validateFleetVariablesOnInstallerScripts(ctx, payload.InstallScript, payload.PostInstallScript, payload.UninstallScript); err != nil {
return nil, err
}
// get software by ID, fail if it does not exist or does not have an existing installer
software, err := svc.ds.SoftwareTitleByID(ctx, payload.TitleID, payload.TeamID, fleet.TeamFilter{
User: vc.User,
IncludeObserver: true,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software title by id")
}
dirty := make(map[string]bool)
if payload.Categories != nil {
categories, catIDs, err := svc.removeDuplicateOrMissingCategories(ctx, ptr.ValOrZero(payload.TeamID), payload.Categories)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "filtering software installer categories")
}
payload.Categories = categories
payload.CategoryIDs = catIDs
dirty["Categories"] = true
}
// Handle in house apps separately
if software.InHouseAppCount == 1 {
return svc.updateInHouseAppInstaller(ctx, payload, vc, teamName, software)
}
if software.SoftwareInstallersCount < 1 {
return nil, &fleet.BadRequestError{
Message: "There are no software installers defined yet for this title and team. Please add an installer instead of attempting to edit.",
}
}
// Defaults to the first-added package; a specific installer_id overrides it below.
existingInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID, true)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting existing installer")
}
// siblings is reused for both installer targeting and the hash-collision check below.
var siblings []*fleet.SoftwareInstaller
if software.SoftwareInstallersCount > 1 || payload.InstallerID != 0 {
siblings, err = svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting title packages")
}
switch {
case payload.InstallerID == 0 && software.SoftwareInstallersCount > 1:
return nil, &fleet.BadRequestError{
Message: "installer_id is required when the title has multiple packages.",
}
case payload.InstallerID != 0:
var found bool
for _, p := range siblings {
if p.InstallerID == payload.InstallerID {
found = true
break
}
}
if !found {
return nil, ctxerr.Wrapf(ctx, &notFoundError{},
"installer %d does not belong to this title and team", payload.InstallerID)
}
// hydrate the targeted package the same way as the first-added default
existingInstaller, err = svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx, payload.TeamID, payload.TitleID, payload.InstallerID, true)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting targeted installer")
}
}
}
if payload.IsNoopPayload(software) {
return existingInstaller, nil // no payload, noop
}
payload.InstallerID = existingInstaller.InstallerID
if payload.DisplayName != nil && *payload.DisplayName != software.DisplayName {
trimmed := strings.TrimSpace(*payload.DisplayName)
if trimmed == "" && *payload.DisplayName != "" {
return nil, fleet.NewInvalidArgumentError("display_name", "Cannot have a display name that is all whitespace.")
}
*payload.DisplayName = trimmed
dirty["DisplayName"] = true
}
if payload.SelfService != nil && *payload.SelfService != existingInstaller.SelfService {
dirty["SelfService"] = true
}
shouldUpdateLabels, validatedLabels, err := ValidateSoftwareLabelsForUpdate(ctx, svc, existingInstaller, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating software labels for update")
}
if shouldUpdateLabels {
dirty["Labels"] = true
}
payload.ValidatedLabels = validatedLabels
// activity team ID must be null if no team, not zero
var actTeamID *uint
if payload.TeamID != nil && *payload.TeamID != 0 {
actTeamID = payload.TeamID
}
activity := fleet.ActivityTypeEditedSoftware{
SoftwareTitle: existingInstaller.SoftwareTitle,
TeamName: teamName,
TeamID: actTeamID,
SelfService: existingInstaller.SelfService,
SoftwarePackage: &existingInstaller.Name,
SoftwareTitleID: payload.TitleID,
SoftwareIconURL: existingInstaller.IconUrl,
}
if payload.SelfService != nil && *payload.SelfService != existingInstaller.SelfService {
dirty["SelfService"] = true
activity.SelfService = *payload.SelfService
}
var payloadForNewInstallerFile *fleet.UploadSoftwareInstallerPayload
if payload.InstallerFile != nil {
payloadForNewInstallerFile = &fleet.UploadSoftwareInstallerPayload{
InstallerFile: payload.InstallerFile,
Filename: payload.Filename,
}
newInstallerExtension, err := svc.addMetadataToSoftwarePayload(ctx, payloadForNewInstallerFile, false)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "extracting updated installer metadata")
}
// Fleet-maintained apps can't have their package replaced; return the FMA message before the
// extension and identity checks so it isn't masked by a more generic error.
if existingInstaller.FleetMaintainedAppID != nil {
return nil, &fleet.BadRequestError{
Message: "Couldn't update. The package can't be changed for Fleet-maintained apps.",
InternalErr: ctxerr.New(ctx, "installer file changed for fleet maintained app installer"),
}
}
if newInstallerExtension != existingInstaller.Extension {
return nil, &fleet.BadRequestError{
Message: "The selected package is for a different file type.",
InternalErr: ctxerr.New(ctx, "installer extension mismatch"),
}
}
// The replacement must be the same software as the installer being edited. Its extracted
// identity (bundle id for apps, upgrade code for Windows, else name) must point at this title.
// A bare name match is accepted only when no title claims the identity — so a re-keyed MSI or
// re-bundled pkg still edits — while another app's package, which resolves to a different title,
// is rejected even when the names coincide. A package that changes both its name and its
// upgrade code at once can't be tied to the edited installer, so it is rejected (as before).
switch {
case payloadForNewInstallerFile.UpgradeCode != "" && payloadForNewInstallerFile.UpgradeCode == existingInstaller.UpgradeCode:
// Same Windows product as the edited installer (a sibling MSI whose upgrade code can differ
// from the title's); trusted fast path, skip the title lookup entirely.
default:
resolvedTitleID, err := svc.ds.GetExistingSoftwareInstallerTitleID(ctx, payloadForNewInstallerFile)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "resolving title for updated installer")
}
switch {
case err == nil && resolvedTitleID == payload.TitleID:
// Identity resolves to this title.
case err == nil && (payloadForNewInstallerFile.BundleIdentifier != "" || payloadForNewInstallerFile.UpgradeCode != ""):
// A strong identifier (bundle id / upgrade code) resolves to a different existing title:
// different software, even if the names coincide. Name-only matches are excluded here
// because the resolver's name branch can match multiple same-named titles ambiguously.
return nil, &fleet.BadRequestError{
Message: "The selected package is for different software.",
InternalErr: ctxerr.Errorf(ctx, "installer resolves to title %d, editing title %d", resolvedTitleID, payload.TitleID),
}
case payloadForNewInstallerFile.Title != software.Name:
// No authoritative identity claims this package and the name does not match either.
return nil, &fleet.BadRequestError{
Message: "The selected package is for different software.",
InternalErr: ctxerr.New(ctx, "installer identity not found and name mismatch"),
}
}
}
if payloadForNewInstallerFile.StorageID != existingInstaller.StorageID {
// Catch a sibling hash match for a friendly 409; the dedup_token key would otherwise raise a raw 1062.
for _, p := range siblings {
if p.InstallerID != existingInstaller.InstallerID && p.StorageID == payloadForNewInstallerFile.StorageID {
return nil, ctxerr.Wrap(ctx, fleet.ConflictError{
Message: fmt.Sprintf(fleet.SoftwarePackageHashConflictMessage, payloadForNewInstallerFile.Filename),
}, "edit collides with sibling package hash")
}
}
activity.SoftwarePackage = &payload.Filename
payload.StorageID = payloadForNewInstallerFile.StorageID
payload.Filename = payloadForNewInstallerFile.Filename
payload.Version = payloadForNewInstallerFile.Version
payload.PackageIDs = payloadForNewInstallerFile.PackageIDs
payload.UpgradeCode = payloadForNewInstallerFile.UpgradeCode
dirty["Package"] = true
// For script packages the uploaded file's contents are the install
// script, so replacing the file must update install_script too. The
// file's contents were not among the payload fields validated above,
// so validate them here.
if fleet.IsScriptPackage(existingInstaller.Extension) {
if err := validateFleetVariablesOnInstallerScripts(ctx, &payloadForNewInstallerFile.InstallScript, nil, nil); err != nil {
return nil, err
}
payload.InstallScript = &payloadForNewInstallerFile.InstallScript
if payloadForNewInstallerFile.InstallScript != existingInstaller.InstallScript {
dirty["InstallScript"] = true
}
}
} else { // noop if uploaded installer is identical to previous installer
payloadForNewInstallerFile = nil
payload.InstallerFile = nil
}
}
if payload.InstallerFile == nil { // fill in existing existingInstaller data to payload
payload.StorageID = existingInstaller.StorageID
payload.Filename = existingInstaller.Name
payload.Version = existingInstaller.Version
payload.PackageIDs = existingInstaller.PackageIDs()
payload.UpgradeCode = existingInstaller.UpgradeCode
}
isScriptPackage := fleet.IsScriptPackage(existingInstaller.Extension)
// default pre-install query is blank, so blanking out the query doesn't have a semantic meaning we have to take care of
if payload.PreInstallQuery != nil {
if *payload.PreInstallQuery != existingInstaller.PreInstallQuery {
dirty["PreInstallQuery"] = true
}
}
if payload.InstallScript != nil {
if isScriptPackage {
// A script package's install script comes from the uploaded file.
// Ignore a user-provided install_script value, but keep one derived
// from a newly uploaded file (set above).
if payloadForNewInstallerFile == nil {
payload.InstallScript = nil
}
} else {
installScript := file.Dos2UnixNewlines(*payload.InstallScript)
installScript = getInstallScript(existingInstaller.Extension, existingInstaller.PackageIDs(), installScript)
if installScript == "" {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't edit. Install script is required for .%s packages.", strings.ToLower(existingInstaller.Extension)),
}
}
if err := fleet.ValidateSoftwareInstallerScript(installScript, existingInstaller.Platform); err != nil {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't edit. install script validation failed: %s", err.Error()),
}
}
if installScript != existingInstaller.InstallScript {
dirty["InstallScript"] = true
}
payload.InstallScript = &installScript
}
}
if payload.PostInstallScript != nil {
postInstallScript := file.Dos2UnixNewlines(*payload.PostInstallScript)
if err := fleet.ValidateSoftwareInstallerScript(postInstallScript, existingInstaller.Platform); err != nil {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't edit. post-install script validation failed: %s", err.Error()),
}
}
if postInstallScript != existingInstaller.PostInstallScript {
dirty["PostInstallScript"] = true
}
payload.PostInstallScript = &postInstallScript
}
if payload.UninstallScript != nil {
uninstallScript := file.Dos2UnixNewlines(*payload.UninstallScript)
// Script packages have no default uninstall script and may leave it empty;
// other types fall back to a default and require one.
if !isScriptPackage {
if uninstallScript == "" { // extension can't change on an edit so we can generate off of the existing file
uninstallScript = file.GetUninstallScript(existingInstaller.Extension)
if payload.UpgradeCode != "" {
uninstallScript = file.UninstallMsiWithUpgradeCodeScript
}
}
if uninstallScript == "" {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't edit. Uninstall script is required for .%s packages.", strings.ToLower(existingInstaller.Extension)),
}
}
}
if err := fleet.ValidateSoftwareInstallerScript(uninstallScript, existingInstaller.Platform); err != nil {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't edit. uninstall script validation failed: %s", err.Error()),
}
}
payloadForUninstallScript := &fleet.UploadSoftwareInstallerPayload{
Extension: existingInstaller.Extension,
UninstallScript: uninstallScript,
PackageIDs: existingInstaller.PackageIDs(),
UpgradeCode: existingInstaller.UpgradeCode,
}
if payloadForNewInstallerFile != nil {
payloadForUninstallScript.PackageIDs = payloadForNewInstallerFile.PackageIDs
payloadForUninstallScript.UpgradeCode = payloadForNewInstallerFile.UpgradeCode
}
if err := preProcessUninstallScript(payloadForUninstallScript); err != nil {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't edit software: %s", err),
}
}
if payloadForUninstallScript.UninstallScript != existingInstaller.UninstallScript {
dirty["UninstallScript"] = true
}
uninstallScript = payloadForUninstallScript.UninstallScript
payload.UninstallScript = &uninstallScript
}
// switch active installer to one that matches the pinned version
var activeInstallerID uint
if payload.PinnedVersion != nil {
if existingInstaller.FleetMaintainedAppID == nil {
return nil, &fleet.BadRequestError{
Message: `Couldn't update. "version" can be only specified for a software title that has a Fleet-maintained app.`,
}
}
if len(dirty) > 0 {
return nil, &fleet.BadRequestError{
Message: `Couldn't update. "version" can't be changed at the same time as other fields.`,
}
}
*payload.PinnedVersion = strings.TrimSpace(*payload.PinnedVersion)
majorVersionString, usesCaret, err := parsePinnedVersion(ctx, *payload.PinnedVersion)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "reading Fleet-maintained app pinned version")
}
versions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, payload.TeamID, payload.TitleID, true)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting Fleet-maintained app versions")
}
if len(versions) == 0 {
return nil, ctxerr.New(ctx, "no cached versions for Fleet-maintained app")
}
switch {
case *payload.PinnedVersion == "": // Latest
activeInstallerID = versions[0].ID
case usesCaret:
for _, v := range versions {
if versionMatchesMajor(v.Version, majorVersionString) {
activeInstallerID = v.ID
break
}
}
if activeInstallerID == 0 {
activeInstallerID = versions[0].ID
}
default: // literal version
for _, v := range versions {
if v.Version == *payload.PinnedVersion {
activeInstallerID = v.ID
break
}
}
if activeInstallerID == 0 {
return nil, fleet.NewUserMessageError(errVersionNotFound, http.StatusNotFound)
}
}
// The active-installer flip is applied in the dirty section below.
dirty["PinnedVersion"] = true
}
fieldsShouldSideEffect := map[string]struct{}{
"InstallerFile": {},
"InstallScript": {},
"UninstallScript": {},
"PostInstallScript": {},
"PreInstallQuery": {},
"Package": {},
"Labels": {},
}
var shouldDoSideEffects bool
// persist changes starting here, now that we've done all the validation/diffing we can
if len(dirty) > 0 {
switch {
case len(dirty) == 1 && dirty["SelfService"]: // only self-service changed; use lighter update function
if err := svc.ds.UpdateInstallerSelfServiceFlag(ctx, *payload.SelfService, existingInstaller.InstallerID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "updating installer self service flag")
}
case len(dirty) == 1 && dirty["PinnedVersion"]: // only the pinned version changed; flip the active installer rather than rewriting it
// SetFleetMaintainedAppActiveInstaller also redirects installs frozen on
// the version we pinned away from to the newly-active one.
if err := svc.ds.SetFleetMaintainedAppActiveInstaller(ctx, payload, activeInstallerID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "pinning Fleet-maintained app version")
}
// the pinned version is now the active installer; return it, not the one we pinned away from
payload.InstallerID = activeInstallerID
default:
if payloadForNewInstallerFile != nil {
if err := svc.storeSoftware(ctx, payloadForNewInstallerFile); err != nil {
return nil, ctxerr.Wrap(ctx, err, "storing software installer")
}
}
// fill in values from existing installer if they weren't supplied
if payload.InstallScript == nil {
payload.InstallScript = &existingInstaller.InstallScript
}
if payload.UninstallScript == nil {
payload.UninstallScript = &existingInstaller.UninstallScript
}
if payload.PostInstallScript == nil && !dirty["PostInstallScript"] {
payload.PostInstallScript = &existingInstaller.PostInstallScript
}
if payload.PreInstallQuery == nil {
payload.PreInstallQuery = &existingInstaller.PreInstallQuery
}
if payload.SelfService == nil {
payload.SelfService = &existingInstaller.SelfService
}
// Get the hosts that are NOT in label scope currently (before the update happens)
var hostsNotInScope map[uint]struct{}
if dirty["Labels"] {
hostsNotInScope, err = svc.ds.GetExcludedHostIDMapForSoftwareInstaller(ctx, payload.InstallerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting hosts not in scope for installer")
}
}
if err := svc.ds.SaveInstallerUpdates(ctx, payload); err != nil {
return nil, ctxerr.Wrap(ctx, err, "saving installer updates")
}
if dirty["Labels"] {
// Get the hosts that are now IN label scope (after the update)
hostsInScope, err := svc.ds.GetIncludedHostIDMapForSoftwareInstaller(ctx, payload.InstallerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting hosts in scope for installer")
}
var hostsToClear []uint
for id := range hostsInScope {
if _, ok := hostsNotInScope[id]; ok {
// it was not in scope but now it is, so we should clear policy status
hostsToClear = append(hostsToClear, id)
}
}
// We clear the policy status here because otherwise the policy automation machinery
// won't pick this up and the software won't install.
if err := svc.ds.ClearSoftwareInstallerAutoInstallPolicyStatusForHosts(ctx, payload.InstallerID, hostsToClear); err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to clear auto install policy status for host")
}
}
for field := range dirty {
if _, ok := fieldsShouldSideEffect[field]; ok {
shouldDoSideEffects = true
break
}
}
// if we're updating anything other than self-service, we cancel pending installs/uninstalls,
// and if we're updating the package we reset counts. This is run in its own transaction internally
// for consistency, but independent of the installer update query as the main update should stick
// even if side effects fail.
if err := svc.ds.ProcessInstallerUpdateSideEffects(ctx, existingInstaller.InstallerID, shouldDoSideEffects, dirty["Package"]); err != nil {
return nil, err
}
}
// now that the payload has been updated with any patches, we can set the
// final fields of the activity
actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromSoftwareScopeLabels(
existingInstaller.LabelsIncludeAny, existingInstaller.LabelsExcludeAny, existingInstaller.LabelsIncludeAll)
if payload.ValidatedLabels != nil {
actLabelsInclAny, actLabelsExclAny, actLabelsInclAll = activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels)
}
activity.LabelsIncludeAny = actLabelsInclAny
activity.LabelsExcludeAny = actLabelsExclAny
activity.LabelsIncludeAll = actLabelsInclAll
if payload.SelfService != nil {
activity.SelfService = *payload.SelfService
}
if payload.DisplayName != nil {
activity.SoftwareDisplayName = *payload.DisplayName
}
if payload.PinnedVersion != nil && *payload.PinnedVersion != "" {
activity.PinnedVersion = payload.PinnedVersion
}
if err := svc.NewActivity(ctx, vc.User, activity); err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating activity for edited software")
}
}
// re-pull the edited installer to reflect side effects; return that specific
// package, not the title's first-added one. May be able to optimize this out later.
updatedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctxdb.RequirePrimary(ctx, true), payload.TeamID, payload.TitleID, payload.InstallerID, true)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "re-hydrating updated installer metadata")
}
statuses, err := svc.ds.GetSummaryHostSoftwareInstalls(ctx, updatedInstaller.InstallerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting updated installer statuses")
}
updatedInstaller.Status = statuses
return updatedInstaller, nil
}
func (svc *Service) validateEmbeddedSecretsOnScript(ctx context.Context, scriptName string, script *string,
argErr *fleet.InvalidArgumentError,
) *fleet.InvalidArgumentError {
if script != nil {
if errScript := svc.ds.ValidateEmbeddedSecrets(ctx, []string{*script}); errScript != nil {
if argErr != nil {
argErr.Append(scriptName, errScript.Error())
} else {
argErr = fleet.NewInvalidArgumentError(scriptName, errScript.Error())
}
}
}
return argErr
}
// validateFleetVariablesOnInstallerScripts validates $FLEET_VAR_* usage on the
// installer's scripts, naming the offending script in the error. Nil scripts
// are skipped (update payloads only carry the scripts that changed).
func validateFleetVariablesOnInstallerScripts(ctx context.Context, installScript, postInstallScript, uninstallScript *string) error {
isPremium := license.IsPremium(ctx)
var argErr *fleet.InvalidArgumentError
for _, s := range []struct {
name string
contents *string
}{
{"install script", installScript},
{"post-install script", postInstallScript},
{"uninstall script", uninstallScript},
} {
if s.contents == nil {
continue
}
fleetVars := variables.Find(*s.contents)
if len(fleetVars) == 0 {
continue
}
if !isPremium {
return fleet.ErrMissingLicense
}
if v := fleet.FindUnsupportedScriptFleetVar(fleetVars); v != "" {
msg := fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in scripts.", v)
if argErr != nil {
argErr.Append(s.name, msg)
} else {
argErr = fleet.NewInvalidArgumentError(s.name, msg)
}
}
}
if argErr != nil {
return argErr
}
return nil
}
// validateReferencedCustomHostVitalsOnScript mirrors validateEmbeddedSecretsOnScript
// so callers can report which specific script references an undefined custom host vital.
func (svc *Service) validateReferencedCustomHostVitalsOnScript(ctx context.Context, scriptName string, script *string,
argErr *fleet.InvalidArgumentError,
) *fleet.InvalidArgumentError {
if script != nil {
if errScript := svc.ds.ValidateReferencedCustomHostVitals(ctx, []string{*script}); errScript != nil {
if argErr != nil {
argErr.Append(scriptName, errScript.Error())
} else {
argErr = fleet.NewInvalidArgumentError(scriptName, errScript.Error())
}
}
}
return argErr
}
func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, existingInstaller *fleet.SoftwareInstaller, includeAny, excludeAny, includeAll []string) (shouldUpdate bool, validatedLabels *fleet.LabelIdentsWithScope, err error) {
if authctx, ok := authz_ctx.FromContext(ctx); !ok {
return false, nil, fleet.NewAuthRequiredError("batch validate labels: missing authorization context")
} else if !authctx.Checked() {
return false, nil, fleet.NewAuthRequiredError("batch validate labels: method requires previous authorization")
}
if existingInstaller == nil {
return false, nil, errors.New("existing installer must be provided")
}
if includeAny == nil && excludeAny == nil && includeAll == nil {
// nothing to do
return false, nil, nil
}
incoming, err := ValidateSoftwareLabels(ctx, svc, existingInstaller.TeamID, includeAny, excludeAny, includeAll)
if err != nil {
return false, nil, err
}
var prevScope fleet.LabelScope
var prevLabels []fleet.SoftwareScopeLabel
switch {
case len(existingInstaller.LabelsIncludeAny) > 0:
prevScope = fleet.LabelScopeIncludeAny
prevLabels = existingInstaller.LabelsIncludeAny
case len(existingInstaller.LabelsExcludeAny) > 0:
prevScope = fleet.LabelScopeExcludeAny
prevLabels = existingInstaller.LabelsExcludeAny
case len(existingInstaller.LabelsIncludeAll) > 0:
prevScope = fleet.LabelScopeIncludeAll
prevLabels = existingInstaller.LabelsIncludeAll
}
prevByName := make(map[string]fleet.LabelIdent, len(prevLabels))
for _, pl := range prevLabels {
prevByName[pl.LabelName] = fleet.LabelIdent{
LabelID: pl.LabelID,
LabelName: pl.LabelName,
}
}
if prevScope != incoming.LabelScope {
return true, incoming, nil
}
if len(prevByName) != len(incoming.ByName) {
return true, incoming, nil
}
// compare labels by name
for n, il := range incoming.ByName {
pl, ok := prevByName[n]
if !ok || pl != il {
return true, incoming, nil
}
}
return false, nil, nil
}
func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error {
if teamID == nil {
return fleet.NewInvalidArgumentError("fleet_id", "is required")
}
// we authorize with SoftwareInstaller here, but it uses the same AuthzType
// as VPPApp, so this is correct for both software installers and VPP apps.
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
return err
}
// metaInstaller is fully hydrated (incl. the title-level icon) which the per-package reads below lack.
metaInstaller, errInstaller := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false)
metaVPP, errVPP := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID)
metaInHouse, errInHouse := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, teamID, titleID)
switch {
case errInstaller != nil && !fleet.IsNotFound(errInstaller):
return ctxerr.Wrap(ctx, errInstaller, "getting software installer metadata")
case errVPP != nil && !fleet.IsNotFound(errVPP):
return ctxerr.Wrap(ctx, errVPP, "getting vpp app metadata")
case errInHouse != nil && !fleet.IsNotFound(errInHouse):
return ctxerr.Wrap(ctx, errInHouse, "getting in house app metadata")
}
// An installer id always refers to a software installer, never a VPP or in-house app.
if installerID != nil {
if metaInstaller == nil {
return ctxerr.Wrapf(ctx, &notFoundError{}, "installer %d does not belong to this title and team", *installerID)
}
pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, titleID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting title packages")
}
for _, pkg := range pkgs {
if pkg.InstallerID == *installerID {
pkg.IconUrl = metaInstaller.IconUrl // title-level icon for cleanup + activity
return svc.deleteSoftwareInstaller(ctx, pkg)
}
}
return ctxerr.Wrapf(ctx, &notFoundError{}, "installer %d does not belong to this title and team", *installerID)
}
switch {
case metaInstaller != nil:
// Delete every package on the title. FMA titles keep one active row, so this
// matches prior behavior for them. Per-package deletes mean a guarded package
// (setup experience / patch policy) fails the title delete partway.
pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, titleID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting title packages to delete")
}
for _, pkg := range pkgs {
pkg.IconUrl = metaInstaller.IconUrl // title-level icon for cleanup + activity
if err := svc.deleteSoftwareInstaller(ctx, pkg); err != nil {
return err
}
}
return nil
case metaVPP != nil:
return svc.deleteVPPApp(ctx, teamID, metaVPP)
case metaInHouse != nil:
return svc.deleteSoftwareInstaller(ctx, metaInHouse)
}
return ctxerr.Wrap(ctx, &notFoundError{}, "getting software installer")
}
func (svc *Service) deleteVPPApp(ctx context.Context, teamID *uint, meta *fleet.VPPAppStoreApp) error {
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
}
var androidHostsUUIDToPolicyID map[string]string
if meta.Platform == fleet.AndroidPlatform {
// if this is an Android app we're deleting, collect the host uuids that should have it removed
// (as we uninstall Android apps on delete). We can't do this in the worker as it will be too late,
// the vpp_apps_teams entry will have been deleted.
hosts, err := svc.ds.GetIncludedHostUUIDMapForAppStoreApp(ctx, meta.VPPAppsTeamsID)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete app store app: getting android hosts in scope")
}
androidHostsUUIDToPolicyID = hosts
}
if err := svc.ds.DeleteVPPAppFromTeam(ctx, teamID, meta.VPPAppID); err != nil {
return ctxerr.Wrap(ctx, err, "deleting VPP app")
}
// if this is an android app, remove the self-service app from the managed Google Play store
// and uninstall it from the hosts.
if meta.Platform == fleet.AndroidPlatform && len(androidHostsUUIDToPolicyID) > 0 {
enterprise, err := svc.ds.GetEnterprise(ctx)
if err != nil {
return &fleet.BadRequestError{Message: "Android MDM is not enabled", InternalErr: err}
}
err = worker.QueueMakeAndroidAppUnavailableJob(ctx, svc.ds, svc.logger, meta.VPPAppID.AdamID, androidHostsUUIDToPolicyID, enterprise.Name(), svc.config.MDM.AndroidBatchSize)
if err != nil {
return ctxerr.Wrap(ctx, err, "enqueuing job to make android app unavailable")
}
}
var teamName *string
if teamID != nil && *teamID != 0 {
t, err := svc.ds.TeamLite(ctx, *teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting team name for deleted VPP app")
}
teamName = &t.Name
}
actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromSoftwareScopeLabels(meta.LabelsIncludeAny, meta.LabelsExcludeAny, meta.LabelsIncludeAll)
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityDeletedAppStoreApp{
AppStoreID: meta.AdamID,
SoftwareTitle: meta.Name,
TeamName: teamName,
TeamID: teamID,
Platform: meta.Platform,
LabelsIncludeAny: actLabelsInclAny,
LabelsExcludeAny: actLabelsExclAny,
LabelsIncludeAll: actLabelsInclAll,
SoftwareIconURL: meta.IconURL,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for deleted VPP app")
}
if teamID != nil && meta.IconURL != nil && *meta.IconURL != "" {
err := svc.ds.DeleteIconsAssociatedWithTitlesWithoutInstallers(ctx, *teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, fmt.Sprintf("failed to delete unused software icons for team %d", *teamID))
}
}
return nil
}
func (svc *Service) deleteSoftwareInstaller(ctx context.Context, meta *fleet.SoftwareInstaller) error {
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
}
switch {
case meta.Extension == "ipa":
if err := svc.ds.DeleteInHouseApp(ctx, meta.InstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "deleting in house app")
}
case meta.FleetMaintainedAppID != nil:
// For FMA installers there may be multiple cached versions (active + up to
// N-1 inactive ones). Delete the active version first so that the
// policy-automation and setup-experience guard-rails are enforced, then
// sweep up any remaining inactive cached versions.
if err := svc.ds.DeleteSoftwareInstaller(ctx, meta.InstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "deleting active FMA installer version")
}
// After the active row is gone, fetch whatever cached versions remain and
// delete them. GetFleetMaintainedVersionsByTitleID queries the live DB, so
// it will not return the row we just deleted.
if meta.TitleID != nil {
cachedVersions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, meta.TeamID, *meta.TitleID, false)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting cached FMA versions for cleanup")
}
for _, v := range cachedVersions {
if err := svc.ds.DeleteSoftwareInstaller(ctx, v.ID); err != nil && !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "deleting cached FMA version")
}
}
// The pin row is keyed by (team, title) and is not cascade-deleted when
// installer rows go away (only when the title row is deleted), so clear
// it explicitly to avoid a stale pin surviving a delete + re-add.
if err := svc.ds.DeletePinnedVersion(ctx, meta.TeamID, *meta.TitleID); err != nil {
return ctxerr.Wrap(ctx, err, "deleting pinned version after FMA removal")
}
}
default:
if err := svc.ds.DeleteSoftwareInstaller(ctx, meta.InstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "deleting software installer")
}
}
var teamName *string
if meta.TeamID != nil {
t, err := svc.ds.TeamLite(ctx, *meta.TeamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting team name for deleted software")
}
teamName = &t.Name
}
actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromSoftwareScopeLabels(meta.LabelsIncludeAny, meta.LabelsExcludeAny, meta.LabelsIncludeAll)
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedSoftware{
SoftwareTitle: meta.SoftwareTitle,
SoftwarePackage: meta.Name,
TeamName: teamName,
TeamID: meta.TeamID,
SelfService: meta.SelfService,
LabelsIncludeAny: actLabelsInclAny,
LabelsExcludeAny: actLabelsExclAny,
LabelsIncludeAll: actLabelsInclAll,
SoftwareIconURL: meta.IconUrl,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for deleted software")
}
if meta.IconUrl != nil && *meta.IconUrl != "" {
var teamIDForCleanup uint
if meta.TeamID != nil {
teamIDForCleanup = *meta.TeamID
}
err := svc.ds.DeleteIconsAssociatedWithTitlesWithoutInstallers(ctx, teamIDForCleanup)
if err != nil {
return ctxerr.Wrap(ctx, fmt.Errorf("failed to delete unused software icons for team %d: %w", teamIDForCleanup, err))
}
}
return nil
}
func (svc *Service) GetSoftwareInstallerMetadata(ctx context.Context, skipAuthz bool, titleID uint, teamID *uint) (*fleet.SoftwareInstaller,
error,
) {
if !skipAuthz {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, err
}
}
meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, true)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software installer metadata")
}
return meta, nil
}
func (svc *Service) GenerateSoftwareInstallerToken(ctx context.Context, alt string, titleID uint, teamID *uint, installerID *uint) (string, error) {
downloadRequested := alt == "media"
if !downloadRequested {
svc.authz.SkipAuthorization(ctx)
return "", fleet.NewInvalidArgumentError("alt", "only alt=media is supported")
}
if teamID == nil {
svc.authz.SkipAuthorization(ctx)
return "", fleet.NewInvalidArgumentError("fleet_id", "is required")
}
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionRead); err != nil {
return "", err
}
meta := fleet.SoftwareInstallerTokenMetadata{
TitleID: titleID,
TeamID: *teamID,
}
// Nil installerID means "no per-package pin" — the token consumer falls
// back to the first-added package. Preserves single-package back-compat.
if installerID != nil {
meta.InstallerID = *installerID
}
metaByte, err := json.Marshal(meta)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "marshaling software installer metadata")
}
// Generate token and store in Redis
token := uuid.NewString()
const tokenExpirationMs = 10 * 60 * 1000 // 10 minutes
ok, err := svc.distributedLock.SetIfNotExist(ctx, fmt.Sprintf("software_installer_token:%s", token), string(metaByte),
tokenExpirationMs)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "saving software installer token")
}
if !ok {
// Should not happen since token is unique
return "", ctxerr.Errorf(ctx, "failed to save software installer token")
}
return token, nil
}
func (svc *Service) GetSoftwareInstallerTokenMetadata(ctx context.Context, token string,
titleID uint,
) (*fleet.SoftwareInstallerTokenMetadata, error) {
// We will manually authorize this endpoint based on the token.
svc.authz.SkipAuthorization(ctx)
if len(token) > softwareInstallerTokenMaxLength {
return nil, fleet.NewPermissionError("invalid token")
}
metaStr, err := svc.distributedLock.GetAndDelete(ctx, fmt.Sprintf("software_installer_token:%s", token))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software installer token metadata")
}
if metaStr == nil {
return nil, ctxerr.Wrap(ctx, fleet.NewPermissionError("invalid token"))
}
var meta fleet.SoftwareInstallerTokenMetadata
if err := json.Unmarshal([]byte(*metaStr), &meta); err != nil {
return nil, ctxerr.Wrap(ctx, err, "unmarshaling software installer token metadata")
}
if titleID != meta.TitleID {
return nil, ctxerr.Wrap(ctx, fleet.NewPermissionError("invalid token"))
}
// The token is valid.
return &meta, nil
}
func (svc *Service) DownloadSoftwareInstaller(ctx context.Context, skipAuthz bool, alt string, titleID uint,
teamID *uint, installerID *uint,
) (*fleet.DownloadSoftwareInstallerPayload, error) {
downloadRequested := alt == "media"
if !downloadRequested {
svc.authz.SkipAuthorization(ctx)
return nil, fleet.NewInvalidArgumentError("alt", "only alt=media is supported")
}
if teamID == nil {
svc.authz.SkipAuthorization(ctx)
return nil, fleet.NewInvalidArgumentError("fleet_id", "is required")
}
// When installerID is set, target the specific package on a multi-package
// title. Nil falls back to the first-added default (single-package titles
// and pre-multi-package callers).
var meta *fleet.SoftwareInstaller
var err error
if installerID != nil {
if !skipAuthz {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, err
}
}
// withScriptContents=false: only StorageID and Name are used below, so
// skip the script_contents join.
meta, err = svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx, teamID, titleID, *installerID, false)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting pinned software installer metadata")
}
} else {
meta, err = svc.GetSoftwareInstallerMetadata(ctx, skipAuthz, titleID, teamID)
if err != nil {
return nil, err
}
}
return svc.getSoftwareInstallerBinary(ctx, meta.StorageID, meta.Name)
}
func (svc *Service) GetSoftwareInstallDetails(ctx context.Context, installUUID string) (*fleet.SoftwareInstallDetails, error) {
// Call the base (non-premium) service to get the software install details
details, err := svc.Service.GetSoftwareInstallDetails(ctx, installUUID)
if err != nil {
return nil, err
}
// SoftwareInstallersCloudFrontSigner can only be set if license.IsPremium()
if svc.config.S3.SoftwareInstallersCloudFrontSigner != nil {
// Sign the URL for the installer
installerURL, err := svc.getSoftwareInstallURL(ctx, details.InstallerID)
if err != nil {
// We log the error but continue to return the details without the signed URL because orbit can still
// try to download the installer via Fleet server.
svc.logger.ErrorContext(ctx, "error getting software installer URL; check CloudFront configuration", "err", err)
} else {
details.SoftwareInstallerURL = installerURL
}
}
return details, nil
}
func (svc *Service) getSoftwareInstallURL(ctx context.Context, installerID uint) (*fleet.SoftwareInstallerURL, error) {
meta, err := svc.validateAndGetSoftwareInstallerMetadata(ctx, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating software installer metadata for download")
}
// Note: we could check if the installer exists in the S3 store.
// However, if we fail and don't return a URL installer, the Orbit client will still try to download the installer via the Fleet server,
// and we will end up checking if the installer exists in the S3 store again.
// So, to reduce server load and speed up the "happy path" software install, we skip the check here and risk returning a URL that doesn't work.
// If CloudFront is misconfigured, the server and Orbit clients will experience a greater load since they'll be doing throw-away work.
// Get the signed URL
signedURL, err := svc.softwareInstallStore.Sign(ctx, meta.StorageID, fleet.SoftwareInstallerSignedURLExpiry)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "signing software installer URL")
}
return &fleet.SoftwareInstallerURL{
URL: signedURL,
Filename: meta.Name,
}, nil
}
func (svc *Service) OrbitDownloadSoftwareInstaller(ctx context.Context, installerID uint) (*fleet.DownloadSoftwareInstallerPayload, error) {
// this is not a user-authenticated endpoint
svc.authz.SkipAuthorization(ctx)
meta, err := svc.validateAndGetSoftwareInstallerMetadata(ctx, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating software installer metadata for download")
}
// Note that we do allow downloading an installer that is on a different team
// than the host's team, because the install request might have come while
// the host was on that team, and then the host got moved to a different team
// but the request is still pending execution.
return svc.getSoftwareInstallerBinary(ctx, meta.StorageID, meta.Name)
}
func (svc *Service) validateAndGetSoftwareInstallerMetadata(ctx context.Context, installerID uint) (*fleet.SoftwareInstaller, error) {
host, ok := hostctx.FromContext(ctx)
if !ok {
return nil, fleet.OrbitError{Message: "internal error: missing host from request context"}
}
access, err := svc.ds.ValidateOrbitSoftwareInstallerAccess(ctx, host.ID, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "check software installer access")
}
if !access {
return nil, fleet.NewUserMessageError(errors.New("Host doesn't have access to this installer"), http.StatusForbidden)
}
// get the installer's metadata
meta, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software installer metadata")
}
return meta, nil
}
func (svc *Service) getSoftwareInstallerBinary(ctx context.Context, storageID string, filename string) (*fleet.DownloadSoftwareInstallerPayload, error) {
// check if the installer exists in the store
exists, err := svc.softwareInstallStore.Exists(ctx, storageID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "checking if installer exists")
}
if !exists {
return nil, ctxerr.Wrapf(ctx, &notFoundError{}, "%s with filename %s does not exist in software installer store", storageID,
filename)
}
// get the installer from the store
installer, size, err := svc.softwareInstallStore.Get(ctx, storageID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting installer from store")
}
return &fleet.DownloadSoftwareInstallerPayload{
Filename: filename,
Installer: installer,
Size: size,
}, nil
}
// resolveFirstAddedInScopeInstaller returns the first-added (smallest installer_id) package of the
// title that host is in label scope for — and, when requireSelfService is set, that is self-service
// enabled. This is the install-time precedence rule now that a title can hold multiple packages:
// admins scope labels to avoid overlap, but when a host still matches more than one package Fleet
// installs the first-added one. anyPackages reports whether the title has any active packages at all,
// so callers can distinguish "no package for this title" (fall through to VPP/in-house) from
// "packages exist but none is a match for this host" (reject).
func (svc *Service) resolveFirstAddedInScopeInstaller(ctx context.Context, host *fleet.Host, titleID uint, requireSelfService bool) (installer *fleet.SoftwareInstaller, anyPackages bool, err error) {
pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, host.TeamID, titleID)
if err != nil {
return nil, false, ctxerr.Wrap(ctx, err, "listing packages for install precedence")
}
anyPackages = len(pkgs) > 0
// pkgs are ordered installer_id ASC (first-added first). Prefer the first-added package the host
// can actually install (in scope and platform-compatible). Keep the first-added in-scope package
// of any platform as a fallback so a cross-platform title with no compatible package still hits
// the downstream platform error (matching single-package behavior).
var fallback *fleet.SoftwareInstaller
for _, pkg := range pkgs {
if requireSelfService && !pkg.SelfService {
continue
}
scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, pkg.InstallerID, host.ID)
if err != nil {
return nil, anyPackages, ctxerr.Wrap(ctx, err, "checking label scoping during software install attempt")
}
if !scoped {
continue
}
if fallback == nil {
fallback = pkg
}
if installerCompatibleWithHost(pkg, host) {
return pkg, anyPackages, nil
}
}
return fallback, anyPackages, nil
}
// installerCompatibleWithHost reports whether the installer's package can run on the host's platform.
// Mirrors the platform gate in installSoftwareTitleUsingInstaller (.sh and .py run on any unix-like host).
func installerCompatibleWithHost(installer *fleet.SoftwareInstaller, host *fleet.Host) bool {
ext, requiredPlatform := installerRequiredPlatform(installer)
if requiredPlatform == "" {
return false
}
if host.FleetPlatform() == requiredPlatform {
return true
}
return (ext == ".sh" || ext == ".py") && fleet.IsUnixLike(host.Platform)
}
func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error {
// we need to use ds.Host because ds.HostLite doesn't return the orbit
// node key
host, err := svc.ds.Host(ctx, hostID)
if err != nil {
// if error is because the host does not exist, check first if the user
// had access to install software (to prevent leaking valid host ids).
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionWrite); err != nil {
return err
}
}
svc.authz.SkipAuthorization(ctx)
return ctxerr.Wrap(ctx, err, "get host")
}
platform := host.FleetPlatform()
mobileAppleDevice := fleet.InstallableDevicePlatform(platform) == fleet.IOSPlatform || fleet.InstallableDevicePlatform(platform) == fleet.IPadOSPlatform
if !mobileAppleDevice && (host.OrbitNodeKey == nil || *host.OrbitNodeKey == "") {
// fleetd is required to install software so if the host is
// enrolled via plain osquery we return an error
svc.authz.SkipAuthorization(ctx)
return fleet.NewUserMessageError(errors.New("Host doesn't have fleetd installed"), http.StatusUnprocessableEntity)
}
// authorize with the host's team
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: host.TeamID}, fleet.ActionWrite); err != nil {
return err
}
if mobileAppleDevice {
iha, err := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID)
if err != nil && !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "install in house app: get metadata")
}
if iha != nil {
scoped, err := svc.ds.IsInHouseAppLabelScoped(ctx, iha.InstallerID, hostID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking label scoping during in-house app install attempt")
}
if !scoped {
return &fleet.BadRequestError{
Message: "Couldn't install. This host isn't a member of the labels defined for this software title.",
}
}
opts := fleet.HostSoftwareInstallOptions{SelfService: false}
cfg, err := svc.ds.GetInHouseAppConfiguration(ctx, iha.InstallerID)
if err != nil && !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "get in-house app configuration for pre-flight check")
}
switch err := svc.precheckAppConfigResolvable(ctx, host, cfg); {
case errors.Is(err, apple_mdm.ErrUnresolvableAppConfigVar):
return svc.recordFailedInHouseInstall(ctx, host.ID, iha.InstallerID, opts, unresolvableAppConfigFailureReason(err))
case err != nil:
return ctxerr.Wrap(ctx, err, "pre-flight substitute fleet variables in in-house app configuration")
}
err = svc.ds.InsertHostInHouseAppInstall(ctx, host.ID, iha.InstallerID, softwareTitleID, uuid.NewString(), opts)
return ctxerr.Wrap(ctx, err, "insert in house app install")
}
// it's OK if we didn't find an in-house app; this might be a VPP app, so continue on
}
if !mobileAppleDevice {
// Resolve the first-added package the host is in label scope for (first-added-wins when the
// host matches more than one package of the title).
installer, anyPackages, err := svc.resolveFirstAddedInScopeInstaller(ctx, host, softwareTitleID, false)
if err != nil {
return err
}
// The title has packages but the host isn't in scope for any of them.
if installer == nil && anyPackages {
return &fleet.BadRequestError{
Message: "Couldn't install. Host isn't member of the labels defined for this software title.",
}
}
// if we resolved an installer, use that
if installer != nil {
lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID)
}
if lastInstallRequest != nil && lastInstallRequest.Status != nil &&
(*lastInstallRequest.Status == fleet.SoftwareInstallPending || *lastInstallRequest.Status == fleet.SoftwareUninstallPending) {
return &fleet.BadRequestError{
Message: "Couldn't install. Host already has a pending install/uninstall for this installer.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "host already has a pending install/uninstall for this installer",
map[string]any{
"host_id": host.ID,
"software_installer_id": installer.InstallerID,
"team_id": host.TeamID,
"title_id": softwareTitleID,
},
),
}
}
return svc.installSoftwareTitleUsingInstaller(ctx, host, installer)
}
}
// User-enrolled (BYOD) iOS/iPadOS hosts are no longer blocked here. The
// downstream VPP install path provisions the per-user VPP user (#44003),
// associates the asset via clientUserIds (#44004), and emits an
// InstallApplication command without ChangeManagementState (#44005).
vppApp, err := svc.ds.GetVPPAppByTeamAndTitleID(ctx, host.TeamID, softwareTitleID)
if err != nil {
// if we couldn't find an installer or a VPP app, return a bad
// request error
if fleet.IsNotFound(err) {
return &fleet.BadRequestError{
Message: "Couldn't install software. Software title is not available for install. Please add software package or App Store app to install.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "couldn't find an installer or VPP app for software title",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
return ctxerr.Wrap(ctx, err, "finding VPP app for title")
}
// check the label scoping for this VPP app and host
scoped, err := svc.ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, hostID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking label scoping during vpp software install attempt")
}
if !scoped {
return &fleet.BadRequestError{
Message: "Couldn't install. This host isn't a member of the labels defined for this software title.",
}
}
_, err = svc.installSoftwareFromVPP(ctx, host, vppApp, mobileAppleDevice || fleet.InstallableDevicePlatform(platform) == fleet.MacOSPlatform, fleet.HostSoftwareInstallOptions{
SelfService: false,
})
return err
}
func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp, appleDevice bool, opts fleet.HostSoftwareInstallOptions) (string, error) {
token, err := svc.GetVPPTokenIfCanInstallVPPApps(ctx, appleDevice, host)
if err != nil {
return "", err
}
return svc.InstallVPPAppPostValidation(ctx, host, vppApp, token, opts)
}
func (svc *Service) GetVPPTokenIfCanInstallVPPApps(ctx context.Context, appleDevice bool, host *fleet.Host) (string, error) {
if !appleDevice {
return "", &fleet.BadRequestError{
Message: "VPP apps can only be installed only on Apple hosts.",
InternalErr: ctxerr.NewWithData(
ctx, "invalid host platform for requested installer",
map[string]any{"host_id": host.ID, "team_id": host.TeamID},
),
}
}
config, err := svc.ds.AppConfig(ctx)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "fetching config to check MDM status")
}
if !config.MDM.EnabledAndConfigured {
return "", fleet.NewUserMessageError(errors.New("Couldn't install. MDM is turned off. Please make sure that MDM is turned on to install App Store apps."), http.StatusUnprocessableEntity)
}
mdmConnected, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host)
if err != nil {
return "", ctxerr.Wrapf(ctx, err, "checking MDM status for host %d", host.ID)
}
if !mdmConnected {
return "", &fleet.BadRequestError{
Message: "Error: Couldn't install. To install App Store app, turn on MDM for this host.",
InternalErr: ctxerr.NewWithData(
ctx, "VPP install attempted on non-MDM host",
map[string]any{"host_id": host.ID, "team_id": host.TeamID},
),
}
}
token, err := svc.getVPPToken(ctx, host.TeamID)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "getting VPP token")
}
return token, nil
}
// fleetVarInErrRe matches the $FLEET_VAR_* token embedded in the error
// returned by apple_mdm.SubstituteFleetVarsInAppConfig, so we can name the
// offending variable in the failure reason shown to the admin.
var fleetVarInErrRe = regexp.MustCompile(`\$FLEET_VAR_[A-Z_]+`)
// unresolvableAppConfigFailureReason builds the user-facing reason surfaced in
// the activity feed and Install Details modal when a managed app configuration
// references a Fleet variable that can't be resolved for the host. Prefers the
// typed per-variable detail (same wording configuration-profile delivery uses)
// and falls back to a generic sentence that names the variable.
func unresolvableAppConfigFailureReason(err error) string {
// All current call sites gate on errors.Is(err, …ErrUnresolvableAppConfigVar)
// before invoking, so err is non-nil in practice. Defensive nil-guard so
// nilaway can prove this and to keep the helper safe if reused.
if err == nil {
return ""
}
var typed *apple_mdm.UnresolvableAppConfigVarError
if errors.As(err, &typed) && typed.Detail != "" {
return typed.Detail
}
if v := fleetVarInErrRe.FindString(err.Error()); v != "" {
return fmt.Sprintf("The app's managed configuration references %s, which Fleet couldn't populate for this host.", v)
}
return "The app's managed configuration references a Fleet variable that can't be resolved for this host."
}
// precheckAppConfigResolvable resolves the managed app configuration's Fleet
// variables for the host without mutating anything. It returns the substitution
// error unchanged (callers check errors.Is(err, apple_mdm.ErrUnresolvableAppConfigVar)).
// cfg may be empty (no managed config), in which case it's a no-op.
func (svc *Service) precheckAppConfigResolvable(ctx context.Context, host *fleet.Host, cfg []byte) error {
if len(cfg) == 0 {
return nil
}
_, err := apple_mdm.SubstituteFleetVarsInAppConfig(ctx, svc.ds, cfg, apple_mdm.AppConfigSubstitutionHost{
UUID: host.UUID,
HardwareSerial: host.HardwareSerial,
Platform: host.Platform,
})
return err
}
// recordFailedVPPInstall records a pre-flight-failed VPP install (no license
// reserved, no command enqueued) and emits the failed-install activity.
//
// For admin / self-service / policy / auto-update paths, returning a nil error
// is intentional — the activity carries the outcome and the API responds 2xx.
// For setup-experience (opts.ForSetupExperience=true) we have to surface a
// non-nil error so the setup-experience driver transitions the step out of
// Running; otherwise it would stash the unused command UUID and wait forever
// for an MDM command result that will never arrive.
func (svc *Service) recordFailedVPPInstall(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp, opts fleet.HostSoftwareInstallOptions, reason string) (string, error) {
cmdUUID := uuid.NewString()
user, act, err := svc.ds.RecordFailedVPPAppInstall(ctx, host.ID, vppApp.VPPAppID, cmdUUID, reason, opts)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "record failed vpp install")
}
if act != nil {
if err := svc.NewActivity(ctx, user, act); err != nil {
return "", ctxerr.Wrap(ctx, err, "create activity for failed vpp install")
}
}
if opts.ForSetupExperience {
return cmdUUID, &fleet.PreflightInstallFailedError{Reason: reason}
}
return cmdUUID, nil
}
// recordFailedInHouseInstall is the in-house (.ipa) counterpart of
// recordFailedVPPInstall.
func (svc *Service) recordFailedInHouseInstall(ctx context.Context, hostID, inHouseAppID uint, opts fleet.HostSoftwareInstallOptions, reason string) error {
cmdUUID := uuid.NewString()
user, act, err := svc.ds.RecordFailedInHouseAppInstall(ctx, hostID, inHouseAppID, cmdUUID, reason, opts)
if err != nil {
return ctxerr.Wrap(ctx, err, "record failed in-house install")
}
if act != nil {
if err := svc.NewActivity(ctx, user, act); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for failed in-house install")
}
}
return nil
}
func (svc *Service) InstallVPPAppPostValidation(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp, token string, opts fleet.HostSoftwareInstallOptions) (string, error) {
// Pre-flight: resolve the managed app configuration's Fleet variables for
// this host BEFORE anything irreversible (reserving a VPP license, enqueuing
// the command). iOS/iPadOS only — macOS VPP installs drop the configuration.
// If a variable can't be resolved for this host (e.g. an IdP variable on a
// host with no IdP linkage), record a failed install and emit the
// failed-install activity instead of rejecting the request, so the failure
// is visible in the activity feed and Install Details modal. Doing this
// before AssociateAssets also avoids leaking a VPP license.
if vppApp.Platform == fleet.IOSPlatform || vppApp.Platform == fleet.IPadOSPlatform {
cfg, err := svc.ds.GetVPPAppConfiguration(ctx, vppApp.Platform, vppApp.AdamID, ptr.ValOrZero(host.TeamID))
if err != nil && !fleet.IsNotFound(err) {
return "", ctxerr.Wrap(ctx, err, "get vpp app configuration for pre-flight check")
}
switch err := svc.precheckAppConfigResolvable(ctx, host, cfg); {
case errors.Is(err, apple_mdm.ErrUnresolvableAppConfigVar):
return svc.recordFailedVPPInstall(ctx, host, vppApp, opts, unresolvableAppConfigFailureReason(err))
case err != nil:
return "", ctxerr.Wrap(ctx, err, "pre-flight substitute fleet variables in vpp app configuration")
}
}
// at this moment, neither the UI nor the back-end are prepared to
// handle [asyncronous errors][1] on assignment, so before assigning a
// device to a license, we need to:
//
// 1. Check if the app is already assigned to the serial number (or
// Managed Apple ID, for User Enrollments).
// 2. If it's not assigned yet, check if we have enough licenses.
//
// A race still might happen, so async error checking needs to be
// implemented anyways at some point.
//
// [1]: https://developer.apple.com/documentation/devicemanagement/app_and_book_management/handling_error_responses#3729433
// Resolve enrollment style first so the assignment query can address the
// right principal — serial for device-scoped licensing, clientUserId for
// user-scoped (BYOD) licensing. Without this branch the existing
// SerialNumber filter always returns empty for User Enrollments, which
// makes Fleet enter the AvailableCount check on every retry and produces
// false-positive "no available licenses" errors when the user is just
// adding their Nth (≤5) device under one Managed Apple ID.
// Device-vs-user VPP licensing must key off the actual MDM enrollment
// channel, not host_mdm.is_personal_enrollment. is_personal_enrollment is
// set for BOTH Account-Driven User Enrollment (ADUE, user-scoped, backed by
// a Managed Apple ID) and manual-profile BYOD (device channel, no Managed
// Apple ID). Only ADUE gets user-scoped licensing; manual-profile BYOD
// installs device-scoped, exactly like company-owned manual enrollment.
//
// The host's primary nano_enrollments row (id = host UUID) tells us the
// channel: ADUE devices enroll as "User Enrollment (Device)", while every
// other device-channel enrollment (ADE, manual, manual-profile BYOD) is
// "Device". Note the "User" type is the separate macOS user channel and is
// NOT what we want here. This row exists from enrollment time, whereas the
// Managed Apple ID only arrives minutes later via TokenUpdate, so this is
// the correct, timing-robust signal. See #48879.
nanoEnroll, err := svc.ds.GetNanoMDMEnrollment(ctx, host.UUID)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "looking up enrollment for VPP install")
}
isUserEnrollment := nanoEnroll != nil && nanoEnroll.Type == nanomdm.EnrollType(nanomdm.UserEnrollmentDevice).String()
var clientUserID string
if isUserEnrollment {
// Token-selection policy (per #44009): use the team's default token —
// `GetVPPTokenByTeamID` already returns the first token for the team
// (existing behavior). Multi-location support is deferred unless a
// customer hits the edge case.
personalTokenDB, err := svc.ds.GetVPPTokenByTeamID(ctx, host.TeamID)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "fetching VPP token DB row for user-enrolled install")
}
clientUserID, err = svc.ensureVPPClientUser(ctx, host, personalTokenDB)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "ensure VPP client user")
}
}
assignmentFilter := &vpp.AssignmentFilter{AdamID: vppApp.AdamID}
if isUserEnrollment {
assignmentFilter.ClientUserID = clientUserID
} else {
assignmentFilter.SerialNumber = host.HardwareSerial
}
assignments, err := vpp.GetAssignments(ctx, token, assignmentFilter)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "getting assignments from VPP API")
}
var eventID string
// assocReq is non-nil once we reserve a license below; it lets us release
// the seat (DisassociateAssets) if a later step fails, avoiding a leak.
var assocReq *vpp.AssociateAssetsRequest
// this app is not assigned to this device (or this user, for BYOD), check
// if we have licenses left and assign it.
if len(assignments) == 0 {
assets, err := vpp.GetAssets(ctx, token, &vpp.AssetFilter{AdamID: vppApp.AdamID})
if err != nil {
return "", ctxerr.Wrap(ctx, err, "getting assets from VPP API")
}
if len(assets) == 0 {
svc.logger.DebugContext(ctx, "trying to assign VPP asset to host",
"adam_id", vppApp.AdamID,
"host_serial", host.HardwareSerial,
)
return "", &fleet.BadRequestError{
Message: "Couldn't add software. <app_store_id> isn't available in Apple Business. Please purchase license in Apple Business and try again.",
InternalErr: ctxerr.Errorf(ctx, "VPP API didn't return any assets for adamID %s", vppApp.AdamID),
}
}
if len(assets) > 1 {
return "", ctxerr.Errorf(ctx, "VPP API returned more than one asset for adamID %s", vppApp.AdamID)
}
if assets[0].AvailableCount <= 0 {
return "", &fleet.BadRequestError{
Message: "Couldn't install. No available licenses. Please purchase license in Apple Business and try again.",
InternalErr: ctxerr.NewWithData(
ctx, "license available count <= 0",
map[string]any{
"host_id": host.ID,
"team_id": host.TeamID,
"adam_id": vppApp.AdamID,
"count": assets[0].AvailableCount,
},
),
}
}
req := &vpp.AssociateAssetsRequest{Assets: assets}
if isUserEnrollment {
req.ClientUserIds = []string{clientUserID}
} else {
req.SerialNumbers = []string{host.HardwareSerial}
}
eventID, err = vpp.AssociateAssets(ctx, token, req)
if err == nil {
// We reserved a seat; remember the request so we can release it if a
// later step fails.
assocReq = req
}
if err != nil {
// Apple rejects the per-user device cap (≤5 devices per Managed
// Apple ID per license). Surface it cleanly so admins can act on
// it without having to decode raw VPP error numbers.
if vpp.IsMaxDevicesPerUserError(err) {
return "", &fleet.BadRequestError{
Message: "Couldn't install. This user has reached the maximum number of devices for this app license.",
InternalErr: ctxerr.WrapWithData(ctx, err, "associate asset rejected by Apple per-user device cap", map[string]any{"host_id": host.ID, "team_id": host.TeamID, "adam_id": vppApp.AdamID}),
}
}
return "", ctxerr.Wrapf(ctx, err, "associating asset with adamID %s to host %s", vppApp.AdamID, host.HardwareSerial)
}
}
// TODO(mna): should we associate the device (give the license) only when the
// upcoming activity is ready to run? I don't think so, because then it could
// fail when it's ready to run which is probably a worse UX as once enqueued
// you expect it to succeed. But eventually, we should do better management
// of the licenses, e.g. if the upcoming activity gets cancelled, it should
// release the reserved license.
//
// But the command is definitely not enqueued now, only when activating the
// activity.
// enqueue the VPP app command to install
cmdUUID := uuid.NewString()
err = svc.ds.InsertHostVPPSoftwareInstall(ctx, host.ID, vppApp.VPPAppID, cmdUUID, eventID, opts)
if err != nil {
// The install didn't persist, so if we reserved a license seat above we
// must release it — otherwise the seat leaks (no install will ever use
// it). Best-effort: log and continue returning the original error.
if assocReq != nil {
if _, dErr := vpp.DisassociateAssets(token, assocReq); dErr != nil {
svc.logger.ErrorContext(ctx, "failed to release reserved VPP license after install insert failure",
"err", dErr, "host_id", host.ID, "adam_id", vppApp.AdamID)
}
}
return "", ctxerr.Wrapf(ctx, err, "inserting host vpp software install for host with serial %s and app with adamID %s", host.HardwareSerial, vppApp.AdamID)
}
return cmdUUID, nil
}
func (svc *Service) installSoftwareTitleUsingInstaller(ctx context.Context, host *fleet.Host, installer *fleet.SoftwareInstaller) error {
ext, requiredPlatform := installerRequiredPlatform(installer)
if requiredPlatform == "" {
// this should never happen
return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext)
}
if host.FleetPlatform() != requiredPlatform {
// Allow .sh and .py scripts for any unix-like platform (linux and darwin)
if !((ext == ".sh" || ext == ".py") && fleet.IsUnixLike(host.Platform)) {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, humanReadableRequiredPlatforms(ext, requiredPlatform)),
InternalErr: ctxerr.NewWithData(
ctx, "invalid host platform for requested installer",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": installer.TitleID},
),
}
}
}
// Reset old attempts so the new install starts fresh at attempt 1.
if err := svc.ds.ResetNonPolicyInstallAttempts(ctx, host.ID, installer.InstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "reset install attempts before new install")
}
_, err := svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, installer.InstallerID, fleet.HostSoftwareInstallOptions{
SelfService: false,
WithRetries: true,
})
return ctxerr.Wrap(ctx, err, "inserting software install request")
}
func (svc *Service) UninstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error {
// we need to use ds.Host because ds.HostLite doesn't return the orbit node key
host, err := svc.ds.Host(ctx, hostID)
fromMyDevicePage := svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) ||
svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) ||
svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL)
if err != nil {
// if error is because the host does not exist, check first if the user
// had access to install/uninstall software (to prevent leaking valid host ids).
if fleet.IsNotFound(err) {
if !fromMyDevicePage {
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionWrite); err != nil {
return err
}
}
}
svc.authz.SkipAuthorization(ctx)
return ctxerr.Wrap(ctx, err, "get host")
}
if host.OrbitNodeKey == nil || *host.OrbitNodeKey == "" {
// fleetd is required to install software so if the host is enrolled via plain osquery we return an error
svc.authz.SkipAuthorization(ctx)
return fleet.NewUserMessageError(errors.New("host does not have fleetd installed"), http.StatusUnprocessableEntity)
}
// If scripts are disabled (according to the last detail query), we return an error.
// host.ScriptsEnabled may be nil for older orbit versions.
if host.ScriptsEnabled != nil && !*host.ScriptsEnabled {
svc.authz.SkipAuthorization(ctx)
return fleet.NewUserMessageError(errors.New(fleet.RunScriptsOrbitDisabledErrMsg), http.StatusUnprocessableEntity)
}
// authorize with the host's team
if !fromMyDevicePage {
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: host.TeamID}, fleet.ActionWrite); err != nil {
return err
}
}
installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false)
if err != nil {
if fleet.IsNotFound(err) {
return &fleet.BadRequestError{
Message: "Couldn't uninstall software. Software title is not available for uninstall. Please add software package to install/uninstall.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "couldn't find an installer for software title",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
return ctxerr.Wrap(ctx, err, "finding software installer for title")
}
lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID)
}
if lastInstallRequest != nil && lastInstallRequest.Status != nil &&
(*lastInstallRequest.Status == fleet.SoftwareInstallPending || *lastInstallRequest.Status == fleet.SoftwareUninstallPending) {
return &fleet.BadRequestError{
Message: "Couldn't uninstall software. Host has a pending install/uninstall request.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "host already has a pending install/uninstall for this installer",
map[string]any{
"host_id": host.ID,
"software_installer_id": installer.InstallerID,
"team_id": host.TeamID,
"title_id": softwareTitleID,
"status": *lastInstallRequest.Status,
},
),
}
}
// Validate platform
ext, requiredPlatform := installerRequiredPlatform(installer)
if requiredPlatform == "" {
// this should never happen
return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext)
}
if host.FleetPlatform() != requiredPlatform {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Package (%s) can be uninstalled only on %s hosts.", ext, requiredPlatform),
InternalErr: ctxerr.NewWithData(
ctx, "invalid host platform for requested uninstall",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": installer.TitleID},
),
}
}
// Get the uninstall script to validate there is one, will use the standard
// script infrastructure to run it.
_, err = svc.ds.GetAnyScriptContents(ctx, installer.UninstallScriptContentID)
if err != nil {
if fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError("software_title_id", `No uninstall script exists for the provided "software_title_id".`).
WithStatus(http.StatusNotFound), "getting uninstall script contents")
}
return err
}
// Pending uninstalls will automatically show up in the UI Host Details -> Activity -> Upcoming tab.
execID := uuid.NewString()
if err = svc.insertSoftwareUninstallRequest(ctx, execID, host, installer, fromMyDevicePage); err != nil {
return err
}
return nil
}
func (svc *Service) insertSoftwareUninstallRequest(ctx context.Context, executionID string, host *fleet.Host,
installer *fleet.SoftwareInstaller, selfService bool,
) error {
if err := svc.ds.InsertSoftwareUninstallRequest(ctx, executionID, host.ID, installer.InstallerID, selfService); err != nil {
return ctxerr.Wrap(ctx, err, "inserting software uninstall request")
}
return nil
}
func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID string) (*fleet.HostSoftwareInstallerResult, error) {
if svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) ||
svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) ||
svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL) {
return svc.getDeviceSoftwareInstallResults(ctx, resultUUID)
}
// Basic auth check
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
res, err := svc.ds.GetSoftwareInstallResults(ctx, resultUUID)
if err != nil {
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionRead); err != nil {
return nil, err
}
}
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get software install result")
}
if res.HostDeletedAt == nil {
// host is not deleted, get it and authorize for the host's team
host, err := svc.ds.HostLite(ctx, res.HostID)
// if error is because the host does not exist, check first if the user
// had access to run a script (to prevent leaking valid host ids).
if err != nil {
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionRead); err != nil {
return nil, err
}
}
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get host lite")
}
// Team specific auth check
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: host.TeamID}, fleet.ActionRead); err != nil {
return nil, err
}
} else {
// host was deleted, authorize for no-team as a fallback
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionRead); err != nil {
return nil, err
}
}
res.EnhanceOutputDetails()
return res, nil
}
func (svc *Service) getDeviceSoftwareInstallResults(ctx context.Context, resultUUID string) (*fleet.HostSoftwareInstallerResult, error) {
host, ok := hostctx.FromContext(ctx)
if !ok {
return nil, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
}
res, err := svc.ds.GetSoftwareInstallResults(ctx, resultUUID)
if err != nil {
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get software install result")
} else if res.HostID != host.ID { // hosts can't see other hosts' executions
return nil, ctxerr.Wrap(ctx, common_mysql.NotFound("HostSoftwareInstallerResult"), "get host software installer results")
}
res.EnhanceOutputDetails()
return res, nil
}
func (svc *Service) GetSelfServiceUninstallScriptResult(ctx context.Context, host *fleet.Host, execID string) (*fleet.HostScriptResult, error) {
scriptResult, err := svc.ds.GetSelfServiceUninstallScriptExecutionResult(ctx, execID, host.ID)
if err != nil {
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get script result")
}
scriptResult.Hostname = host.DisplayName()
return scriptResult, nil
}
// normalizeSetupExperiencePlatforms lowercases, deduplicates, and validates
// the incoming platforms against the extension's allowlist. The "macos" alias
// is not accepted — only canonical tokens ("darwin", "linux"), consistent with
// the query/policy `platform` field. Returns an error on the first
// incompatible entry; empty input is legal.
func normalizeSetupExperiencePlatforms(platforms []string, extension string) ([]string, error) {
allowed := fleet.AllowedSetupExperiencePlatformsForExtension(extension)
allowedSet := make(map[string]struct{}, len(allowed))
for _, a := range allowed {
allowedSet[a] = struct{}{}
}
seen := make(map[string]struct{}, len(platforms))
out := make([]string, 0, len(platforms))
for _, raw := range platforms {
// No canonicalization, so "macos" is rejected rather than mapped to "darwin".
platform := strings.ToLower(strings.TrimSpace(raw))
if platform == "" {
continue
}
if _, ok := allowedSet[platform]; !ok {
return nil, fmt.Errorf(
`platform %q is not a valid "setup_experience_platform" value for a .%s package (allowed: %s)`,
raw, extension, strings.Join(allowed, ", "),
)
}
if _, ok := seen[platform]; ok {
continue
}
seen[platform] = struct{}{}
out = append(out, platform)
}
return out, nil
}
// batchNeedsWindowsTitleReconcile reports whether a batch added any Fleet-maintained app,
// in which case Windows software titles may need merging onto the installers' titles.
//
// Keyed on the maintained-app link alone rather than also on the platform: the reconcile
// is a no-op for non-Windows apps, so an unnecessary run costs one indexed scan, whereas a
// missed run leaves the uninstall action hidden until the next periodic pass.
func batchNeedsWindowsTitleReconcile(installers []*fleet.UploadSoftwareInstallerPayload) bool {
return slices.ContainsFunc(installers, func(i *fleet.UploadSoftwareInstallerPayload) bool {
return i != nil && i.FleetMaintainedAppID != nil
})
}
func (svc *Service) storeSoftware(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error {
// check if exists in the installer store
exists, err := svc.softwareInstallStore.Exists(ctx, payload.StorageID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking if installer exists")
}
if !exists {
if err := svc.softwareInstallStore.Put(ctx, payload.StorageID, payload.InstallerFile); err != nil {
return ctxerr.Wrap(ctx, err, "storing installer")
}
}
return nil
}
func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload, failOnBlankScript bool) (extension string, err error) {
if payload == nil {
return "", ctxerr.New(ctx, "payload is required")
}
if payload.InstallerFile == nil {
return "", ctxerr.New(ctx, "installer file is required")
}
ext := strings.ToLower(filepath.Ext(payload.Filename))
ext = strings.TrimPrefix(ext, ".")
if fleet.IsScriptPackage(ext) {
if err := svc.addScriptPackageMetadata(ctx, payload, ext); err != nil {
return "", err
}
return ext, nil
}
// Handle Windows zip files specially since they require scripts (like exe)
// and share magic bytes with IPA files, so we check the extension first
if ext == "zip" {
platform, err := fleet.SoftwareInstallerPlatformFromExtension(ext)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "determining platform for zip file")
}
if platform == "windows" {
// For Windows zip files, create basic metadata manually
// since they require custom install/uninstall scripts
if err := svc.addZipPackageMetadata(ctx, payload); err != nil {
return "", err
}
// Validate that install and uninstall scripts are provided
if failOnBlankScript {
if payload.InstallScript == "" {
return "", &fleet.BadRequestError{
Message: "Install script is required for .zip packages.",
}
}
if payload.UninstallScript == "" {
return "", &fleet.BadRequestError{
Message: "Uninstall script is required for .zip packages.",
}
}
}
return ext, nil
}
// For non-Windows zip files (e.g., macOS), let ExtractInstallerMetadata handle it
}
meta, err := file.ExtractInstallerMetadata(payload.InstallerFile)
if err != nil {
if errors.Is(err, file.ErrUnsupportedType) {
// The failure comes from magic-byte detection, so the file's content
// (not its extension) is what didn't match a supported format.
return "", &fleet.BadRequestError{
Message: "The file's content doesn't match a supported installer format. Supported types: .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .py, .ipa or .ps1.",
InternalErr: ctxerr.Wrap(ctx, err, "extracting metadata from installer"),
}
}
if errors.Is(err, file.ErrInvalidTarball) {
return "", &fleet.BadRequestError{
Message: "Uploaded file is not a valid .tar.gz archive.",
InternalErr: ctxerr.Wrap(ctx, err, "extracting metadata from installer"),
}
}
return "", ctxerr.Wrap(ctx, err, "extracting metadata from installer")
}
if len(meta.PackageIDs) == 0 && meta.Extension != "tar.gz" && meta.Extension != "zip" {
return "", &fleet.BadRequestError{
Message: "Unable to extract necessary metadata.",
InternalErr: ctxerr.New(ctx, "extracting package IDs from installer metadata"),
}
}
payload.Title = meta.Name
if payload.Title == "" {
// use the filename if no title from metadata
payload.Title = payload.Filename
}
payload.Version = meta.Version
payload.StorageID = hex.EncodeToString(meta.SHASum)
payload.BundleIdentifier = meta.BundleIdentifier
payload.PackageIDs = meta.PackageIDs
payload.Extension = meta.Extension
payload.UpgradeCode = meta.UpgradeCode
// reset the reader (it was consumed to extract metadata)
if err := payload.InstallerFile.Rewind(); err != nil {
return "", ctxerr.Wrap(ctx, err, "resetting installer file reader")
}
payload.InstallScript = getInstallScript(meta.Extension, meta.PackageIDs, payload.InstallScript)
// Software edits validate non-empty scripts later, so set failOnBlankScript to false
if payload.InstallScript == "" && failOnBlankScript && payload.Extension != "ipa" {
ext := strings.ToLower(payload.Extension)
if ext == "zip" {
return "", &fleet.BadRequestError{
Message: "Install script is required for .zip packages.",
}
}
return "", &fleet.BadRequestError{
Message: fmt.Sprintf("Install script is required for .%s packages.", ext),
}
}
defaultUninstallScript := file.GetUninstallScript(meta.Extension)
if payload.UninstallScript == "" || payload.UninstallScript == defaultUninstallScript || payload.UninstallScript == file.UninstallMsiWithUpgradeCodeScript {
payload.UninstallScript = defaultUninstallScript
if payload.UpgradeCode != "" {
payload.UninstallScript = file.UninstallMsiWithUpgradeCodeScript
}
}
if payload.UninstallScript == "" && failOnBlankScript && payload.Extension != "ipa" {
return "", &fleet.BadRequestError{
Message: fmt.Sprintf("Uninstall script is required for .%s packages.", strings.ToLower(payload.Extension)),
}
}
platform, err := fleet.SoftwareInstallerPlatformFromExtension(meta.Extension)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "determining platform from extension")
}
payload.Platform = platform
switch {
case payload.Extension == "ipa":
if payload.Platform == "ipados" {
payload.Source = "ipados_apps"
} else {
payload.Source = "ios_apps"
}
case payload.BundleIdentifier != "":
payload.Source = "apps"
default:
source, err := fleet.SofwareInstallerSourceFromExtensionAndName(meta.Extension, meta.Name)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "determining source from extension and name")
}
payload.Source = source
}
return meta.Extension, nil
}
func (svc *Service) addScriptPackageMetadata(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload, extension string) error {
if payload == nil {
return ctxerr.New(ctx, "payload is required")
}
if payload.InstallerFile == nil {
return ctxerr.New(ctx, "installer file is required")
}
scriptBytes, err := io.ReadAll(payload.InstallerFile)
if err != nil {
return ctxerr.Wrap(ctx, err, "reading script file")
}
if err := payload.InstallerFile.Rewind(); err != nil {
return ctxerr.Wrap(ctx, err, "resetting script file reader")
}
scriptContents := string(scriptBytes)
if err := fleet.ValidateHostScriptContents(scriptContents, true); err != nil {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Script validation failed: %s", err.Error()),
InternalErr: ctxerr.Wrap(ctx, err, "validating script contents"),
}
}
// Validate that the shebang matches the file extension
kind, directExecute, err := fleet.ShebangInfo(scriptContents)
if err != nil {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Script validation failed: %s", err.Error()),
InternalErr: ctxerr.Wrap(ctx, err, "validating script shebang"),
}
}
switch extension {
case "sh":
// allow no shebang (defaults to /bin/sh), or a supported shell shebang.
if directExecute && kind != fleet.ShebangShell {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Script validation failed: %s", fleet.ErrUnsupportedInterpreter.Error()),
InternalErr: ctxerr.New(ctx, "shell script with non-shell shebang"),
}
}
case "py":
// python scripts must be directly executable (via a python shebang).
if !directExecute || kind != fleet.ShebangPython {
return &fleet.BadRequestError{
Message: "Script validation failed: Python scripts must start with a python shebang (for example, \"#!/usr/bin/env python3\").",
InternalErr: ctxerr.New(ctx, "python script without python shebang"),
}
}
case "ps1":
// PowerShell scripts are executed via powershell.exe, shebangs are not supported.
if directExecute {
return &fleet.BadRequestError{
Message: "Script validation failed: PowerShell scripts must not start with a shebang (\"#!\").",
InternalErr: ctxerr.New(ctx, "powershell script with shebang"),
}
}
}
shaSum, err := file.SHA256FromTempFileReader(payload.InstallerFile)
if err != nil {
return ctxerr.Wrap(ctx, err, "calculating script SHA256")
}
if payload.Title == "" {
base := filepath.Base(payload.Filename)
payload.Title = strings.TrimSuffix(base, filepath.Ext(base))
}
payload.Version = ""
payload.InstallScript = scriptContents
payload.StorageID = shaSum
payload.BundleIdentifier = ""
payload.PackageIDs = nil
payload.Extension = extension
switch extension {
case "sh":
payload.Source = "sh_packages"
case "ps1":
payload.Source = "ps1_packages"
case "py":
payload.Source = "py_packages"
}
platform, err := fleet.SoftwareInstallerPlatformFromExtension(extension)
if err != nil {
return ctxerr.Wrap(ctx, err, "determining platform from extension")
}
payload.Platform = platform
return nil
}
func (svc *Service) addZipPackageMetadata(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error {
if payload == nil {
return ctxerr.New(ctx, "payload is required")
}
if payload.InstallerFile == nil {
return ctxerr.New(ctx, "installer file is required")
}
shaSum, err := file.SHA256FromTempFileReader(payload.InstallerFile)
if err != nil {
return ctxerr.Wrap(ctx, err, "calculating zip SHA256")
}
if err := payload.InstallerFile.Rewind(); err != nil {
return ctxerr.Wrap(ctx, err, "resetting zip file reader")
}
if payload.Title == "" {
base := filepath.Base(payload.Filename)
payload.Title = strings.TrimSuffix(base, filepath.Ext(base))
}
platform, err := fleet.SoftwareInstallerPlatformFromExtension("zip")
if err != nil {
return ctxerr.Wrap(ctx, err, "determining platform from extension")
}
// Don't overwrite version if it's already set (e.g., from Fleet Maintained App manifest)
// Zip files don't have extractable version metadata, so preserve any existing version
payload.StorageID = shaSum
payload.BundleIdentifier = ""
payload.PackageIDs = nil // Zip files require scripts, so no package IDs extracted
payload.Extension = "zip"
payload.Source = "programs" // Same as exe and msi
payload.Platform = platform
return nil
}
const (
batchSoftwarePrefix = "software_batch_"
// batchSoftwareDeletedSuffix is appended to the batch status key to form the key holding
// the JSON-encoded list of packages the batch will delete (or, on a dry run, would delete).
batchSoftwareDeletedSuffix = ":deleted"
// batchSoftwareCategoriesSuffix is appended to the batch status key to form the key holding
// the JSON-encoded list of self-service categories this batch added. This is required because
// we can only be certain of all categories after downloading all FMA manifests and seeing
// which default categories we might need to add.
batchSoftwareCategoriesSuffix = ":categories"
// keyExpireTime serves as a timeout for each step of the batch upload process (initial checks, download for
// a package from source, upload for a package to object storage) for each package. This timeout is refreshed
// at each step. If the timeout is reached, they key expires in Redis and the batch process is considered
// abandoned by clients checking in on it.
keyExpireTime = 4 * time.Minute
)
func (svc *Service) BatchSetSoftwareInstallers(
ctx context.Context, tmName string, payloads []*fleet.SoftwareInstallerPayload, dryRun bool,
) (string, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return "", err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return "", fleet.ErrNoContext
}
var teamID *uint
if tmName != "" {
tm, err := svc.ds.TeamByName(ctx, tmName)
if err != nil {
// If this is a dry run, the team may not have been created yet
if dryRun && fleet.IsNotFound(err) {
return "", nil
}
return "", err
}
teamID = &tm.ID
}
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
return "", ctxerr.Wrap(ctx, err, "validating authorization")
}
// Same pattern as the dry-run + team-not-found short-circuit above. Empty payload
// + dry-run has nothing to validate or stage, so skip the async round-trip — but
// only when the team also has no installers: an empty payload deletes every
// existing package, and the dry run must report each one. The client handles an
// empty UUID response gracefully.
if dryRun && len(payloads) == 0 {
pendingDeletion, err := svc.ds.GetSoftwareInstallersPendingDeletion(ctx, teamID, nil)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "checking for software installers pending deletion")
}
if len(pendingDeletion) == 0 {
svc.logger.DebugContext(ctx, "software batch dry-run skipped: empty payload and no existing installers",
"team_id", teamID,
)
return "", nil
}
}
var allScripts []string
var categoryNames []string
// Verify payloads first, to prevent starting the download+upload process if the data is invalid.
for _, payload := range payloads {
if payload.Slug != nil && *payload.Slug != "" {
err := svc.softwareInstallerPayloadFromSlug(ctx, payload, teamID)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "getting fleet maintained software installer payload from slug")
}
}
if payload.URL == "" && payload.SHA256 == "" {
return "", fleet.NewInvalidArgumentError(
"software",
"Couldn't edit software. One or more software packages is missing url or hash_sha256 fields.",
)
}
if payload.AlwaysDownload && payload.SHA256 != "" {
return "", fleet.NewInvalidArgumentError(
"software",
"Couldn't edit software. The 'always_download' option cannot be used with 'hash_sha256'.",
)
}
if len(payload.URL) > fleet.SoftwareInstallerURLMaxLength {
return "", fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("software URL is too long, must be %d characters or less", fleet.SoftwareInstallerURLMaxLength),
)
}
// Skip URL validation when it is empty or when it is for a script-only package,
// which uses a "script://" URL scheme to pass the filename
if payload.URL != "" && !strings.HasPrefix(payload.URL, "script://") {
if _, err := url.ParseRequestURI(payload.URL); err != nil {
return "", fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q) is invalid", payload.URL),
)
}
}
if !dryRun {
validatedLabels, err := ValidateSoftwareLabels(ctx, svc, teamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll)
if err != nil {
return "", err
}
payload.ValidatedLabels = validatedLabels
}
allScripts = append(allScripts, payload.InstallScript, payload.PostInstallScript, payload.UninstallScript)
// static check, so unlike the secrets validation below it also runs on
// gitops dry runs
if err := validateFleetVariablesOnInstallerScripts(ctx, &payload.InstallScript, &payload.PostInstallScript, &payload.UninstallScript); err != nil {
return "", err
}
if err := trimAndValidateCategories(ctx, payload.Categories.Value); err != nil {
return "", ctxerr.Wrap(ctx, err, "validating software categories")
}
categoryNames = append(categoryNames, payload.Categories.Value...)
}
categories, err := svc.batchAddSelfServiceCategories(ctx, teamID, categoryNames, dryRun)
if err != nil {
return "", err
}
if !dryRun {
// presence of these secrets are validated on the gitops side,
// we only want to ensure that secrets are in the database on the
// non-dry run case.
if err := svc.ds.ValidateEmbeddedSecrets(ctx, allScripts); err != nil {
return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error()))
}
if err := svc.ds.ValidateReferencedCustomHostVitals(ctx, allScripts); err != nil {
if !fleet.IsInvalidReferencedCustomHostVitalsError(err) {
return "", ctxerr.Wrap(ctx, err, "validating referenced custom host vitals")
}
return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error()))
}
}
requestUUID := uuid.NewString()
if err := svc.keyValueStore.Set(ctx, batchSoftwarePrefix+requestUUID, batchSetProcessing, keyExpireTime); err != nil {
return "", ctxerr.Wrapf(ctx, err, "failed to set key as %s", batchSetProcessing)
}
categoriesJSON, err := json.Marshal(categories)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "marshal self-service categories result")
}
if err := svc.keyValueStore.Set(ctx, batchSoftwarePrefix+requestUUID+batchSoftwareCategoriesSuffix, string(categoriesJSON), 10*time.Minute); err != nil {
return "", ctxerr.Wrap(ctx, err, "failed to set self-service categories result")
}
svc.logger.InfoContext(ctx, "software batch start",
"request_uuid", requestUUID,
"team_id", teamID,
"payloads", len(payloads),
)
go svc.softwareBatchUpload(
requestUUID,
teamID,
vc.UserID(),
payloads,
dryRun,
)
return requestUUID, nil
}
var (
errEmptyCaretVersion = errors.New("a major version must be specified after the caret (^). For example, \"^32\".")
errNonMajorVersion = errors.New("only the major version can be specified with a caret (^), without including minor and patch versions. For example, \"^32\".")
errMajorVersionNotFound = errors.New("specified major version is not available. Available versions are listed in the Fleet UI under Actions > Edit software.")
errVersionNotFound = errors.New("specified version is not available. Available versions are listed in the Fleet UI under Actions > Edit software.")
)
func (svc *Service) softwareInstallerPayloadFromSlug(ctx context.Context, payload *fleet.SoftwareInstallerPayload, teamID *uint) error {
slug := payload.Slug
if slug == nil || *slug == "" {
return nil
}
// convert nil teamID to 0 to get correct titleID
tmID := ptr.ValOrZero(teamID)
app, err := svc.ds.GetMaintainedAppBySlug(ctx, *slug, &tmID)
if err != nil {
// Return user-friendly message for generic not found error
if fleet.IsNotFound(err) {
// Must return low-level error in order to be properly handled upstream
return fleet.NewUserMessageError(
fmt.Errorf("%s isn't a supported Fleet-maintained app. See supported apps: https://fleetdm.com/learn-more-about/supported-fleet-maintained-app-slugs", *slug),
http.StatusNotFound,
)
}
return err
}
payload.RollbackVersion = strings.TrimSpace(payload.RollbackVersion)
majorVersionString, usesCaret, err := parsePinnedVersion(ctx, payload.RollbackVersion)
if err != nil {
return ctxerr.Wrap(ctx, err, "reading Fleet-maintained app pinned version")
}
// use a temporary string for calling hydrate, so we download the latest manifest but keep the
// version in the db later for the auto update cron job
hydrateVersion := payload.RollbackVersion
if usesCaret {
hydrateVersion = ""
}
_, err = maintained_apps.Hydrate(ctx, app, hydrateVersion, teamID, svc.ds)
if err != nil {
return err
}
if usesCaret {
if !versionMatchesMajor(app.Version, majorVersionString) {
// We cannot use the FMA we just got the manifest for since it is on a different major
// version, so we try to find the latest cached version and use that instead.
if app.TitleID == nil {
return fleet.NewUserMessageError(errMajorVersionNotFound, http.StatusNotFound)
}
versions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, teamID, *app.TitleID, true)
if err != nil {
return fleet.NewUserMessageError(errMajorVersionNotFound, http.StatusNotFound)
}
// This is a bit inefficient as we are duplicating strings for categories and install/uninstall scripts,
// but it can be optimized in softwareBatchUpload if it accepted only passing category and script content IDs.
installer, err := svc.ds.GetCachedFMAInstallerMetadata(ctx, teamID, app.ID, versions[0].Version)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting software installer")
}
app.Version = installer.Version
app.InstallerURL = installer.InstallerURL
app.SHA256 = installer.SHA256
app.InstallScript = installer.InstallScript
app.UninstallScript = installer.UninstallScript
app.Categories = installer.Categories
app.PatchQuery = installer.PatchQuery
}
}
payload.URL = app.InstallerURL
if app.SHA256 != noCheckHash {
payload.SHA256 = app.SHA256
}
if payload.InstallScript == "" {
payload.InstallScript = app.InstallScript
}
if payload.UninstallScript == "" {
payload.UninstallScript = app.UninstallScript
}
payload.FleetMaintained = true
payload.MaintainedApp = app
if !payload.Categories.Set {
payload.Categories = optjson.SetSlice(app.Categories)
}
payload.MaintainedApp.PatchQuery = app.PatchQuery
return nil
}
const (
batchSetProcessing = "processing"
batchSetCompleted = "completed"
batchSetFailedPrefix = "failed:"
)
// downloadInstallerURL downloads an installer from a URL. If ifNoneMatch is
// non-empty, the request includes an If-None-Match header for conditional GET.
//
// On 304 Not Modified, returns (resp, nil, nil): resp has StatusCode 304 and a
// closed body, tfr is nil. Callers MUST check resp.StatusCode before using tfr.
func downloadInstallerURL(ctx context.Context, downloadURL string, ifNoneMatch string, maxInstallerSize int64) (*http.Response, *fleet.TempFileReader, error) {
client := fleethttp.NewClient()
client.Transport = fleethttp.NewSizeLimitTransport(maxInstallerSize)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, nil, fmt.Errorf("creating request for URL %q: %w", downloadURL, err)
}
if ifNoneMatch != "" {
req.Header.Set("If-None-Match", ifNoneMatch)
}
resp, err := client.Do(req)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.Is(err, fleethttp.ErrMaxSizeExceeded) || errors.As(err, &maxBytesErr) {
return nil, nil, fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q). The maximum file size is %s", downloadURL, installersize.Human(maxInstallerSize)),
)
}
return nil, nil, fmt.Errorf("performing request for URL %q: %w", downloadURL, err)
}
// 304 Not Modified: content unchanged, return response with no body.
// Set Body to http.NoBody after closing so downstream Close() calls are safe.
if resp.StatusCode == http.StatusNotModified {
resp.Body.Close()
resp.Body = http.NoBody
return resp, nil, nil
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil, fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q) returned \"Not Found\". Please make sure that URLs are reachable from your Fleet server.", downloadURL),
)
}
// Allow all 2xx and 3xx status codes in this pass.
if resp.StatusCode >= 400 {
return nil, nil, fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q) received response status code %d.", downloadURL, resp.StatusCode),
)
}
tfr, err := fleet.NewTempFileReader(resp.Body, nil)
if err != nil {
// the max size error can be received either at client.Do or here when
// reading the body if it's caught via a limited body reader.
var maxBytesErr *http.MaxBytesError
if errors.Is(err, fleethttp.ErrMaxSizeExceeded) || errors.As(err, &maxBytesErr) {
return nil, nil, fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q). The maximum file size is %s", downloadURL, installersize.Human(maxInstallerSize)),
)
}
return nil, nil, fmt.Errorf("reading installer %q contents: %w", downloadURL, err)
}
return resp, tfr, nil
}
func (svc *Service) softwareBatchUpload(
requestUUID string,
teamID *uint,
userID uint,
payloads []*fleet.SoftwareInstallerPayload,
dryRun bool,
) {
var batchErr error
// deletedPackagesJSON holds the JSON-encoded list of packages this batch will
// delete (dry run: would delete), recorded in Redis on completion.
var deletedPackagesJSON string
// TODO: this might be a little drastic to drop back to Background context,
// consider using ctx.WithoutCancel to keep all but the cancellation of the
// parent: https://pkg.go.dev/context#WithoutCancel
// e.g. for telemetry and such.
// We do not use the request ctx on purpose because this method runs in the background.
ctx := context.Background()
defer func(start time.Time) {
// The deleted-packages list was already persisted before any datastore
// mutation; re-set it here to refresh its TTL so the client has the full
// window to read it even after a long-running batch. Best-effort only: at
// this point the batch may have already committed, so a Redis failure must
// not mark it as failed.
if batchErr == nil && deletedPackagesJSON != "" {
if err := svc.keyValueStore.Set(ctx, batchSoftwarePrefix+requestUUID+batchSoftwareDeletedSuffix, deletedPackagesJSON, 10*time.Minute); err != nil {
svc.logger.WarnContext(ctx, "failed to refresh deleted-packages result; the deletion report may be missing from the batch result",
"request_uuid", requestUUID,
"err", err,
)
}
}
status := batchSetCompleted
if batchErr != nil {
status = fmt.Sprintf("%s%s", batchSetFailedPrefix, batchErr)
}
logger := svc.logger.With(
"request_uuid", requestUUID,
"team_id", teamID,
"payloads", len(payloads),
"status", status,
"took", time.Since(start),
)
logger.InfoContext(ctx, "software batch done")
// Give 10m for the client to read the result (it overrides the previos expiration time).
if err := svc.keyValueStore.Set(ctx, batchSoftwarePrefix+requestUUID, status, 10*time.Minute); err != nil {
logger.ErrorContext(ctx, "failed to set result", "err", err)
}
}(time.Now())
// Periodically refresh the expiration on the batch install process so that, even when downloading/uploading
// large installers, we ensure the server doesn't lose track of the batch. This way, the only time a batch times
// out is if the server goes offline during running the batch.
done := make(chan struct{})
go func() {
ticker := time.NewTicker(keyExpireTime / 3) // Running keepalive much more often since we don't retry set errors
defer ticker.Stop()
for {
select {
// at this point we're done with the batch, at which point the caller will set the job in Redis as complete
// with a longer TTL, so we don't need to do anything here
case <-done:
return
case <-ticker.C:
_ = svc.keyValueStore.Set(ctx, batchSoftwarePrefix+requestUUID, batchSetProcessing, keyExpireTime)
}
}
}()
defer close(done)
maxInstallerSize := svc.config.Server.MaxInstallerSizeBytes
downloadURLFn := func(ctx context.Context, downloadURL string, ifNoneMatch string) (*http.Response, *fleet.TempFileReader, error) {
return downloadInstallerURL(ctx, downloadURL, ifNoneMatch, maxInstallerSize)
}
// retryDownload wraps downloadURLFn with the standard retry policy.
// Note: a 304 response returns nil error and is treated as success (not retried).
retryDownload := func(ctx context.Context, downloadURL, ifNoneMatch string) (*http.Response, *fleet.TempFileReader, error) {
var resp *http.Response
var tfr *fleet.TempFileReader
err := retry.Do(func() error {
// Close resources from a previous attempt to avoid leaking
// file descriptors, temp files, and HTTP connections.
if tfr != nil {
tfr.Close()
tfr = nil
}
if resp != nil && resp.Body != nil {
resp.Body.Close()
resp = nil
}
var retryErr error
resp, tfr, retryErr = downloadURLFn(ctx, downloadURL, ifNoneMatch)
return retryErr
}, retry.WithMaxAttempts(fleet.BatchDownloadMaxRetries), retry.WithInterval(fleet.BatchSoftwareInstallerRetryInterval()))
return resp, tfr, err
}
var manualAgentInstall bool
tmID := ptr.ValOrZero(teamID)
if tmID == 0 {
ac, err := svc.ds.AppConfig(ctx)
if err != nil {
batchErr = fmt.Errorf("Couldn't get app config: %w", err)
return
}
manualAgentInstall = ac.MDM.MacOSSetup.ManualAgentInstall.Value
} else {
team, err := svc.ds.TeamLite(ctx, tmID)
if err != nil {
batchErr = fmt.Errorf("Couldn't get team for team ID %d: %w", tmID, err)
return
}
manualAgentInstall = team.Config.MDM.MacOSSetup.ManualAgentInstall.Value
}
var g errgroup.Group
g.SetLimit(1) // TODO: consider whether we can increase this limit, see https://github.com/fleetdm/fleet/issues/22704#issuecomment-2397407837
// the reason for this struct with extra installers support is that:
// - ih-house apps match multiple installers to a single source installer
// payload (because an .ipa creates entries for iOS and iPadOS)
// - the for loop over each entry in the payload is executed in a goroutine
// that can only write to its pre-allocated index in the installers slice, so
// any extra installer for a given payload must be part of a single value
// inserted in that slice.
type installerPayloadWithExtras struct {
*fleet.UploadSoftwareInstallerPayload
ExtraInstallers []*fleet.UploadSoftwareInstallerPayload
}
// critical to avoid data race, the slices are pre-allocated and each
// goroutine only writes to its index.
installers := make([]*installerPayloadWithExtras, len(payloads))
toBeClosedTFRs := make([]*fleet.TempFileReader, len(payloads))
for i, p := range payloads {
i, p := i, p
g.Go(func() error {
// NOTE: cannot defer tfr.Close() here because the reader needs to be
// available after the goroutine completes. Instead, all temp file
// readers are collected in toBeClosedTFRs and will have their Close
// deferred after the join/wait of goroutines.
installer := &fleet.UploadSoftwareInstallerPayload{
TeamID: teamID,
InstallScript: p.InstallScript,
PreInstallQuery: p.PreInstallQuery,
PostInstallScript: p.PostInstallScript,
UninstallScript: p.UninstallScript,
SelfService: p.SelfService,
UserID: userID,
URL: p.URL,
InstallDuringSetup: p.InstallDuringSetup,
SetupExperiencePlatforms: p.SetupExperiencePlatforms,
LabelsIncludeAny: p.LabelsIncludeAny,
LabelsExcludeAny: p.LabelsExcludeAny,
LabelsIncludeAll: p.LabelsIncludeAll,
ValidatedLabels: p.ValidatedLabels,
Categories: p.Categories.Value,
DisplayName: p.DisplayName,
RollbackVersion: p.RollbackVersion,
AlwaysDownload: p.AlwaysDownload,
Configuration: p.Configuration,
}
var extraInstallers []*fleet.UploadSoftwareInstallerPayload
categories, catIDs, err := svc.removeDuplicateOrMissingCategories(ctx, tmID, p.Categories.Value)
if err != nil {
return ctxerr.Wrap(ctx, err, "filtering software installer categories")
}
installer.Categories = categories
installer.CategoryIDs = catIDs
// check if we already have the installer based on the SHA256 and URL
teamIDs, err := svc.ds.GetTeamsWithInstallerByHash(ctx, p.SHA256, p.URL)
if err != nil {
return err
}
foundInstallers, ok := teamIDs[tmID]
switch {
case ok:
// Perfect match: existing installer on the same team
foundInstaller := foundInstallers[0]
if foundInstaller.Extension == "exe" || foundInstaller.Extension == "tar.gz" {
if p.InstallScript == "" {
return fmt.Errorf("Couldn't edit. Install script is required for .%s packages.", foundInstaller.Extension)
}
if p.UninstallScript == "" {
return fmt.Errorf("Couldn't edit. Uninstall script is required for .%s packages.", foundInstaller.Extension)
}
}
// make a copy of the installer without filled fields in case we add
// extra installers
extraInstallerBase := *installer
if err := svc.fillSoftwareInstallerPayloadFromExisting(ctx, installer, foundInstaller, p.SHA256); err != nil {
return err
}
for _, extraInstaller := range foundInstallers[1:] {
extraPayload := extraInstallerBase
if err := svc.fillSoftwareInstallerPayloadFromExisting(ctx, &extraPayload, extraInstaller, p.SHA256); err != nil {
return err
}
extraInstallers = append(extraInstallers, &extraPayload)
}
case !ok && len(teamIDs) > 0:
// Installer(s) exists, but for another team. We should copy it over to this team
// (if we have access to the other team).
user, err := svc.ds.UserByID(ctx, userID)
if err != nil {
return err
}
userctx := viewer.NewContext(ctx, viewer.Viewer{User: user})
for tmID, teamInstallers := range teamIDs {
// use the first one to which this user has access; the specific one shouldn't
// matter because they're all the same installer bytes
var tmIDPtr *uint
if tmID != 0 {
tmIDPtr = ptr.Uint(tmID)
}
if authErr := svc.authz.Authorize(userctx, &fleet.SoftwareInstaller{TeamID: tmIDPtr}, fleet.ActionWrite); authErr != nil {
continue
}
teamInstaller := teamInstallers[0]
if teamInstaller.Extension == "exe" || teamInstaller.Extension == "zip" {
if p.InstallScript == "" {
ext := teamInstaller.Extension
return fmt.Errorf("Couldn't edit. Install script is required for .%s packages.", ext)
}
if p.UninstallScript == "" {
ext := teamInstaller.Extension
return fmt.Errorf("Couldn't edit. Uninstall script is required for .%s packages.", ext)
}
}
// make a copy of the installer without filled fields in case we add
// extra installers
extraInstallerBase := *installer
if err := svc.fillSoftwareInstallerPayloadFromExisting(ctx, installer, teamInstaller, p.SHA256); err != nil {
return err
}
for _, extraInstaller := range teamInstallers[1:] {
extraPayload := extraInstallerBase
if err := svc.fillSoftwareInstallerPayloadFromExisting(ctx, &extraPayload, extraInstaller, p.SHA256); err != nil {
return err
}
extraInstallers = append(extraInstallers, &extraPayload)
}
break
}
}
// For FMA installers, check if this version is already cached for this team.
// Match on the hash too so a rebuilt package (same version, new hash) isn't
// treated as cached and gets downloaded and upserted instead.
var fmaVersionCached bool
if p.Slug != nil && *p.Slug != "" && p.MaintainedApp != nil && p.MaintainedApp.Version != "" {
versionExists, cachedHash, err := svc.ds.HasFMAInstallerVersion(ctx, teamID, p.MaintainedApp.ID, p.MaintainedApp.Version)
if err != nil {
return ctxerr.Wrap(ctx, err, "check cached FMA version")
}
if versionExists && cachedHash == p.MaintainedApp.SHA256 {
fmaVersionCached = true
}
installer.FMAVersionCached = fmaVersionCached
}
var installerBytesExist bool
if !fmaVersionCached && p.SHA256 != "" {
installerBytesExist, err = svc.softwareInstallStore.Exists(ctx, installer.StorageID)
if err != nil {
return ctxerr.Wrap(ctx, err, "check if installer exists in store")
}
}
// no accessible matching installer was found, so attempt to download it from URL.
if !fmaVersionCached && (installer.StorageID == "" || !installerBytesExist) {
if p.SHA256 != "" && p.URL == "" {
return fmt.Errorf("package not found with hash %s", p.SHA256)
}
var tfr *fleet.TempFileReader
// Handle script packages from path (script:// URL scheme)
if filename, ok := strings.CutPrefix(p.URL, "script://"); ok {
ext := strings.ToLower(filepath.Ext(filename))
ext = strings.TrimPrefix(ext, ".")
if !fleet.IsScriptPackage(ext) {
return fmt.Errorf("script:// URL must reference a .sh, .py, or .ps1 file, got: %s", filename)
}
if p.InstallScript == "" {
return fmt.Errorf("script package %s has no install script content", filename)
}
scriptContent := []byte(p.InstallScript)
tfr, err = fleet.NewTempFileReader(bytes.NewReader(scriptContent), nil)
if err != nil {
return fmt.Errorf("creating temp file for script package %s: %w", filename, err)
}
installer.InstallerFile = tfr
toBeClosedTFRs[i] = tfr
installer.Filename = filename
} else {
// Conditional GET (default behavior, disabled by always_download: true).
// Look up existing installer by URL for its ETag, only when
// we're about to download (avoids wasted DB queries).
var existingForCache *fleet.ExistingSoftwareInstaller
var ifNoneMatch string
if !p.AlwaysDownload && p.SHA256 == "" && p.URL != "" {
// First try same-team lookup, then fall back to any team.
existing, lookupErr := svc.ds.GetInstallerByTeamAndURL(ctx, &tmID, p.URL)
if lookupErr != nil {
svc.logger.WarnContext(ctx, "conditional download lookup failed, will download normally", "url", p.URL, "err", lookupErr)
} else if existing == nil {
// Cross-team fallback: another team may already have this URL cached.
existing, lookupErr = svc.ds.GetInstallerByTeamAndURL(ctx, nil, p.URL)
if lookupErr != nil {
svc.logger.WarnContext(ctx, "cross-team conditional download lookup failed, will download normally", "url", p.URL, "err", lookupErr)
}
}
if lookupErr == nil && existing != nil && existing.StorageID != "" &&
existing.HTTPETag != nil && *existing.HTTPETag != "" &&
existing.Extension != "ipa" && // skip conditional download for .ipa (multi-platform extraInstallers)
validETag(*existing.HTTPETag) { // re-validate before use as defense-in-depth
existingForCache = existing
ifNoneMatch = *existing.HTTPETag
}
}
resp, tfr, err := retryDownload(ctx, p.URL, ifNoneMatch)
if err != nil {
return err
}
// Handle 304 Not Modified (conditional download with matching ETag).
// TRUST ASSUMPTION: conditional download trusts the origin server's
// ETag as a content fingerprint, so we reuse the cached installer
// bytes and metadata (filename, version, extension, etc.) without
// re-extraction. Flow continues past the download-specific code so
// that script fields from the user's GitOps config still pass
// through the shared normalization/validation below.
var cacheHit bool
if resp != nil && resp.StatusCode == http.StatusNotModified && existingForCache != nil {
bytesExist, existErr := svc.softwareInstallStore.Exists(ctx, existingForCache.StorageID)
if existErr == nil && bytesExist {
if err := svc.fillSoftwareInstallerPayloadFromExisting(ctx, installer, existingForCache, existingForCache.StorageID); err != nil {
return err
}
installer.HTTPETag = existingForCache.HTTPETag
// Propagate the existing hash so FMA hydration below
// doesn't try to recompute it from the (nil) file
// reader when the manifest uses noCheckHash.
if p.MaintainedApp != nil {
p.MaintainedApp.SHA256 = existingForCache.StorageID
}
cacheHit = true
} else {
svc.logger.WarnContext(ctx, "304 received but installer bytes missing, re-downloading", "url", p.URL)
resp, tfr, err = retryDownload(ctx, p.URL, "")
if err != nil {
return err
}
if resp != nil && resp.StatusCode == http.StatusNotModified {
return fmt.Errorf("server returned 304 on unconditional re-download of %q", p.URL)
}
}
}
if !cacheHit {
// Protocol violation guards: downloadURLFn never returns nil resp
// on success, but guard defensively for server misbehavior.
if resp == nil || tfr == nil {
statusCode := 0
if resp != nil {
statusCode = resp.StatusCode
}
return fmt.Errorf("download of %q returned no body (status %d)", p.URL, statusCode)
}
installer.InstallerFile = tfr
toBeClosedTFRs[i] = tfr
filename := maintained_apps.FilenameFromResponse(resp)
installer.Filename = filename
// Always capture ETag from download response so it's available
// immediately if always_download is later disabled.
if etag := resp.Header.Get("ETag"); etag != "" && validETag(etag) {
installer.HTTPETag = &etag
} else {
svc.logger.DebugContext(ctx, "no usable ETag from server for conditional download", "url", p.URL, "etag", resp.Header.Get("ETag"))
}
// In-house apps (.ipa) don't support custom scripts or a
// pre-install query; clear them.
ext := strings.ToLower(filepath.Ext(filename))
ext = strings.TrimPrefix(ext, ".")
if ext == "ipa" {
installer.InstallScript = ""
installer.PostInstallScript = ""
installer.UninstallScript = ""
installer.PreInstallQuery = ""
}
}
}
}
if p.Slug != nil && *p.Slug != "" {
// Fleet maintained software hydration
// This code should be extracted for common use from here and AddFleetMaintainedApp in maintained_apps.go
// It's the same code and would be nice to get some reuse
appName := p.MaintainedApp.UniqueIdentifier
if p.MaintainedApp.Platform == "darwin" || appName == "" {
appName = p.MaintainedApp.Name
}
if installer.Filename == "" {
parsedURL, err := url.Parse(installer.URL)
if err != nil {
return fmt.Errorf("Error with maintained app, parsing URL: %v\n", err)
}
installer.Filename = path.Base(parsedURL.Path)
}
// noCheckHash is used by homebrew to signal that a hash shouldn't be checked
// This comes from the manifest and is a special case for maintained apps
// we need to generate the SHA256 from the installer file.
// Skip when version is cached — the existing row already has the computed hash.
if !fmaVersionCached && p.MaintainedApp.SHA256 == noCheckHash {
// generate the SHA256 from the installer file
if installer.InstallerFile == nil {
return fmt.Errorf("maintained app %s requires hash to be generated but no installer file found", p.MaintainedApp.UniqueIdentifier)
}
p.MaintainedApp.SHA256, err = file.SHA256FromTempFileReader(installer.InstallerFile)
if err != nil {
return fmt.Errorf("maintained app %s error generating hash: %w", p.MaintainedApp.UniqueIdentifier, err)
}
}
extension := strings.TrimLeft(filepath.Ext(installer.Filename), ".")
installer.Title = appName
installer.Version = p.MaintainedApp.Version
// Some FMAs (e.g. Chrome for macOS) aren't version-pinned by URL, so we have to extract the
// version from the package once we download it.
// Skip when version is cached — the existing row already has the correct version.
if !fmaVersionCached && installer.Version == "latest" && installer.InstallerFile != nil {
meta, err := file.ExtractInstallerMetadata(installer.InstallerFile)
if err != nil {
return ctxerr.Wrap(ctx, err, "extracting installer metadata")
}
// reset the reader (it was consumed to extract metadata)
if err := installer.InstallerFile.Rewind(); err != nil {
return ctxerr.Wrap(ctx, err, "resetting installer file reader")
}
installer.Version = meta.Version
}
installer.Platform = p.MaintainedApp.Platform
installer.Source = p.MaintainedApp.Source()
if installer.Source == "programs" && p.MaintainedApp.UpgradeCode != "" {
installer.UpgradeCode = p.MaintainedApp.UpgradeCode
}
installer.Extension = extension
installer.BundleIdentifier = p.MaintainedApp.BundleIdentifier()
installer.StorageID = p.MaintainedApp.SHA256
installer.FleetMaintainedAppID = &p.MaintainedApp.ID
installer.PatchQuery = p.MaintainedApp.PatchQuery
}
var ext string
if installer.FleetMaintainedAppID == nil && installer.InstallerFile != nil {
ext, err = svc.addMetadataToSoftwarePayload(ctx, installer, true)
if err != nil {
return err
}
if p.SHA256 != "" && p.SHA256 != installer.StorageID {
// this isn't the specified installer, so return an error
return fmt.Errorf("downloaded installer hash does not match provided hash for installer with url %s", p.URL)
}
}
// Managed app configuration is only supported for iOS / iPadOS in-house apps.
if installer.Extension != "ipa" {
installer.Configuration = nil
}
switch {
case fleet.IsScriptPackage(installer.Extension):
// Keep the file-derived install script and the provided post-install,
// uninstall, and pre-install query; skip the default-script injection
// below. Path-based script packages carry their filename in a
// "script://" url — an internal placeholder, not a real download url,
// so don't persist it.
if strings.HasPrefix(installer.URL, "script://") {
installer.URL = ""
}
case installer.Extension != "exe":
// custom scripts only for exe installers and non-script packages
installer.InstallScript = getInstallScript(installer.Extension, installer.PackageIDs, installer.InstallScript)
if installer.UninstallScript == "" {
installer.UninstallScript = file.GetUninstallScript(installer.Extension)
}
case installer.Extension == "ipa":
installer.PostInstallScript = ""
installer.UninstallScript = ""
installer.PreInstallQuery = ""
installer.InstallScript = ""
}
if fleet.IsMacOSPlatform(installer.Platform) && ptr.ValOrZero(installer.InstallDuringSetup) && manualAgentInstall {
return errors.New(`Couldn't edit software. "setup_experience" cannot be used for macOS software if "macos_manual_agent_install" is enabled.`)
}
// Canonicalize and reject platforms incompatible with the
// installer's extension before the batch reaches the datastore.
// When set, this field is authoritative for the installer's setup
// experience state — including the native platform, which
// overrides whatever setup_experience said on the same payload.
if installer.SetupExperiencePlatforms != nil {
normalized, err := normalizeSetupExperiencePlatforms(*installer.SetupExperiencePlatforms, installer.Extension)
if err != nil {
return fmt.Errorf("Couldn't edit software. %s: %s", installer.Filename, err.Error())
}
installer.SetupExperiencePlatforms = &normalized
if slices.Contains(normalized, "darwin") && manualAgentInstall {
return errors.New(`Couldn't edit software. "setup_experience_platform" cannot include macOS if "macos_manual_agent_install" is enabled.`)
}
nativeSelected := slices.Contains(normalized, installer.Platform)
installer.InstallDuringSetup = &nativeSelected
}
// Update $PACKAGE_ID/$UPGRADE_CODE in uninstall script
if err := preProcessUninstallScript(installer); err != nil {
return fmt.Errorf("processing uninstall script: %w", err)
}
// A script package's install script is the uploaded file, validated in
// addScriptPackageMetadata, so only post-install/uninstall are checked here.
scriptsToValidate := []struct {
name string
content string
}{
{"post-install script", installer.PostInstallScript},
{"uninstall script", installer.UninstallScript},
}
if !fleet.IsScriptPackage(installer.Extension) {
scriptsToValidate = append(scriptsToValidate, struct {
name string
content string
}{"install script", installer.InstallScript})
}
for _, sv := range scriptsToValidate {
if err := fleet.ValidateSoftwareInstallerScript(sv.content, installer.Platform); err != nil {
return fmt.Errorf("Couldn't edit software. %s validation failed: %s", sv.name, err.Error())
}
}
// if filename was empty, try to extract it from the URL with the
// now-known extension
if installer.Filename == "" {
installer.Filename = file.ExtractFilenameFromURLPath(p.URL, ext)
}
// if empty, resort to a default name
if installer.Filename == "" {
installer.Filename = fmt.Sprintf("package.%s", ext)
}
if installer.Title == "" && installer.Extension != "ipa" {
// If an IPA is specified via hash rather than downloaded via URL, we won't have a title populated,
// and should try to pull the title from the database if it exists. If we can't extract title name for
// some reason, filename should only be used after attempting to pull data from the database.
installer.Title = installer.Filename
}
// if this is an .ipa and there is no extra installer, create it here
if installer.Extension == "ipa" && len(extraInstallers) == 0 {
extraPayload := *installer
switch installer.Platform {
case string(fleet.IOSPlatform):
extraPayload.Platform = string(fleet.IPadOSPlatform)
extraPayload.Source = "ipados_apps"
case string(fleet.IPadOSPlatform):
extraPayload.Platform = string(fleet.IOSPlatform)
extraPayload.Source = "ios_apps"
}
extraInstallers = append(extraInstallers, &extraPayload)
}
installers[i] = &installerPayloadWithExtras{
UploadSoftwareInstallerPayload: installer,
ExtraInstallers: extraInstallers,
}
return nil
})
}
waitErr := g.Wait()
// defer close for any valid temp file reader
for _, tfr := range toBeClosedTFRs {
if tfr != nil {
defer tfr.Close()
}
}
if waitErr != nil {
// NOTE: intentionally not wrapping to avoid polluting user errors.
batchErr = waitErr
return
}
// Compute which existing packages this batch will delete (dry run: would
// delete): the installers on the team whose title matches no incoming
// payload, mirroring the title-based deletion in ds.BatchSetSoftwareInstallers.
incoming := make([]fleet.SoftwareTitleIdentifier, 0, len(installers))
for _, payloadWithExtras := range installers {
for _, p := range append([]*fleet.UploadSoftwareInstallerPayload{payloadWithExtras.UploadSoftwareInstallerPayload}, payloadWithExtras.ExtraInstallers...) {
incoming = append(incoming, fleet.SoftwareTitleIdentifier{
UniqueIdentifier: p.UniqueIdentifier(),
Source: p.Source,
})
}
}
deletedPackages, err := svc.ds.GetSoftwareInstallersPendingDeletion(ctx, teamID, incoming)
if err != nil {
batchErr = fmt.Errorf("computing software packages pending deletion: %w", err)
return
}
if len(deletedPackages) > 0 {
deletedJSON, err := json.Marshal(deletedPackages)
if err != nil {
batchErr = fmt.Errorf("encoding software packages pending deletion: %w", err)
return
}
deletedPackagesJSON = string(deletedJSON)
// Persist before any datastore mutation: a failure here fails the batch
// while it is still safe to retry (nothing has been applied or deleted),
// so deletion warnings are never silently missing. The defer refreshes
// this key's TTL on completion for long-running batches.
if err := svc.keyValueStore.Set(ctx, batchSoftwarePrefix+requestUUID+batchSoftwareDeletedSuffix, deletedPackagesJSON, 10*time.Minute); err != nil {
batchErr = fmt.Errorf("recording software packages pending deletion: %w", err)
return
}
}
if dryRun {
return
}
var inHouseInstallers, softwareInstallers []*fleet.UploadSoftwareInstallerPayload
for _, payloadWithExtras := range installers {
payload := payloadWithExtras.UploadSoftwareInstallerPayload
if !payload.FMAVersionCached {
batchErr = retry.Do(func() error {
if retryErr := svc.storeSoftware(ctx, payload); retryErr != nil {
return fmt.Errorf("storing software installer %q: %w", payload.Filename, retryErr)
}
return nil
}, retry.WithMaxAttempts(fleet.BatchUploadMaxRetries), retry.WithInterval(fleet.BatchSoftwareInstallerRetryInterval()))
}
if payload.Extension == "ipa" {
inHouseInstallers = append(inHouseInstallers, payload)
inHouseInstallers = append(inHouseInstallers, payloadWithExtras.ExtraInstallers...)
} else {
softwareInstallers = append(softwareInstallers, payload)
softwareInstallers = append(softwareInstallers, payloadWithExtras.ExtraInstallers...)
}
}
if err := svc.ds.BatchSetSoftwareInstallers(ctx, teamID, softwareInstallers); err != nil {
batchErr = fmt.Errorf("batch set software installers: %w", err)
return
}
if err := svc.ds.BatchSetInHouseAppsInstallers(ctx, teamID, inHouseInstallers); err != nil {
batchErr = fmt.Errorf("batch set in-house apps installers: %w", err)
return
}
// Windows programs report the version inside their name, so software already
// inventoried for an app in this batch sits under a versioned software title rather
// than the one its installer now owns, and the uninstall action stays hidden until
// they are merged. Matches what the single-add path does, so a team managed through
// GitOps is not left waiting for the periodic pass.
//
// Once for the whole batch rather than per installer: the pass covers every added app
// in one scan. Best effort, since the installers are committed at this point and the
// periodic pass will redo it.
//
// Triggered by the maintained-app link alone rather than also checking the platform.
// The pass is a no-op for non-Windows apps, so an unnecessary run costs one indexed
// scan, whereas a missed run leaves the uninstall action hidden until the next
// periodic pass. Not worth depending on Platform being populated this far down the
// batch payload chain to save that.
if batchNeedsWindowsTitleReconcile(softwareInstallers) {
if err := svc.ds.ReconcileWindowsMaintainedAppSoftwareTitles(ctx); err != nil {
svc.logger.WarnContext(ctx, "reconciling Windows software titles after a software batch",
"team_id", teamID,
"err", err,
)
}
}
// Reconcile cross-platform setup experience selections when the incoming
// batch mentions them. A batch that never touches setup_experience_platform
// leaves the cross-table alone so UI-set selections aren't clobbered.
if err := svc.reconcileGitOpsSetupExperienceCrossInstallers(ctx, ptr.ValOrZero(teamID), softwareInstallers); err != nil {
batchErr = fmt.Errorf("reconciling cross-platform setup experience selections: %w", err)
return
}
// Note: per @noahtalerman we don't want activity items for CLI actions
// anymore, so that's intentionally skipped.
}
// reconcileGitOpsSetupExperienceCrossInstallers rewrites the
// setup_experience_software_installers rows for each installer in the batch
// that explicitly sets SetupExperiencePlatforms. Installers with nil
// SetupExperiencePlatforms are left alone — that preserves cross-platform
// selections made by another caller (UI, unrelated batch) for installers that
// this apply didn't opt into.
func (svc *Service) reconcileGitOpsSetupExperienceCrossInstallers(
ctx context.Context,
teamID uint,
payloads []*fleet.UploadSoftwareInstallerPayload,
) error {
type key struct{ filename, platform string }
optedIn := make(map[key][]string)
var allKeys []key
seenKey := make(map[key]struct{})
for _, p := range payloads {
if p == nil || p.SetupExperiencePlatforms == nil {
continue
}
k := key{filename: p.Filename, platform: p.Platform}
// Filter to non-native platforms — the native platform is expressed
// via install_during_setup, not the cross-table.
var targets []string
for _, target := range *p.SetupExperiencePlatforms {
if target == p.Platform {
continue
}
targets = append(targets, target)
}
optedIn[k] = targets
if _, ok := seenKey[k]; !ok {
seenKey[k] = struct{}{}
allKeys = append(allKeys, k)
}
}
if len(allKeys) == 0 {
return nil
}
// (filename, platform) is uniquely constrained per team, so at most one
// row matches each pair.
filenames := make([]string, 0, len(allKeys))
platforms := make([]string, 0, len(allKeys))
for _, k := range allKeys {
filenames = append(filenames, k.filename)
platforms = append(platforms, k.platform)
}
rows, err := svc.ds.GetSoftwareInstallerIDsByTeamAndFilenamePlatform(ctx, teamID, filenames, platforms)
if err != nil {
return ctxerr.Wrap(ctx, err, "look up installer ids for cross-platform reconcile")
}
idByKey := make(map[key]uint, len(rows))
for _, r := range rows {
idByKey[key{filename: r.Filename, platform: r.Platform}] = r.ID
}
for _, k := range allKeys {
id, ok := idByKey[k]
if !ok {
// Installer not found — batch inserts and lookups are eventually
// consistent; skip rather than fail the apply.
continue
}
if err := svc.ds.SetSetupExperienceCrossInstallersForInstaller(ctx, id, teamID, optedIn[k]); err != nil {
return ctxerr.Wrap(ctx, err, "set cross-platform setup experience installer rows")
}
}
return nil
}
func (svc *Service) fillSoftwareInstallerPayloadFromExisting(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload, existing *fleet.ExistingSoftwareInstaller, sha256Hash string) error {
payload.Extension = existing.Extension
payload.Filename = existing.Filename
payload.Version = existing.Version
payload.Platform = existing.Platform
payload.Source = existing.Source
if existing.BundleIdentifier != nil {
payload.BundleIdentifier = *existing.BundleIdentifier
}
payload.Title = existing.Title
payload.StorageID = sha256Hash
payload.PackageIDs = existing.PackageIDs
if fleet.IsScriptPackage(existing.Extension) {
contents, err := svc.ds.GetAnyScriptContents(ctx, existing.InstallScriptContentID)
if err != nil {
return ctxerr.Wrap(ctx, err, "fetch install script for hash-matched script package")
}
payload.InstallScript = string(contents)
}
return nil
}
// validETag checks if an ETag value is a strong ETag per RFC 7232
// section 2.3: a quoted opaque-tag without the weak validator prefix.
// Weak ETags (W/"...") are rejected because they indicate semantic
// equivalence rather than byte-for-byte identity, which is insufficient
// for validating cached binary installers.
// The opaque-tag body must consist of RFC 7232 etagc characters
// (%x21 / %x23-7E), which excludes control chars, spaces, inner DQUOTEs,
// and DEL. We reject obs-text (>0x7F) for defense-in-depth. Values over
// 512 bytes are rejected.
func validETag(etag string) bool {
if len(etag) > 512 {
return false
}
// Reject weak ETags — they don't guarantee byte-identical content.
if strings.HasPrefix(etag, "W/") {
return false
}
e := etag
if len(e) < 2 || e[0] != '"' || e[len(e)-1] != '"' {
return false
}
for i := 1; i < len(e)-1; i++ {
c := e[i]
// RFC 7232 etagc = %x21 / %x23-7E / obs-text. Reject obs-text
// (>0x7F) for defense-in-depth.
if c != 0x21 && (c < 0x23 || c > 0x7E) {
return false
}
}
return true
}
func (svc *Service) GetBatchSetSoftwareInstallersResult(ctx context.Context, tmName string, requestUUID string, dryRun bool) (string, string, []fleet.SoftwarePackageResponse, []fleet.DeletedSoftwarePackage, []string, error) {
// We've already authorized in the POST /api/latest/fleet/software/batch,
// but adding it here so we don't need to worry about a special case endpoint.
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return "", "", nil, nil, nil, err
}
result, err := svc.keyValueStore.Get(ctx, batchSoftwarePrefix+requestUUID)
if err != nil {
return "", "", nil, nil, nil, ctxerr.Wrap(ctx, err, "failed to get result")
}
if result == nil {
return "", "", nil, nil, nil, ctxerr.Wrap(ctx, &notFoundError{}, "request_uuid not found")
}
// getDeletedPackages loads the packages the batch deleted (dry run: would
// delete). A missing or expired key degrades to an empty list, not an error.
getDeletedPackages := func() ([]fleet.DeletedSoftwarePackage, error) {
deletedJSON, err := svc.keyValueStore.Get(ctx, batchSoftwarePrefix+requestUUID+batchSoftwareDeletedSuffix)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to get deleted packages result")
}
if deletedJSON == nil || *deletedJSON == "" {
return nil, nil
}
var deletedPackages []fleet.DeletedSoftwarePackage
if err := json.Unmarshal([]byte(*deletedJSON), &deletedPackages); err != nil {
return nil, ctxerr.Wrap(ctx, err, "unmarshal deleted packages result")
}
return deletedPackages, nil
}
// getCategories loads the self-service categories the batch's software references
getCategories := func() ([]string, error) {
categoriesJSON, err := svc.keyValueStore.Get(ctx, batchSoftwarePrefix+requestUUID+batchSoftwareCategoriesSuffix)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to get categories result")
}
if categoriesJSON == nil || *categoriesJSON == "" {
return nil, nil
}
var categories []string
if err := json.Unmarshal([]byte(*categoriesJSON), &categories); err != nil {
return nil, ctxerr.Wrap(ctx, err, "unmarshal categories result")
}
return categories, nil
}
switch {
case *result == batchSetCompleted:
// fall through to retrieving the (deleted) software packages below.
case *result == batchSetProcessing:
return fleet.BatchSetSoftwareInstallersStatusProcessing, "", nil, nil, nil, nil
case strings.HasPrefix(*result, batchSetFailedPrefix):
message := strings.TrimPrefix(*result, batchSetFailedPrefix)
return fleet.BatchSetSoftwareInstallersStatusFailed, message, nil, nil, nil, nil
default:
return "", "", nil, nil, nil, ctxerr.New(ctx, "invalid status")
}
var (
teamID uint // GetSoftwareInstallers uses 0 for "No team"
ptrTeamID *uint // Authorize uses *uint for "No team" teamID
)
if tmName != "" {
team, err := svc.ds.TeamByName(ctx, tmName)
if err != nil {
return "", "", nil, nil, nil, ctxerr.Wrap(ctx, err, "load team by name")
}
teamID = team.ID
ptrTeamID = &team.ID
}
// We've already authorized in the POST /api/latest/fleet/software/batch,
// but adding it here so we don't need to worry about a special case endpoint.
//
// We use fleet.ActionWrite because this method is the counterpart of the POST
// /api/latest/fleet/software/batch. This applies to dry runs too, since the
// deleted-packages list exposes team-scoped software data.
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: ptrTeamID}, fleet.ActionWrite); err != nil {
return "", "", nil, nil, nil, ctxerr.Wrap(ctx, err, "validating authorization")
}
deletedPackages, err := getDeletedPackages()
if err != nil {
return "", "", nil, nil, nil, err
}
categories, err := getCategories()
if err != nil {
return "", "", nil, nil, nil, err
}
if dryRun {
return fleet.BatchSetSoftwareInstallersStatusCompleted, "", nil, deletedPackages, categories, nil
}
softwarePackages, err := svc.ds.GetSoftwareInstallers(ctx, teamID)
if err != nil {
return "", "", nil, nil, nil, ctxerr.Wrap(ctx, err, "get software installers")
}
return fleet.BatchSetSoftwareInstallersStatusCompleted, "", softwarePackages, deletedPackages, categories, nil
}
func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *fleet.Host, softwareTitleID uint) error {
// User-enrolled (BYOD) iOS/iPadOS hosts are no longer blocked from
// self-service. The downstream VPP install flow handles user-scoped
// licensing via clientUserIds. End-to-end success still depends on the
// main install-gate removal landing (#31138 subtask 01).
// Resolve the first-added self-service package the host is in label scope for (first-added-wins
// when the host matches more than one self-service package of the title).
installer, anyPackages, err := svc.resolveFirstAddedInScopeInstaller(ctx, host, softwareTitleID, true)
if err != nil {
return err
}
if installer == nil && anyPackages {
// The title has packages but none is available to this host through self-service. Distinguish
// "in scope but not self-service enabled" from "not a label member" to preserve the existing
// error messages.
inScopeInstaller, _, err := svc.resolveFirstAddedInScopeInstaller(ctx, host, softwareTitleID, false)
if err != nil {
return err
}
if inScopeInstaller != nil {
return &fleet.BadRequestError{
Message: "Software title is not available through self-service",
InternalErr: ctxerr.NewWithData(
ctx, "software title not available through self-service",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
return &fleet.BadRequestError{
Message: "Couldn't install. Host isn't member of the labels defined for this software title.",
}
}
if installer != nil {
ext, requiredPlatform := installerRequiredPlatform(installer)
if requiredPlatform == "" {
// this should never happen
return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext)
}
if host.FleetPlatform() != requiredPlatform {
// Allow .sh and .py scripts for any unix-like platform (linux and darwin)
if !((ext == ".sh" || ext == ".py") && fleet.IsUnixLike(host.Platform)) {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, humanReadableRequiredPlatforms(ext, requiredPlatform)),
InternalErr: ctxerr.WrapWithData(
ctx, err, "invalid host platform for requested installer",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
}
if err := svc.ds.ResetNonPolicyInstallAttempts(ctx, host.ID, installer.InstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "reset install attempts before self-service install")
}
_, err = svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, installer.InstallerID, fleet.HostSoftwareInstallOptions{
SelfService: true,
WithRetries: true,
})
return ctxerr.Wrap(ctx, err, "inserting self-service software install request")
}
vppApp, err := svc.ds.GetVPPAppByTeamAndTitleID(ctx, host.TeamID, softwareTitleID)
if err != nil {
// if we couldn't find an installer or a VPP app, try an in-house app
if fleet.IsNotFound(err) {
return svc.selfServiceInstallInHouseApp(ctx, host, softwareTitleID)
}
return ctxerr.Wrap(ctx, err, "finding VPP app for title")
}
if !vppApp.SelfService {
return &fleet.BadRequestError{
Message: "Software title is not available through self-service",
InternalErr: ctxerr.NewWithData(
ctx, "software title not available through self-service",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
scoped, err := svc.ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking vpp label scoping during software install attempt")
}
if !scoped {
return &fleet.BadRequestError{
Message: "Couldn't install. This software is not available for this host.",
}
}
platform := host.FleetPlatform()
mobileAppleDevice := fleet.InstallableDevicePlatform(platform) == fleet.IOSPlatform || fleet.InstallableDevicePlatform(platform) == fleet.IPadOSPlatform
_, err = svc.installSoftwareFromVPP(ctx, host, vppApp, mobileAppleDevice || fleet.InstallableDevicePlatform(platform) == fleet.MacOSPlatform, fleet.HostSoftwareInstallOptions{
SelfService: true,
})
return err
}
func (svc *Service) SelfServiceInstallAllSoftwareTitles(ctx context.Context, host *fleet.Host, categoryID *uint) error {
// get available self-service titles sorted by name
titles, categoryName, err := svc.ds.GetSoftwareTitlesForInstallAll(ctx, host, categoryID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get software titles for install all")
}
// Queue individual install activities for each title. If any errors occurred while
// queuing this title we log them and continue to the next software title.
var queuedCount uint
for _, title := range titles {
if err := svc.SelfServiceInstallSoftwareTitle(ctx, host, title.ID); err != nil {
svc.logger.ErrorContext(ctx, "enqueuing software install", "title_id", title.ID, "err", err)
continue
}
queuedCount++
}
if queuedCount == 0 {
return nil
}
if err := svc.NewActivity(ctx, nil, fleet.ActivityTypeInstalledAllSelfServiceSoftware{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
SelfServiceCategoryID: categoryID,
SelfServiceCategoryName: categoryName,
SoftwareTitlesCount: queuedCount,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating installed all self-service software activity")
}
return nil
}
// branching out this call so it doesn't conflict with work in parallel in the
// self-service install method, and it would be good to isolate the installers
// and VPP apps logic too later on.
func (svc *Service) selfServiceInstallInHouseApp(ctx context.Context, host *fleet.Host, softwareTitleID uint) error {
iha, err := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID)
if err != nil {
if fleet.IsNotFound(err) {
return &fleet.BadRequestError{
Message: "Couldn't install software. Software title is not available for install. Please add software package or App Store app to install.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "couldn't find an installer, VPP app or in-house app for software title",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
return ctxerr.Wrap(ctx, err, "install in house app: get metadata")
}
if !iha.SelfService {
return &fleet.BadRequestError{
Message: "Software title is not available through self-service",
InternalErr: ctxerr.NewWithData(
ctx, "software title not available through self-service",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
scoped, err := svc.ds.IsInHouseAppLabelScoped(ctx, iha.InstallerID, host.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking label scoping during in-house app install attempt")
}
if !scoped {
return &fleet.BadRequestError{
Message: "Couldn't install. This software is not available for this host.",
}
}
opts := fleet.HostSoftwareInstallOptions{SelfService: true}
cfg, err := svc.ds.GetInHouseAppConfiguration(ctx, iha.InstallerID)
if err != nil && !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "get in-house app configuration for pre-flight check")
}
switch err := svc.precheckAppConfigResolvable(ctx, host, cfg); {
case errors.Is(err, apple_mdm.ErrUnresolvableAppConfigVar):
return svc.recordFailedInHouseInstall(ctx, host.ID, iha.InstallerID, opts, unresolvableAppConfigFailureReason(err))
case err != nil:
return ctxerr.Wrap(ctx, err, "pre-flight substitute fleet variables in in-house app configuration")
}
err = svc.ds.InsertHostInHouseAppInstall(ctx, host.ID, iha.InstallerID, softwareTitleID, uuid.NewString(), opts)
return ctxerr.Wrap(ctx, err, "insert in house app install")
}
// installerRequiredPlatform returns the file extension and the platform used for
// platform validation. The installer's stored Platform is used when set (e.g.
// .zip installers may target windows or darwin). Note that `.sh` installers are
// stored as platform=linux but are allowed on any unix-like host by callers.
func installerRequiredPlatform(installer *fleet.SoftwareInstaller) (ext, requiredPlatform string) {
ext = filepath.Ext(installer.Name)
if installer.Platform != "" {
return ext, installer.Platform
}
return ext, packageExtensionToPlatform(ext)
}
// humanReadableRequiredPlatforms returns the platform(s) named in the
// "can be installed only on ..." rejection message. .sh/.py script packages
// are stored/derived as "linux" but the install gate also permits darwin
// (see fleet.IsUnixLike), so they need the two-platform wording.
func humanReadableRequiredPlatforms(ext, requiredPlatform string) string {
if ext == ".sh" || ext == ".py" {
return "macOS and Linux"
}
switch requiredPlatform {
case "darwin":
return "macOS"
case "windows":
return "Windows"
case "linux":
return "Linux"
default:
return requiredPlatform
}
}
// packageExtensionToPlatform returns the platform name based on the
// package extension. Returns an empty string if there is no match. This is only
// used as a fallback by installerRequiredPlatform when an installer has no
// stored Platform; prefer the stored Platform, which is authoritative.
//
// .msix is included for Fleet-maintained Windows apps only; custom package
// upload still rejects .msix (see addMetadataToSoftwarePayload and
// SoftwareInstallerPlatformFromExtension).
//
// .zip is intentionally omitted: it is ambiguous across platforms (a Windows
// installer or a macOS app bundle), so the stored Platform must be used. Both
// FMAs and uploads always set Platform for .zip, so this fallback is never hit
// for zip.
func packageExtensionToPlatform(ext string) string {
var requiredPlatform string
switch ext {
case ".msi", ".exe", ".ps1", ".msix":
requiredPlatform = "windows"
case ".pkg", ".dmg":
requiredPlatform = "darwin"
case ".deb", ".rpm", ".gz", ".tgz", ".sh", ".py":
requiredPlatform = "linux"
default:
return ""
}
return requiredPlatform
}
func UpgradeCodeMigration(
ctx context.Context,
ds fleet.Datastore,
softwareInstallStore fleet.SoftwareInstallerStore,
logger *slog.Logger,
) error {
// Find MSI installers without upgrade_code
idMap, err := ds.GetMSIInstallersWithoutUpgradeCode(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting msi installers without upgrade_code")
}
if len(idMap) == 0 {
return nil
}
upgradeCodesByStorageID := map[string]string{}
// Download each package and parse it, if we haven't already
for id, storageID := range idMap {
if _, hasParsedUpgradeCode := upgradeCodesByStorageID[storageID]; !hasParsedUpgradeCode {
// check if the installer exists in the store
exists, err := softwareInstallStore.Exists(ctx, storageID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking if installer exists")
}
if !exists {
logger.WarnContext(ctx, "software installer not found in store", "software_installer_id", id, "storage_id", storageID)
upgradeCodesByStorageID[storageID] = "" // set to empty string to avoid duplicating work
continue
}
// get the installer from the store
installer, _, err := softwareInstallStore.Get(ctx, storageID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting installer from store")
}
tfr, err := fleet.NewTempFileReader(installer, nil)
_ = installer.Close()
if err != nil {
logger.WarnContext(ctx, "extracting metadata from installer",
"software_installer_id", id, "storage_id", storageID, "err", err)
upgradeCodesByStorageID[storageID] = ""
continue
}
meta, err := file.ExtractInstallerMetadata(tfr)
_ = tfr.Close() // best-effort closing and deleting of temp file
if err != nil {
logger.WarnContext(ctx, "extracting metadata from installer",
"software_installer_id", id, "storage_id", storageID, "err", err)
upgradeCodesByStorageID[storageID] = ""
continue
}
if meta.UpgradeCode == "" {
logger.DebugContext(ctx, "no upgrade code found in metadata", "software_installer_id", id, "storage_id", storageID)
} // fall through since we're going to set the upgrade code even if it's blank
upgradeCodesByStorageID[storageID] = meta.UpgradeCode
}
if upgradeCode, hasParsedUpgradeCode := upgradeCodesByStorageID[storageID]; hasParsedUpgradeCode && upgradeCode != "" {
// Update the upgrade_code of the software package if we have one
if err := ds.UpdateInstallerUpgradeCode(ctx, id, upgradeCode); err != nil {
logger.WarnContext(ctx, "failed to update upgrade code", "software_installer_id", id, "error", err)
continue
}
}
}
return nil
}
func UninstallSoftwareMigration(
ctx context.Context,
ds fleet.Datastore,
softwareInstallStore fleet.SoftwareInstallerStore,
logger *slog.Logger,
) error {
// Find software installers that should have their uninstall script populated
idMap, err := ds.GetSoftwareInstallersPendingUninstallScriptPopulation(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting software installers to modufy")
}
if len(idMap) == 0 {
return nil
}
// Download each package and parse it
for id, storageID := range idMap {
// check if the installer exists in the store
exists, err := softwareInstallStore.Exists(ctx, storageID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking if installer exists")
}
if !exists {
logger.WarnContext(ctx, "software installer not found in store", "software_installer_id", id, "storage_id", storageID)
continue
}
// get the installer from the store
installer, _, err := softwareInstallStore.Get(ctx, storageID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting installer from store")
}
tfr, err := fleet.NewTempFileReader(installer, nil)
_ = installer.Close()
if err != nil {
logger.WarnContext(ctx, "extracting metadata from installer",
"software_installer_id", id, "storage_id", storageID, "err", err)
continue
}
meta, err := file.ExtractInstallerMetadata(tfr)
_ = tfr.Close() // best-effort closing and deleting of temp file
if err != nil {
logger.WarnContext(ctx, "extracting metadata from installer",
"software_installer_id", id, "storage_id", storageID, "err", err)
continue
}
if len(meta.PackageIDs) == 0 {
logger.WarnContext(ctx, "no package_id found in metadata", "software_installer_id", id, "storage_id", storageID)
continue
}
if meta.Extension == "" {
logger.WarnContext(ctx, "no extension found in metadata", "software_installer_id", id, "storage_id", storageID)
continue
}
payload := fleet.UploadSoftwareInstallerPayload{
PackageIDs: meta.PackageIDs,
Extension: meta.Extension,
}
payload.UninstallScript = file.GetUninstallScript(payload.Extension)
// Update $PACKAGE_ID in uninstall script
if err := preProcessUninstallScript(&payload); err != nil {
return ctxerr.Wrap(ctx, err, "applying uninstall script template")
}
// Update the package_id and extension in the software installer and the uninstall script
if err := ds.UpdateSoftwareInstallerWithoutPackageIDs(ctx, id, payload); err != nil {
return ctxerr.Wrap(ctx, err, "updating package_id in software installer")
}
}
return nil
}
func activitySoftwareLabelsFromValidatedLabels(validatedLabels *fleet.LabelIdentsWithScope) (includeAny, excludeAny, includeAll []fleet.ActivitySoftwareLabel) {
if validatedLabels == nil || len(validatedLabels.ByName) == 0 {
return nil, nil, nil
}
labels := make([]fleet.ActivitySoftwareLabel, 0, len(validatedLabels.ByName))
for _, lbl := range validatedLabels.ByName {
labels = append(labels, fleet.ActivitySoftwareLabel{
ID: lbl.LabelID,
Name: lbl.LabelName,
})
}
switch validatedLabels.LabelScope {
case fleet.LabelScopeIncludeAny:
includeAny = labels
case fleet.LabelScopeExcludeAny:
excludeAny = labels
case fleet.LabelScopeIncludeAll:
includeAll = labels
}
return includeAny, excludeAny, includeAll
}
func activitySoftwareLabelsFromSoftwareScopeLabels(includeAnyScopeLabels, excludeAnyScopeLabels, includeAllScopeLabels []fleet.SoftwareScopeLabel) (includeAny, excludeAny, includeAll []fleet.ActivitySoftwareLabel) {
for _, label := range includeAnyScopeLabels {
includeAny = append(includeAny, fleet.ActivitySoftwareLabel{
ID: label.LabelID,
Name: label.LabelName,
})
}
for _, label := range excludeAnyScopeLabels {
excludeAny = append(excludeAny, fleet.ActivitySoftwareLabel{
ID: label.LabelID,
Name: label.LabelName,
})
}
for _, label := range includeAllScopeLabels {
includeAll = append(includeAll, fleet.ActivitySoftwareLabel{
ID: label.LabelID,
Name: label.LabelName,
})
}
return includeAny, excludeAny, includeAll
}
// getInstallScript returns the install script for a software installer,
// using a special script for fleetd packages to handle macOS in-band upgrades.
func getInstallScript(extension string, packageIDs []string, currentScript string) string {
if extension == "pkg" && file.IsFleetdPkg(packageIDs) {
return file.InstallPkgFleetdScript
}
if currentScript != "" {
return currentScript
}
return file.GetInstallScript(extension)
}
// batchAddSelfServiceCategories only adds categories, because it is used across both the installer and vpp
// endpoints and we cannot know what categories to delete before those are both done.
func (svc *Service) batchAddSelfServiceCategories(ctx context.Context, teamID *uint, categoryNames []string, dryRun bool) ([]string, error) {
// Compare names with fleet.SoftwareCategoryNamesEqual rather than a plain
// case-insensitive comparison: the software_categories unique index uses the
// utf8mb4_unicode_ci collation, which ignores variation selectors, so two
// names Go considers distinct (e.g. "🖥️ Productivity" with vs. without U+FE0F)
// are the same row to MySQL. Deduping/matching on the DB's terms here avoids
// attempting an insert that would fail with a 1062 duplicate-entry error.
var allCategories []string
for _, name := range fleet.TranslateLegacySoftwareCategoryNames(categoryNames) {
if slices.ContainsFunc(allCategories, func(c string) bool { return fleet.SoftwareCategoryNamesEqual(c, name) }) {
continue
}
allCategories = append(allCategories, name)
}
if len(allCategories) == 0 {
return allCategories, nil
}
existingCategories, err := svc.ds.ListSoftwareCategories(ctxdb.RequirePrimary(ctx, true), ptr.ValOrZero(teamID))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "listing existing software categories")
}
var categoriesToInsert []string
for _, name := range allCategories {
if !slices.ContainsFunc(existingCategories, func(c fleet.SoftwareCategory) bool { return fleet.SoftwareCategoryNamesEqual(c.Name, name) }) {
categoriesToInsert = append(categoriesToInsert, name)
}
}
if dryRun {
return allCategories, nil
}
if err := svc.ds.BatchNewSoftwareCategories(ctx, ptr.ValOrZero(teamID), categoriesToInsert); err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating self-service categories")
}
return allCategories, nil
}
func parsePinnedVersion(ctx context.Context, version string) (trimmedVersion string, usesCaret bool, err error) {
trimmedVersion, usesCaret = strings.CutPrefix(version, "^")
if usesCaret {
if len(trimmedVersion) == 0 {
return "", false, fleet.NewUserMessageError(errEmptyCaretVersion, http.StatusBadRequest)
}
if _, err := strconv.ParseUint(trimmedVersion, 10, 64); err != nil {
return "", false, fleet.NewUserMessageError(errNonMajorVersion, http.StatusBadRequest)
}
}
return trimmedVersion, usesCaret, nil
}
func versionMatchesMajor(version string, majorVersion string) bool {
versionMajor, _, _ := strings.Cut(version, ".")
return versionMajor == majorVersion
}