fleet/server/fleet/software_installer.go
Jonathan Katz 0d15fd6cd6
Override patch policy query (#42322)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41815
### Changes
- Extracted patch policy creation to `pkg/patch_policy`
- Added a `patch_query` column to the `software_installers` table
- By default that column is empty, and patch policies will generate with
the default query if so
- On app manifest ingestion, the appropriate entry in
`software_installers` will save the override "patch" query from the
manifest in patch_query

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [ ] QA'd all new/changed functionality manually
- Relied on integration test for FMA version pinning

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [ ] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
2026-03-25 10:32:41 -04:00

1160 lines
48 KiB
Go

package fleet
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/ptr"
)
const SoftwareInstallerSignedURLExpiry = 6 * time.Hour
// SoftwareInstallerStore is the interface to store and retrieve software
// installer files. Fleet supports storing to the local filesystem and to an
// S3 bucket.
type SoftwareInstallerStore interface {
Get(ctx context.Context, installerID string) (io.ReadCloser, int64, error)
Put(ctx context.Context, installerID string, content io.ReadSeeker) error
Exists(ctx context.Context, installerID string) (bool, error)
Cleanup(ctx context.Context, usedInstallerIDs []string, removeCreatedBefore time.Time) (int, error)
Sign(ctx context.Context, fileID string, expiresIn time.Duration) (string, error)
}
// SoftwareInstallDetails contains all of the information
// required for a client to pull in and install software from the fleet server
type SoftwareInstallDetails struct {
// HostID is used for authentication on the backend and should not
// be passed to the client
HostID uint `json:"-" db:"host_id"`
// ExecutionID is a unique identifier for this installation
ExecutionID string `json:"install_id" db:"execution_id"`
// InstallerID is the unique identifier for the software package metadata in Fleet.
InstallerID uint `json:"installer_id" db:"installer_id"`
// PreInstallCondition is the query to run as a condition to installing the software package.
PreInstallCondition string `json:"pre_install_condition" db:"pre_install_condition"`
// InstallScript is the script to run to install the software package.
InstallScript string `json:"install_script" db:"install_script"`
// UninstallScript is the script to run to uninstall the software package.
UninstallScript string `json:"uninstall_script" db:"uninstall_script"`
// PostInstallScript is the script to run after installing the software package.
PostInstallScript string `json:"post_install_script" db:"post_install_script"`
// SelfService indicates the install was initiated by the device user
SelfService bool `json:"self_service" db:"self_service"`
// SoftwareInstallerURL contains the details to download the software installer from CDN.
SoftwareInstallerURL *SoftwareInstallerURL `json:"installer_url,omitempty"`
// MaxRetries is the number of additional attempts allowed after the initial attempt (0 = no retries).
MaxRetries uint `json:"max_retries,omitempty"`
}
type SoftwareInstallerURL struct {
// URL is the URL to download the software installer.
URL string `json:"url"`
// Filename is the name of the software installer file that contents should be downloaded to from the URL.
Filename string `json:"filename"`
}
// SoftwareInstaller represents a software installer package that can be used to install software on
// hosts in Fleet.
type SoftwareInstaller struct {
// TeamID is the ID of the team. A value of nil means it is scoped to hosts that are assigned to
// no team.
TeamID *uint `json:"team_id" renameto:"fleet_id" db:"team_id"`
// TitleID is the id of the software title associated with the software installer.
TitleID *uint `json:"title_id" db:"title_id"`
// Name is the name of the software package.
Name string `json:"name" db:"filename"`
// IconUrl is the URL for the software's icon, whether from VPP or via an uploaded override
IconUrl *string `json:"icon_url" db:"-"`
// Extension is the file extension of the software package, inferred from package contents.
Extension string `json:"-" db:"extension"`
// Version is the version of the software package.
Version string `json:"version" db:"version"`
// Platform can be "darwin" (for pkgs), "windows" (for exes/msis) or "linux" (for debs).
Platform string `json:"platform" db:"platform"`
// PackageIDList is a comma-separated list of packages extracted from the installer
PackageIDList string `json:"-" db:"package_ids"`
// UpgradeCode is the (optional) upgrade code included in an MSI
UpgradeCode string `json:"-" db:"upgrade_code"`
// UploadedAt is the time the software package was uploaded.
UploadedAt time.Time `json:"uploaded_at" db:"uploaded_at"`
// InstallerID is the unique identifier for the software package metadata in Fleet.
InstallerID uint `json:"installer_id" db:"id"`
// InstallScript is the script to run to install the software package.
InstallScript string `json:"install_script" db:"install_script"`
// InstallScriptContentID is the ID of the install script content.
InstallScriptContentID uint `json:"-" db:"install_script_content_id"`
// UninstallScriptContentID is the ID of the uninstall script content.
UninstallScriptContentID uint `json:"-" db:"uninstall_script_content_id"`
// PreInstallQuery is the query to run as a condition to installing the software package.
PreInstallQuery string `json:"pre_install_query" db:"pre_install_query"`
// PostInstallScript is the script to run after installing the software package.
PostInstallScript string `json:"post_install_script" db:"post_install_script"`
// UninstallScript is the script to run to uninstall the software package.
UninstallScript string `json:"uninstall_script" db:"uninstall_script"`
// PostInstallScriptContentID is the ID of the post-install script content.
PostInstallScriptContentID *uint `json:"-" db:"post_install_script_content_id"`
// StorageID is the unique identifier for the software package in the software installer store.
StorageID string `json:"hash_sha256" db:"storage_id"`
// Status is the status of the software installer package.
Status *SoftwareInstallerStatusSummary `json:"status,omitempty" db:"-"`
// SoftwareTitle is the title of the software pointed installed by this installer.
SoftwareTitle string `json:"-" db:"software_title"`
// SelfService indicates that the software can be installed by the
// end user without admin intervention
SelfService bool `json:"self_service" db:"self_service"`
// URL is the source URL for this installer (set when uploading via batch/gitops).
URL string `json:"url" db:"url"`
// FleetMaintainedAppID is the related Fleet-maintained app for this installer (if not nil).
FleetMaintainedAppID *uint `json:"fleet_maintained_app_id" db:"fleet_maintained_app_id"`
FleetMaintainedVersions []FleetMaintainedVersion `json:"fleet_maintained_versions,omitempty"`
// AutomaticInstallPolicies is the list of policies that trigger automatic
// installation of this software.
AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies" db:"-"`
// LabelsIncludeAny is the list of "include any" labels for this software installer (if not nil).
LabelsIncludeAny []SoftwareScopeLabel `json:"labels_include_any" db:"labels_include_any"`
// LabelsExcludeAny is the list of "exclude any" labels for this software installer (if not nil).
LabelsExcludeAny []SoftwareScopeLabel `json:"labels_exclude_any" db:"labels_exclude_any"`
// LabelsIncludeAll is the list of "include all" labels for this software installer (if not nil).
LabelsIncludeAll []SoftwareScopeLabel `json:"labels_include_all" db:"labels_include_all"`
// Source is the osquery source for this software.
Source string `json:"-" db:"source"`
// Categories is the list of categories to which this software belongs: e.g. "Productivity",
// "Browsers", etc.
Categories []string `json:"categories"`
BundleIdentifier string `json:"-" db:"bundle_identifier"`
// DisplayName is an end-user friendly name.
DisplayName string `json:"display_name"`
// PatchPolicy is present for Fleet maintained apps with an associated patch policy
PatchPolicy *PatchPolicyData `json:"patch_policy"`
// PatchQuery is the query to use for creating a patch policy
PatchQuery string `json:"-" db:"patch_query"`
}
// SoftwarePackageResponse is the response type used when applying software by batch.
type SoftwarePackageResponse struct {
// TeamID is the ID of the team.
// A value of nil means it is scoped to hosts that are assigned to "No team".
TeamID *uint `json:"team_id" renameto:"fleet_id" db:"team_id"`
// TitleID is the id of the software title associated with the software installer.
TitleID *uint `json:"title_id" db:"title_id"`
// URL is the source URL for this installer (set when uploading via batch/gitops).
URL string `json:"url" db:"url"`
// HashSHA256 is the SHA256 hash of the software installer.
HashSHA256 string `json:"hash_sha256" db:"hash_sha256"`
// ID of the Fleet Maintained App this package uses, if any
FleetMaintainedAppID *uint `json:"fleet_maintained_app_id" db:"fleet_maintained_app_id"`
// Slug of the Fleet Maintained App this package uses, if any
Slug string `json:"fleet_maintained_app_slug"`
//// Custom icon fields (blank if not set)
// IconHash is the SHA256 hash of the icon server-side
IconHash string `json:"icon_hash_sha256" db:"icon_hash_sha256"`
// IconFilename is the filename of the icon server-side
IconFilename string `json:"icon_filename" db:"icon_filename"`
// LocalIconHash is the SHA256 hash of the icon specified in YAML
LocalIconHash string `json:"-" db:"-"`
// LocalIconPath is the path to the icon specified in YAML
LocalIconPath string `json:"-" db:"-"`
}
func (p SoftwarePackageResponse) GetTeamID() uint {
if p.TeamID == nil {
return 0
}
return *p.TeamID
}
func (p SoftwarePackageResponse) GetTitleID() *uint { return p.TitleID }
func (p SoftwarePackageResponse) GetIconHash() string { return p.IconHash }
func (p SoftwarePackageResponse) GetIconFilename() string { return p.IconFilename }
func (p SoftwarePackageResponse) GetLocalIconHash() string { return p.LocalIconHash }
func (p SoftwarePackageResponse) GetLocalIconPath() string { return p.LocalIconPath }
// VPPAppResponse is the response type used when applying app store apps by batch.
type VPPAppResponse struct {
// TeamID is the ID of the team.
// A value of nil means it is scoped to hosts that are assigned to "No team".
TeamID *uint `json:"team_id" renameto:"fleet_id" db:"team_id"`
// TitleID is the id of the software title associated with the software installer.
TitleID *uint `json:"title_id" db:"title_id"`
// AppStoreID is the ADAM ID for this app (set when uploading via batch/gitops).
AppStoreID string `json:"app_store_id" db:"app_store_id"`
// Platform is the platform this title ID corresponds to
Platform InstallableDevicePlatform `json:"platform" db:"platform"`
//// Custom icon fields (blank if not set)
// IconHash is the SHA256 hash of the icon server-side
IconHash string `json:"icon_hash_sha256" db:"icon_hash_sha256"`
// IconFilename is the filename of the icon server-side
IconFilename string `json:"icon_filename" db:"icon_filename"`
// LocalIconHash is the SHA256 hash of the icon specified in YAML
LocalIconHash string `json:"-" db:"-"`
// LocalIconPath is the path to the icon specified in YAML
LocalIconPath string `json:"-" db:"-"`
AppTeamID uint `json:"-" db:"app_team_id"`
}
func (v VPPAppResponse) GetTeamID() uint {
if v.TeamID == nil {
return 0
}
return *v.TeamID
}
func (v VPPAppResponse) GetTitleID() *uint { return v.TitleID }
func (v VPPAppResponse) GetIconHash() string { return v.IconHash }
func (v VPPAppResponse) GetIconFilename() string { return v.IconFilename }
func (v VPPAppResponse) GetLocalIconHash() string { return v.LocalIconHash }
func (v VPPAppResponse) GetLocalIconPath() string { return v.LocalIconPath }
type CanHaveSoftwareIcon interface {
GetTeamID() uint
GetTitleID() *uint
GetIconHash() string
GetIconFilename() string
GetLocalIconHash() string
GetLocalIconPath() string
}
type IconFileUpdate struct {
TitleID uint
Path string
}
type IconMetaUpdate struct {
TitleID uint
Path string
Hash string
}
type IconGitOpsSettings struct {
ConcurrentUploads int
ConcurrentUpdates int
UploadedHashes []string
}
type IconChanges struct {
TeamID uint
UploadedHashes []string
IconsToUpload []IconFileUpdate
IconsToUpdate []IconMetaUpdate
TitleIDsToRemoveIconsFrom []uint
}
func (c IconChanges) WithUploadedHashes(hashes []string) IconChanges {
c.UploadedHashes = append(c.UploadedHashes, hashes...)
return c
}
func (c IconChanges) WithSoftware(packages []SoftwarePackageResponse, vppApps []VPPAppResponse) IconChanges {
// build a slice of software to avoid copypasta
software := make([]CanHaveSoftwareIcon, 0, len(packages)+len(vppApps))
for i := range packages {
software = append(software, packages[i])
}
for i := range vppApps {
software = append(software, vppApps[i])
}
// don't (duplicate) upload (of) icons that we don't need to
for _, sw := range software {
teamID := sw.GetTeamID()
if teamID != 0 {
c.TeamID = teamID
}
if h := sw.GetIconHash(); h != "" && !slices.Contains(c.UploadedHashes, h) {
c.UploadedHashes = append(c.UploadedHashes, h)
}
}
for _, sw := range software {
if sw.GetTitleID() == nil {
continue
}
localHash := sw.GetLocalIconHash()
if localHash == "" { // desired state: no custom icon
if h := sw.GetIconHash(); h != "" {
c.TitleIDsToRemoveIconsFrom = append(c.TitleIDsToRemoveIconsFrom, *sw.GetTitleID())
}
continue
} // else local icon hash is set
localPath := sw.GetLocalIconPath()
if localHash == sw.GetIconHash() && filepath.Base(localPath) == sw.GetIconFilename() {
continue // no-op; icons match
} // else we need to either upload the icon or point the software title to it
if !slices.Contains(c.UploadedHashes, localHash) { // icon wasn't uploaded so we need to upload it
c.IconsToUpload = append(c.IconsToUpload, IconFileUpdate{TitleID: *sw.GetTitleID(), Path: localPath})
c.UploadedHashes = append(c.UploadedHashes, localHash) // only upload a given icon once
continue
} // else we have the icon server-side already and just need to update the name
c.IconsToUpdate = append(c.IconsToUpdate, IconMetaUpdate{
TitleID: *sw.GetTitleID(),
Path: localPath,
Hash: localHash,
})
}
return c
}
// AuthzType implements authz.AuthzTyper.
func (s *SoftwareInstaller) AuthzType() string {
return "installable_entity"
}
// PackageIDs turns the comma-separated string from the database into a list (potentially zero-length) of string package IDs
func (s *SoftwareInstaller) PackageIDs() []string {
if s.PackageIDList == "" {
return []string{}
}
return strings.Split(s.PackageIDList, ",")
}
// SoftwareInstallerStatusSummary represents aggregated status metrics for a software installer package.
type SoftwareInstallerStatusSummary struct {
// Installed is the number of hosts that have the software package installed.
Installed uint `json:"installed" db:"installed"`
// PendingInstall is the number of hosts that have the software package pending installation.
PendingInstall uint `json:"pending_install" db:"pending_install"`
// FailedInstall is the number of hosts that have the software package installation failed.
FailedInstall uint `json:"failed_install" db:"failed_install"`
// PendingUninstall is the number of hosts that have the software package pending installation.
PendingUninstall uint `json:"pending_uninstall" db:"pending_uninstall"`
// FailedInstall is the number of hosts that have the software package installation failed.
FailedUninstall uint `json:"failed_uninstall" db:"failed_uninstall"`
}
// SoftwareInstallerStatus represents the status of a software installer package on a host.
type SoftwareInstallerStatus string
const (
SoftwareInstallPending SoftwareInstallerStatus = "pending_install"
SoftwareInstallFailed SoftwareInstallerStatus = "failed_install"
SoftwareInstalled SoftwareInstallerStatus = "installed"
SoftwareUninstallPending SoftwareInstallerStatus = "pending_uninstall"
SoftwareUninstallFailed SoftwareInstallerStatus = "failed_uninstall"
// SoftwarePending and SoftwareFailed statuses are only used as filters in the API and are not stored in the database.
SoftwarePending SoftwareInstallerStatus = "pending" // either pending_install or pending_uninstall
SoftwareFailed SoftwareInstallerStatus = "failed" // either failed_install or failed_uninstall
)
func (s SoftwareInstallerStatus) IsValid() bool {
switch s {
case
SoftwarePending,
SoftwareFailed,
SoftwareUninstallPending,
SoftwareUninstallFailed,
SoftwareInstallFailed,
SoftwareInstalled,
SoftwareInstallPending:
return true
default:
return false
}
}
// HostLastInstallData contains data for the last installation of a package on a host.
type HostLastInstallData struct {
// ExecutionID is the installation ID of the package on the host.
ExecutionID string `db:"execution_id"`
// Status is the status of the installation on the host.
Status *SoftwareInstallerStatus `db:"status"`
}
// HostSoftwareInstaller represents a software installer package that has been installed on a host.
type HostSoftwareInstallerResult struct {
// ID is the unique numerical ID of the result assigned by the datastore.
ID uint `json:"-" db:"id"`
// InstallUUID is the unique identifier for the software install operation associated with the host.
InstallUUID string `json:"install_uuid" db:"execution_id"`
// SoftwareTitle is the title of the software.
SoftwareTitle string `json:"software_title" db:"software_title"`
// SoftwareTitleID is the unique numerical ID of the software title assigned by the datastore.
SoftwareTitleID *uint `json:"software_title_id" db:"software_title_id"`
// SoftwareInstallerID is the unique numerical ID of the software installer assigned by the datastore.
SoftwareInstallerID *uint `json:"-" db:"software_installer_id"`
// SoftwarePackage is the name of the software installer package.
SoftwarePackage string `json:"software_package" db:"software_package"`
// Source is the osquery source for this software (e.g., "sh_packages", "ps1_packages").
Source *string `json:"source" db:"source"`
// HostID is the ID of the host.
HostID uint `json:"host_id" db:"host_id"`
// Status is the status of the software installer package on the host.
Status SoftwareInstallerStatus `json:"status" db:"status"`
// Output is the output of the software installer package on the host.
Output *string `json:"output" db:"install_script_output"`
// PreInstallQueryOutput is the output of the pre-install query on the host.
PreInstallQueryOutput *string `json:"pre_install_query_output" db:"pre_install_query_output"`
// PostInstallScriptOutput is the output of the post-install script on the host.
PostInstallScriptOutput *string `json:"post_install_script_output" db:"post_install_script_output"`
// CreatedAt is the time the software installer request was triggered.
CreatedAt time.Time `json:"created_at" db:"created_at"`
// UpdatedAt is the time the software installer request was last updated.
UpdatedAt *time.Time `json:"updated_at" db:"updated_at"`
// UserID is the user ID that requested the software installation on that host.
UserID *uint `json:"-" db:"user_id"`
// InstallScriptExitCode is used internally to determine the output displayed to the user.
InstallScriptExitCode *int `json:"-" db:"install_script_exit_code"`
// PostInstallScriptExitCode is used internally to determine the output displayed to the user.
PostInstallScriptExitCode *int `json:"-" db:"post_install_script_exit_code"`
// SelfService indicates that the installation was queued by the
// end user and not an administrator
SelfService bool `json:"self_service" db:"self_service"`
// HostDeletedAt indicates if the data is associated with a
// deleted host
HostDeletedAt *time.Time `json:"-" db:"host_deleted_at"`
// PolicyID is the id of the policy that triggered the install, or
// nil if the install was not triggered by a policy failure
PolicyID *uint `json:"policy_id" db:"policy_id"`
// AttemptNumber tracks which retry attempt this is for policy automation installations.
// nil = not triggered by a policy
// 1,2,3 attempt, 3 being max retries
AttemptNumber *int `json:"attempt_number,omitempty" db:"attempt_number"`
}
const (
SoftwareInstallerQueryFailCopy = "Query didn't return result or failed\nInstall stopped"
SoftwareInstallerQuerySuccessCopy = "Query returned result\nProceeding to install..."
SoftwareInstallerScriptsDisabledCopy = "Installing software...\nError: Scripts are disabled for this host. To run scripts, deploy the fleetd agent with --enable-scripts."
SoftwareInstallerInstallFailCopy = "Installing software...\nFailed\n%s"
SoftwareInstallerInstallSuccessCopy = "Installing software...\nSuccess\n%s"
SoftwareInstallerPostInstallSuccessCopy = "Running script...\nExit code: 0 (Success)\n%s"
SoftwareInstallerPostInstallFailCopy = `Running script...
Exit code: %d (Failed)
%s
`
SoftwareInstallerDownloadFailedCopy = "Installing software...\nError: Software installer download failed."
)
// EnhanceOutputDetails is used to add extra boilerplate/information to the
// output fields so they're easier to consume by users.
func (h *HostSoftwareInstallerResult) EnhanceOutputDetails() {
if h.Status == SoftwareInstallPending {
return
}
if h.PreInstallQueryOutput != nil {
if *h.PreInstallQueryOutput == "" {
*h.PreInstallQueryOutput = SoftwareInstallerQueryFailCopy
return
}
*h.PreInstallQueryOutput = SoftwareInstallerQuerySuccessCopy
}
if h.Output == nil || h.InstallScriptExitCode == nil {
return
}
switch *h.InstallScriptExitCode {
case 0:
// ok, continue
case ExitCodeScriptsDisabled:
*h.Output = SoftwareInstallerScriptsDisabledCopy
return
case ExitCodeInstallerDownloadFailed:
*h.Output = SoftwareInstallerDownloadFailedCopy
return
default:
h.Output = ptr.String(fmt.Sprintf(SoftwareInstallerInstallFailCopy, *h.Output))
return
}
h.Output = ptr.String(fmt.Sprintf(SoftwareInstallerInstallSuccessCopy, *h.Output))
if h.PostInstallScriptExitCode == nil || h.PostInstallScriptOutput == nil {
return
}
if *h.PostInstallScriptExitCode != 0 {
h.PostInstallScriptOutput = ptr.String(fmt.Sprintf(SoftwareInstallerPostInstallFailCopy, *h.PostInstallScriptExitCode, *h.PostInstallScriptOutput))
return
}
h.PostInstallScriptOutput = ptr.String(fmt.Sprintf(SoftwareInstallerPostInstallSuccessCopy, *h.PostInstallScriptOutput))
}
type HostSoftwareInstallerResultAuthz struct {
HostTeamID *uint `json:"host_team_id" renameto:"host_fleet_id"`
}
// AuthzType implements authz.AuthzTyper.
func (s *HostSoftwareInstallerResultAuthz) AuthzType() string {
return "host_software_installer_result"
}
type UploadSoftwareInstallerPayload struct {
TeamID *uint
InstallScript string
PreInstallQuery string
PostInstallScript string
InstallerFile *TempFileReader // TODO: maybe pull this out of the payload and only pass it to methods that need it (e.g., won't be needed when storing metadata in the database)
StorageID string
Filename string
Title string
Version string
Source string
Platform string
BundleIdentifier string
SelfService bool
UserID uint
URL string
FleetMaintainedAppID *uint
// RollbackVersion is the version to pin as "active" for a fleet-maintained app.
// If empty, the latest version is used.
RollbackVersion string
// FMAVersionCached indicates this FMA version is already cached in the
// database and installer store, so storage and insert can be skipped.
FMAVersionCached bool
PackageIDs []string
UpgradeCode string
UninstallScript string
Extension string
InstallDuringSetup *bool // keep saved value if nil, otherwise set as indicated
LabelsIncludeAny []string // names of "include any" labels
LabelsExcludeAny []string // names of "exclude any" labels
LabelsIncludeAll []string // names of "include all" labels
// ValidatedLabels is a struct that contains the validated labels for the software installer. It
// is nil if the labels have not been validated.
ValidatedLabels *LabelIdentsWithScope
AutomaticInstall bool
AutomaticInstallQuery string
Categories []string
CategoryIDs []uint
DisplayName string
// AddedAutomaticInstallPolicy is the auto-install policy that can be
// automatically created when a software installer is added to Fleet. This field should be set
// after software installer creation if AutomaticInstall is true.
AddedAutomaticInstallPolicy *Policy
PatchQuery string
}
func (p UploadSoftwareInstallerPayload) UniqueIdentifier() string {
if p.BundleIdentifier != "" {
return p.BundleIdentifier
}
if p.Source == "programs" && p.UpgradeCode != "" {
return p.UpgradeCode
}
return p.Title
}
// GetBundleIdentifierForDB returns a pointer to the bundle identifier if it's
// non-empty (after trimming whitespace), or nil otherwise. This is used when
// inserting into the database where NULL is preferred over empty string.
func (p UploadSoftwareInstallerPayload) GetBundleIdentifierForDB() *string {
if strings.TrimSpace(p.BundleIdentifier) != "" {
return &p.BundleIdentifier
}
return nil
}
// GetUpgradeCodeForDB returns a pointer to the upgrade code if the source is
// "programs", or nil otherwise. This is used when inserting into the database
// where NULL is preferred for non-Windows installers.
func (p UploadSoftwareInstallerPayload) GetUpgradeCodeForDB() *string {
if p.Source != "programs" {
return nil
}
return &p.UpgradeCode
}
type ExistingSoftwareInstaller struct {
InstallerID uint `db:"installer_id"`
TeamID *uint `db:"team_id"`
Filename string `db:"filename"`
Extension string `db:"extension"`
Version string `db:"version"`
Platform string `db:"platform"`
Source string `db:"source"`
BundleIdentifier *string `db:"bundle_identifier"`
Title string `db:"title"`
PackageIDList string `db:"package_ids"`
PackageIDs []string
}
type UpdateSoftwareInstallerPayload struct {
// find the installer via these fields
TitleID uint
TeamID *uint
InstallerID uint
// used for authorization and persisted as author
UserID uint
// optional; used for pulling metadata + persisting new installer package to file system
InstallerFile *TempFileReader
// update the installer with these fields (*not* PATCH semantics at that point; while the
// associated endpoint is a PATCH, the entire row will be updated to these values, including
// blanks, so make sure they're set from either user input or the existing installer row
// before saving)
InstallScript *string
PreInstallQuery *string
PostInstallScript *string
SelfService *bool
UninstallScript *string
StorageID string
Filename string
Version string
PackageIDs []string
UpgradeCode string
LabelsIncludeAny []string // names of "include any" labels
LabelsExcludeAny []string // names of "exclude any" labels
LabelsIncludeAll []string // names of "include all" labels
// ValidatedLabels is a struct that contains the validated labels for the software installer. It
// can be nil if the labels have not been validated or if the labels are not being updated.
ValidatedLabels *LabelIdentsWithScope
Categories []string
CategoryIDs []uint
// DisplayName is an end-user friendly name.
DisplayName *string
}
func (u *UpdateSoftwareInstallerPayload) IsNoopPayload(existing *SoftwareTitle) bool {
return u.SelfService == nil && u.InstallerFile == nil && u.PreInstallQuery == nil &&
u.InstallScript == nil && u.PostInstallScript == nil && u.UninstallScript == nil &&
u.LabelsIncludeAny == nil && u.LabelsExcludeAny == nil && u.LabelsIncludeAll == nil &&
u.DisplayName == nil && u.CategoryIDs == nil
}
// DownloadSoftwareInstallerPayload is the payload for downloading a software installer.
type DownloadSoftwareInstallerPayload struct {
Filename string
Installer io.ReadCloser
Size int64
}
func SofwareInstallerSourceFromExtensionAndName(ext, name string) (string, error) {
ext = strings.TrimPrefix(ext, ".")
switch ext {
case "deb":
return "deb_packages", nil
case "rpm":
return "rpm_packages", nil
case "exe", "msi", "zip":
return "programs", nil
case "pkg":
if filepath.Ext(name) == ".app" {
return "apps", nil
}
return "pkg_packages", nil
case "tar.gz":
return "tgz_packages", nil
case "ipa":
return "ipa", nil
case "sh":
return "sh_packages", nil
case "ps1":
return "ps1_packages", nil
default:
return "", fmt.Errorf("unsupported file type: %s", ext)
}
}
func SoftwareInstallerPlatformFromExtension(ext string) (string, error) {
ext = strings.TrimPrefix(ext, ".")
switch ext {
case "deb", "rpm", "tar.gz", "sh":
return "linux", nil
case "exe", "msi", "ps1", "zip":
return "windows", nil
case "pkg":
return "darwin", nil
case "ipa": // TODO(JVE): what about iPads? Can we get the platforms from the Info.plist file?
return "ios", nil
default:
return "", fmt.Errorf("unsupported file type: %s", ext)
}
}
// IsScriptPackage returns true if the extension represents a script package
// (.sh or .ps1 files where the file contents become the install script).
func IsScriptPackage(ext string) bool {
ext = strings.TrimPrefix(ext, ".")
return ext == "sh" || ext == "ps1"
}
// HostSoftwareWithInstaller represents the list of software installed on a
// host with installer information if a matching installer exists. This is the
// payload returned by the "Get host's (device's) software" endpoints.
type HostSoftwareWithInstaller struct {
ID uint `json:"id" db:"id"`
Name string `json:"name" db:"name"`
IconUrl *string `json:"icon_url" db:"-"`
Source string `json:"source" db:"source"`
ExtensionFor string `json:"extension_for" db:"extension_for"`
Status *SoftwareInstallerStatus `json:"status" db:"status"`
InstalledVersions []*HostSoftwareInstalledVersion `json:"installed_versions"`
DisplayName string `json:"display_name" db:"display_name"`
// UpgradeCode is a GUID representing a related set of Windows software products. See https://learn.microsoft.com/en-us/windows/win32/msi/upgradecode
UpgradeCode *string `json:"upgrade_code,omitempty" db:"upgrade_code"`
// SoftwarePackage provides software installer package information, it is
// only present if a software installer is available for the software title.
SoftwarePackage *SoftwarePackageOrApp `json:"software_package"`
// AppStoreApp provides VPP app information, it is only present if a VPP app
// is available for the software title.
AppStoreApp *SoftwarePackageOrApp `json:"app_store_app"`
}
func (h *HostSoftwareWithInstaller) IsPackage() bool {
return h.SoftwarePackage != nil
}
func (h *HostSoftwareWithInstaller) IsAppStoreApp() bool {
return h.AppStoreApp != nil
}
func (h *HostSoftwareWithInstaller) ForMyDevicePage(token string) {
// convert api style iconURL to device token URL
if h.IconUrl != nil && *h.IconUrl != "" {
matched := SoftwareTitleIconURLRegex.MatchString(*h.IconUrl)
if matched {
icon := SoftwareTitleIcon{SoftwareTitleID: h.ID}
deviceIconURL := icon.IconUrlWithDeviceToken(token)
h.IconUrl = ptr.String(deviceIconURL)
}
}
}
type AutomaticInstallPolicy struct {
ID uint `json:"id" db:"id"`
Name string `json:"name" db:"name"`
TitleID uint `json:"-" db:"software_title_id"`
Type string `json:"type" db:"type"`
}
type PatchPolicyData struct {
ID uint `json:"id" db:"id"`
Name string `json:"name" db:"name"`
}
// SoftwarePackageOrApp provides information about a software installer
// package or a VPP app.
type SoftwarePackageOrApp struct {
// AppStoreID is only present for VPP apps.
AppStoreID string `json:"app_store_id,omitempty"`
// Name is only present for software installer packages.
Name string `json:"name,omitempty"`
// AutomaticInstallPolicies is present for Fleet maintained apps and custom packages
// installed automatically with a policy.
AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies"`
Version string `json:"version"`
Platform string `json:"platform"`
SelfService *bool `json:"self_service,omitempty"`
LastInstall *HostSoftwareInstall `json:"last_install"`
LastUninstall *HostSoftwareUninstall `json:"last_uninstall"`
PackageURL *string `json:"package_url"`
// InstallDuringSetup is a boolean that indicates if the package
// will be installed during the macos setup experience.
InstallDuringSetup *bool `json:"install_during_setup,omitempty" db:"install_during_setup"`
FleetMaintainedAppID *uint `json:"fleet_maintained_app_id,omitempty" db:"fleet_maintained_app_id"`
FleetMaintainedVersions []FleetMaintainedVersion `json:"fleet_maintained_versions,omitempty"`
Categories []string `json:"categories,omitempty"`
}
func (s *SoftwarePackageOrApp) GetPlatform() string {
return s.Platform
}
func (s *SoftwarePackageOrApp) GetAppStoreID() string {
return s.AppStoreID
}
// Returns unique name by Platform + AppStoreID/Name
func (s *SoftwarePackageOrApp) FullyQualifiedName() string {
if s.AppStoreID != "" {
return fmt.Sprintf(`%s_%s`, s.AppStoreID, s.Platform)
}
if s.Name != "" {
return fmt.Sprintf(`%s_%s`, s.Name, s.Platform)
}
return ""
}
type SoftwarePackageSpec struct {
URL string `json:"url"`
SelfService bool `json:"self_service"`
PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"`
InstallScript TeamSpecSoftwareAsset `json:"install_script"`
PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"`
UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"`
LabelsIncludeAny []string `json:"labels_include_any"`
LabelsExcludeAny []string `json:"labels_exclude_any"`
LabelsIncludeAll []string `json:"labels_include_all"`
InstallDuringSetup optjson.Bool `json:"setup_experience"`
Icon TeamSpecSoftwareAsset `json:"icon"`
// FMA
Slug *string `json:"slug"`
Version string `json:"version"`
// ReferencedYamlPath is the resolved path of the file used to fill the
// software package. Only present after parsing a GitOps file on the fleetctl
// side of processing. This is required to match a setup_experience.software to
// its corresponding software package, as we do this matching by yaml path.
//
// It must be JSON-marshaled because it gets set during gitops file processing,
// which is then re-marshaled to JSON from this struct and later re-unmarshaled
// during ApplyGroup...
ReferencedYamlPath string `json:"referenced_yaml_path"`
SHA256 string `json:"hash_sha256"`
Categories []string `json:"categories"`
DisplayName string `json:"display_name,omitempty"`
}
func (spec SoftwarePackageSpec) ResolveSoftwarePackagePaths(baseDir string) SoftwarePackageSpec {
spec.PreInstallQuery.Path = resolveApplyRelativePath(baseDir, spec.PreInstallQuery.Path)
spec.InstallScript.Path = resolveApplyRelativePath(baseDir, spec.InstallScript.Path)
spec.PostInstallScript.Path = resolveApplyRelativePath(baseDir, spec.PostInstallScript.Path)
spec.UninstallScript.Path = resolveApplyRelativePath(baseDir, spec.UninstallScript.Path)
spec.Icon.Path = resolveApplyRelativePath(baseDir, spec.Icon.Path)
return spec
}
func (spec SoftwarePackageSpec) IncludesFieldsDisallowedInPackageFile() bool {
return len(spec.LabelsExcludeAny) > 0 || len(spec.LabelsIncludeAny) > 0 || len(spec.LabelsIncludeAll) > 0 ||
len(spec.Categories) > 0 || spec.SelfService || spec.InstallDuringSetup.Valid
}
func resolveApplyRelativePath(baseDir string, path string) string {
if path != "" && baseDir != "" && !filepath.IsAbs(path) {
return filepath.Join(baseDir, path)
}
return path
}
type MaintainedAppSpec struct {
Slug string `json:"slug"`
Version string `json:"version"`
SelfService bool `json:"self_service"`
PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"`
InstallScript TeamSpecSoftwareAsset `json:"install_script"`
PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"`
UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"`
LabelsIncludeAny []string `json:"labels_include_any"`
LabelsExcludeAny []string `json:"labels_exclude_any"`
LabelsIncludeAll []string `json:"labels_include_all"`
Categories []string `json:"categories"`
InstallDuringSetup optjson.Bool `json:"setup_experience"`
Icon TeamSpecSoftwareAsset `json:"icon"`
}
func (spec MaintainedAppSpec) ToSoftwarePackageSpec() SoftwarePackageSpec {
return SoftwarePackageSpec{
Slug: &spec.Slug,
Version: spec.Version,
PreInstallQuery: spec.PreInstallQuery,
InstallScript: spec.InstallScript,
PostInstallScript: spec.PostInstallScript,
UninstallScript: spec.UninstallScript,
SelfService: spec.SelfService,
LabelsIncludeAny: spec.LabelsIncludeAny,
LabelsExcludeAny: spec.LabelsExcludeAny,
LabelsIncludeAll: spec.LabelsIncludeAll,
InstallDuringSetup: spec.InstallDuringSetup,
Icon: spec.Icon,
Categories: spec.Categories,
}
}
func (spec MaintainedAppSpec) ResolveSoftwarePackagePaths(baseDir string) MaintainedAppSpec {
spec.PreInstallQuery.Path = resolveApplyRelativePath(baseDir, spec.PreInstallQuery.Path)
spec.InstallScript.Path = resolveApplyRelativePath(baseDir, spec.InstallScript.Path)
spec.PostInstallScript.Path = resolveApplyRelativePath(baseDir, spec.PostInstallScript.Path)
spec.UninstallScript.Path = resolveApplyRelativePath(baseDir, spec.UninstallScript.Path)
spec.Icon.Path = resolveApplyRelativePath(baseDir, spec.Icon.Path)
return spec
}
type SoftwareSpec struct {
Packages optjson.Slice[SoftwarePackageSpec] `json:"packages,omitempty"`
FleetMaintainedApps optjson.Slice[MaintainedAppSpec] `json:"fleet_maintained_apps,omitempty"`
AppStoreApps optjson.Slice[TeamSpecAppStoreApp] `json:"app_store_apps,omitempty"`
}
// HostSoftwareInstall represents installation of software on a host from a
// Fleet software installer.
type HostSoftwareInstall struct {
// InstallUUID is the the UUID of the script execution issued to install the related software. This
// field is only used if the install we're describing was for an uploaded software installer.
// Empty if the install was for an App Store app.
InstallUUID string `json:"install_uuid,omitempty"`
// CommandUUID is the UUID of the MDM command issued to install the related software. This field
// is only used if the install we're describing was for an App Store app.
// Empty if the install was for an uploaded software installer.
CommandUUID string `json:"command_uuid,omitempty"`
InstalledAt time.Time `json:"installed_at"`
}
// HostSoftwareUninstall represents uninstallation of software from a host with a
// Fleet software installer.
type HostSoftwareUninstall struct {
// ExecutionID is the UUID of the script execution that uninstalled the software.
ExecutionID string `json:"script_execution_id,omitempty"`
UninstalledAt time.Time `json:"uninstalled_at"`
}
// HostSoftwareInstalledVersion represents a version of software installed on a host.
type HostSoftwareInstalledVersion struct {
SoftwareID uint `json:"-" db:"software_id"`
SoftwareTitleID uint `json:"-" db:"software_title_id"`
Source string `json:"-" db:"source"`
Version string `json:"version" db:"version"`
BundleIdentifier string `json:"bundle_identifier,omitempty" db:"bundle_identifier"`
LastOpenedAt *time.Time `json:"last_opened_at,omitempty" db:"last_opened_at"`
Vulnerabilities []string `json:"vulnerabilities" db:"vulnerabilities"`
InstalledPaths []string `json:"installed_paths"`
SignatureInformation []PathSignatureInformation `json:"signature_information,omitempty"`
}
// MarshalJSON implements custom JSON marshaling for HostSoftwareInstalledVersion to conditionally
// handle last_opened_at based on the software source.
func (hsv *HostSoftwareInstalledVersion) MarshalJSON() ([]byte, error) {
type Alias HostSoftwareInstalledVersion
return json.Marshal(&struct {
*Alias
LastOpenedAt any `json:"last_opened_at,omitempty"`
}{
Alias: (*Alias)(hsv),
LastOpenedAt: marshalLastOpenedAt(hsv.Source, hsv.LastOpenedAt),
})
}
// UnmarshalJSON implements custom JSON unmarshaling for HostSoftwareInstalledVersion to handle
// the potential empty string in last_opened_at.
func (hsv *HostSoftwareInstalledVersion) UnmarshalJSON(b []byte) error {
type Alias HostSoftwareInstalledVersion
aux := &struct {
*Alias
LastOpenedAt json.RawMessage `json:"last_opened_at"`
}{
Alias: (*Alias)(hsv),
}
if err := json.Unmarshal(b, &aux); err != nil {
return err
}
var err error
hsv.LastOpenedAt, err = unmarshalLastOpenedAt(aux.LastOpenedAt)
return err
}
// HostSoftwareInstallResultPayload is the payload provided by fleetd to record
// the results of a software installation attempt.
type HostSoftwareInstallResultPayload struct {
HostID uint `json:"host_id"`
InstallUUID string `json:"install_uuid"`
// the following fields are nil-able because the corresponding steps may not
// have been executed (optional step, or executed conditionally to a previous
// step).
PreInstallConditionOutput *string `json:"pre_install_condition_output"`
InstallScriptExitCode *int `json:"install_script_exit_code"`
InstallScriptOutput *string `json:"install_script_output"`
PostInstallScriptExitCode *int `json:"post_install_script_exit_code"`
PostInstallScriptOutput *string `json:"post_install_script_output"`
// RetriesRemaining indicates how many retries are left for this installation.
// When > 0, the server should treat this as an intermediate failure and assume
// another attempt is in progress. This field helps make retry handling idempotent.
RetriesRemaining uint `json:"retries_remaining,omitempty"`
}
// Status returns the status computed from the result payload. It should match the logic
// found in the database-computed status (see
// softwareInstallerHostStatusNamedQuery in mysql/software.go).
func (h *HostSoftwareInstallResultPayload) Status() SoftwareInstallerStatus {
switch {
case h.PostInstallScriptExitCode != nil && *h.PostInstallScriptExitCode == 0:
return SoftwareInstalled
case h.PostInstallScriptExitCode != nil && *h.PostInstallScriptExitCode != 0:
return SoftwareInstallFailed
case h.InstallScriptExitCode != nil && *h.InstallScriptExitCode == 0:
return SoftwareInstalled
case h.InstallScriptExitCode != nil && *h.InstallScriptExitCode != 0:
return SoftwareInstallFailed
case h.PreInstallConditionOutput != nil && *h.PreInstallConditionOutput == "":
return SoftwareInstallFailed
default:
return SoftwareInstallPending
}
}
const (
// ExitCodeScriptsDisabled is a special exit code returned by fleetd in the
// HostSoftwareInstallResultPayload when the install was attempted on a host with scripts
// disabled.
ExitCodeScriptsDisabled = -2
// ExitCodeInstallerDownloadFailed is a special exit code returned by fleetd in the
// HostSoftwareInstallResultPayload when fleetd failed to download the installer.
ExitCodeInstallerDownloadFailed = -3
)
// SoftwareInstallerTokenMetadata is the metadata stored in Redis for a software installer token.
type SoftwareInstallerTokenMetadata struct {
TitleID uint `json:"title_id"`
TeamID uint `json:"team_id" renameto:"fleet_id"`
}
const SoftwareInstallerURLMaxLength = 4000
// TempFileReader is an io.Reader with all extra io interfaces supported by a
// file on disk reader (e.g. io.ReaderAt, io.Seeker, etc.). When created with
// NewTempFileReader, it is backed by a temporary file on disk, and that file
// is deleted when Close is called.
type TempFileReader struct {
*os.File
keepFile bool
}
// Rewind seeks to the beginning of the file so the next read will read from
// the start of the bytes.
func (r *TempFileReader) Rewind() error {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return err
}
return nil
}
// Close closes the TempFileReader and deletes the underlying temp file unless
// it was instructed not to do so at creation time.
func (r *TempFileReader) Close() error {
cerr := r.File.Close()
var rerr error
if !r.keepFile {
rerr = os.Remove(r.File.Name())
}
if cerr != nil {
return cerr
}
return rerr
}
// NewKeepFileReader creates a TempFileReader from a file path and keeps the
// file on Close, instead of deleting it.
func NewKeepFileReader(filename string) (*TempFileReader, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
return &TempFileReader{File: f, keepFile: true}, nil
}
// NewTempFileReader creates a temp file to store the data from the provided
// reader and returns a TempFileReader that reads from that temp file, deleting
// it on close.
func NewTempFileReader(from io.Reader, tempDirFn func() string) (*TempFileReader, error) {
if tempDirFn == nil {
tempDirFn = os.TempDir
}
tempFile, err := os.CreateTemp(tempDirFn(), "fleet-temp-file-*")
if err != nil {
return nil, err
}
tfr := &TempFileReader{File: tempFile}
if _, err := io.Copy(tempFile, from); err != nil {
_ = tfr.Close() // best-effort close/delete
return nil, err
}
if err := tfr.Rewind(); err != nil {
_ = tfr.Close() // best-effort close/delete
return nil, err
}
return tfr, nil
}
// SoftwareScopeLabel represents the many-to-many relationship between
// software titles and labels.
//
// NOTE: json representation of the fields is a bit awkward to match the
// required API response, as this struct is returned within software title details.
//
// NOTE: depending on how/where this struct is used, fields MAY BE
// UNRELIABLE insofar as they represent default, empty values.
type SoftwareScopeLabel struct {
LabelName string `db:"label_name" json:"name"`
LabelID uint `db:"label_id" json:"id"` // label id in database, which may be the empty value in some cases where id is not known in advance (e.g., if labels are created during gitops processing)
Exclude bool `db:"exclude" json:"-"` // not rendered in JSON, used when processing LabelsIncludeAll, LabelsIncludeAny and LabelsExcludeAny on parent title (may be the empty value in some cases)
TitleID uint `db:"title_id" json:"-"` // not rendered in JSON, used to store the associated title ID (may be the empty value in some cases)
RequireAll bool `db:"require_all" json:"-"` // not rendered in JSON, used when processing LabelsIncludeAll, LabelsIncludeAny and LabelsExcludeAny on parent title (may be the empty value in some cases)
}
// Max total attempts (including initial) for a non-policy software install.
const MaxSoftwareInstallAttempts = 3
// HostSoftwareInstallOptions contains options that apply to a software or VPP
// app install request.
type HostSoftwareInstallOptions struct {
SelfService bool
PolicyID *uint
ForSetupExperience bool
// ForScheduledUpdates means the install request is for iOS/iPadOS
// scheduled updates, which means it was Fleet-initiated.
ForScheduledUpdates bool
// UserID is an explicit user ID for retries (overrides context user when set).
UserID *uint
// WithRetries indicates the install should be retried on failure (up to
// MaxSoftwareInstallAttempts total). Set by host details, self-service,
// and setup experience install paths.
WithRetries bool
}
// IsFleetInitiated returns true if the software install is initiated by Fleet.
// Software installs initiated via a policy are fleet-initiated (and we also
// make sure SelfService is false, as this case is always user-initiated).
func (o HostSoftwareInstallOptions) IsFleetInitiated() bool {
return !o.SelfService && (o.PolicyID != nil || o.ForScheduledUpdates)
}
// Priority returns the upcoming activities queue priority to use for this
// software installation. Software installed for the setup experience is
// prioritized over other software installations.
func (o HostSoftwareInstallOptions) Priority() int {
if o.ForSetupExperience {
return 100
}
return 0
}
const (
BatchDownloadMaxRetries = 3
BatchUploadMaxRetries = 3
)
func BatchSoftwareInstallerRetryInterval() time.Duration {
defaultInterval := 30 * time.Second
d := dev_mode.Env("FLEET_DEV_BATCH_RETRY_INTERVAL")
if d != "" {
t, err := time.ParseDuration(d)
if err != nil {
return defaultInterval
}
return t
}
return defaultInterval
}