fleet/server/datastore/mysql/software_installers.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

3653 lines
124 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"maps"
"slices"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/automatic_policy"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
const setupExperienceSoftwareInstallsRetries uint = 2 // 3 attempts = 1 initial + 2 retries
func (ds *Datastore) ListPendingSoftwareInstalls(ctx context.Context, hostID uint) ([]string, error) {
return ds.listUpcomingSoftwareInstalls(ctx, hostID, false)
}
func (ds *Datastore) ListReadyToExecuteSoftwareInstalls(ctx context.Context, hostID uint) ([]string, error) {
return ds.listUpcomingSoftwareInstalls(ctx, hostID, true)
}
func (ds *Datastore) listUpcomingSoftwareInstalls(ctx context.Context, hostID uint, onlyReadyToExecute bool) ([]string, error) {
extraWhere := ""
if onlyReadyToExecute {
extraWhere = " AND activated_at IS NOT NULL"
}
stmt := fmt.Sprintf(`
SELECT
execution_id
FROM (
SELECT
execution_id,
IF(activated_at IS NULL, 0, 1) as topmost,
priority,
created_at
FROM
upcoming_activities
WHERE
host_id = ? AND
activity_type = 'software_install'
%s
ORDER BY topmost DESC, priority ASC, created_at ASC) as t
`, extraWhere)
var results []string
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, stmt, hostID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "list pending software installs")
}
return results, nil
}
func (ds *Datastore) GetSoftwareInstallDetails(ctx context.Context, executionId string) (*fleet.SoftwareInstallDetails, error) {
const stmt = `
SELECT
hsi.host_id AS host_id,
hsi.execution_id AS execution_id,
hsi.software_installer_id AS installer_id,
hsi.self_service AS self_service,
COALESCE(si.pre_install_query, '') AS pre_install_condition,
inst.contents AS install_script,
uninst.contents AS uninstall_script,
COALESCE(pisnt.contents, '') AS post_install_script
FROM
host_software_installs hsi
INNER JOIN
software_installers si
ON hsi.software_installer_id = si.id
LEFT OUTER JOIN
script_contents inst
ON inst.id = si.install_script_content_id
LEFT OUTER JOIN
script_contents uninst
ON uninst.id = si.uninstall_script_content_id
LEFT OUTER JOIN
script_contents pisnt
ON pisnt.id = si.post_install_script_content_id
WHERE
hsi.execution_id = ? AND
hsi.canceled = 0
UNION
SELECT
ua.host_id AS host_id,
ua.execution_id AS execution_id,
siua.software_installer_id AS installer_id,
ua.payload->'$.self_service' AS self_service,
COALESCE(si.pre_install_query, '') AS pre_install_condition,
inst.contents AS install_script,
uninst.contents AS uninstall_script,
COALESCE(pisnt.contents, '') AS post_install_script
FROM
upcoming_activities ua
INNER JOIN
software_install_upcoming_activities siua
ON ua.id = siua.upcoming_activity_id
INNER JOIN
software_installers si
ON siua.software_installer_id = si.id
LEFT OUTER JOIN
script_contents inst
ON inst.id = si.install_script_content_id
LEFT OUTER JOIN
script_contents uninst
ON uninst.id = si.uninstall_script_content_id
LEFT OUTER JOIN
script_contents pisnt
ON pisnt.id = si.post_install_script_content_id
WHERE
ua.execution_id = ? AND
ua.activated_at IS NULL -- if already activated, then it is covered by the other SELECT
`
result := &fleet.SoftwareInstallDetails{}
if err := sqlx.GetContext(ctx, ds.reader(ctx), result, stmt, executionId, executionId); err != nil {
if err == sql.ErrNoRows {
return nil, ctxerr.Wrap(ctx, notFound("SoftwareInstallerDetails").WithName(executionId), "get software installer details")
}
return nil, ctxerr.Wrap(ctx, err, "get software install details")
}
expandedInstallScript, err := ds.ExpandEmbeddedSecrets(ctx, result.InstallScript)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "expanding secrets in install script")
}
expandedPostInstallScript, err := ds.ExpandEmbeddedSecrets(ctx, result.PostInstallScript)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "expanding secrets in post-install script")
}
expandedUninstallScript, err := ds.ExpandEmbeddedSecrets(ctx, result.UninstallScript)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "expanding secrets in uninstall script")
}
result.InstallScript = expandedInstallScript
result.PostInstallScript = expandedPostInstallScript
result.UninstallScript = expandedUninstallScript
// Check if this install is part of setup experience and set retry count accordingly
var setupExperienceCount int
setupExpStmt := `
SELECT EXISTS(
SELECT 1 FROM setup_experience_status_results
WHERE host_software_installs_execution_id = ? AND status IN (?, ?)
)`
if err := sqlx.GetContext(ctx, ds.reader(ctx), &setupExperienceCount, setupExpStmt, executionId, fleet.SetupExperienceStatusPending, fleet.SetupExperienceStatusRunning); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, ctxerr.Wrap(ctx, err, "check if install is part of setup experience")
}
if setupExperienceCount > 0 {
result.MaxRetries = setupExperienceSoftwareInstallsRetries
}
return result, nil
}
func (ds *Datastore) checkVPPAppExistsForTitleIdentifier(ctx context.Context, q sqlx.QueryerContext, teamID *uint, platform, bundleIdentifier, source, browser string) (bool, error) {
const stmt = `
SELECT
1
FROM
software_titles st
INNER JOIN
vpp_apps vpa ON st.id = vpa.title_id AND vpa.platform = ?
INNER JOIN
vpp_apps_teams vpt ON vpa.adam_id = vpt.adam_id AND vpa.platform = vpt.platform
AND vpt.global_or_team_id = ?
WHERE
st.unique_identifier = ? AND st.source = ? AND st.extension_for = ?
`
var globalOrTeamID uint
if teamID != nil {
globalOrTeamID = *teamID
}
var exists int
err := sqlx.GetContext(ctx, q, &exists, stmt, platform, globalOrTeamID, bundleIdentifier, source, browser)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, ctxerr.Wrap(ctx, err, "check VPP app exists for title identifier")
}
return exists == 1, nil
}
func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (installerID, titleID uint, err error) {
if payload.ValidatedLabels == nil {
// caller must ensure this is not nil; if caller intends no labels to be created,
// payload.ValidatedLabels should point to an empty struct.
return 0, 0, errors.New("validated labels must not be nil")
}
err = ds.checkSoftwareConflictsByIdentifier(ctx, payload)
if err != nil {
teamName, err := ds.getTeamName(ctx, payload.TeamID)
if err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "get team for installer conflict error")
}
return 0, 0, ctxerr.Wrap(ctx, fleet.ConflictError{
Message: fmt.Sprintf(fleet.CantAddSoftwareConflictMessage, payload.Title, teamName),
}, "vpp app conflicts with existing software installer")
}
// Insert in house app instead of software installer
// And add both iOS and ipadOS titles per https://github.com/fleetdm/fleet/issues/34283
if payload.Extension == "ipa" {
installerID, titleID, err := ds.insertInHouseApp(ctx, &fleet.InHouseAppPayload{
TeamID: payload.TeamID,
Title: payload.Title,
Filename: payload.Filename,
BundleID: payload.BundleIdentifier,
StorageID: payload.StorageID,
Platform: payload.Platform,
ValidatedLabels: payload.ValidatedLabels,
CategoryIDs: payload.CategoryIDs,
Version: payload.Version,
SelfService: payload.SelfService,
})
if err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "insert in house app")
}
return installerID, titleID, err
}
titleID, err = ds.getOrGenerateSoftwareInstallerTitleID(ctx, payload)
if err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "get or generate software installer title ID")
}
// Enforce team-scoped uniqueness by storage hash, aligning upload behavior with GitOps.
// However, if the duplicate-by-hash is for the same title/source on the same team,
// let the DB unique (team,title) constraint surface the conflict (so tests expecting
// a 409 Conflict with "already exists" still pass).
// Only validate for script packages (.sh/.ps1) where content hash equals functionality.
// Binary installers can legitimately share content with different install scripts.
if payload.StorageID != "" && fleet.IsScriptPackage(payload.Extension) {
var tmID uint
if payload.TeamID != nil {
tmID = *payload.TeamID
}
// Check duplicates by content hash only (ignore URL) to align with GitOps/apply rules.
teamsByHash, err := ds.GetTeamsWithInstallerByHash(ctx, payload.StorageID, "")
if err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "check duplicate installer by hash")
}
if found, exists := teamsByHash[tmID]; exists {
// If the existing installer has the same title and source, allow the insert to proceed
// so that the existing UNIQUE (global_or_team_id, title_id) constraint yields a
// Conflict error with the expected message.
// Since this is not an in-house app, only one installer per team can exist.
if !(found[0].Title == payload.Title && found[0].Source == payload.Source) {
return 0, 0, fleet.NewInvalidArgumentError(
"software",
"Couldn't add software. An installer with identical contents already exists on this fleet.",
)
}
// If exact duplicate (same title and source), continue to let DB constraint handle it
}
}
if err := ds.addSoftwareTitleToMatchingSoftware(ctx, titleID, payload); err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "add software title to matching software")
}
installScriptID, err := ds.getOrGenerateScriptContentsID(ctx, payload.InstallScript)
if err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "get or generate install script contents ID")
}
uninstallScriptID, err := ds.getOrGenerateScriptContentsID(ctx, payload.UninstallScript)
if err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "get or generate uninstall script contents ID")
}
var postInstallScriptID *uint
if payload.PostInstallScript != "" {
sid, err := ds.getOrGenerateScriptContentsID(ctx, payload.PostInstallScript)
if err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "get or generate post-install script contents ID")
}
postInstallScriptID = &sid
}
var tid *uint
var globalOrTeamID uint
if payload.TeamID != nil {
globalOrTeamID = *payload.TeamID
if *payload.TeamID > 0 {
tid = payload.TeamID
}
}
if err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
stmt := `
INSERT INTO software_installers (
team_id,
global_or_team_id,
title_id,
storage_id,
filename,
extension,
version,
package_ids,
install_script_content_id,
pre_install_query,
post_install_script_content_id,
uninstall_script_content_id,
platform,
self_service,
user_id,
user_name,
user_email,
fleet_maintained_app_id,
url,
upgrade_code,
is_active,
patch_query
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ?, ?, ?, ?)`
args := []interface{}{
tid,
globalOrTeamID,
titleID,
payload.StorageID,
payload.Filename,
payload.Extension,
payload.Version,
strings.Join(payload.PackageIDs, ","),
installScriptID,
payload.PreInstallQuery,
postInstallScriptID,
uninstallScriptID,
payload.Platform,
payload.SelfService,
payload.UserID,
payload.UserID,
payload.UserID,
payload.FleetMaintainedAppID,
payload.URL,
payload.UpgradeCode,
true,
payload.PatchQuery,
}
res, err := tx.ExecContext(ctx, stmt, args...)
if err != nil {
if IsDuplicate(err) {
// already exists for this team/no team
teamName, err := ds.getTeamName(ctx, payload.TeamID)
if err != nil {
return ctxerr.Wrap(ctx, err)
}
return alreadyExists("SoftwareInstaller", payload.Title).WithTeamName(teamName)
}
return err
}
id, _ := res.LastInsertId()
installerID = uint(id) //nolint:gosec // dismiss G115
if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, installerID, *payload.ValidatedLabels, softwareTypeInstaller); err != nil {
return ctxerr.Wrap(ctx, err, "upsert software installer labels")
}
if payload.CategoryIDs != nil {
if err := setOrUpdateSoftwareInstallerCategoriesDB(ctx, tx, installerID, payload.CategoryIDs, softwareTypeInstaller); err != nil {
return ctxerr.Wrap(ctx, err, "upsert software installer categories")
}
}
if payload.AutomaticInstall {
var installerMetadata automatic_policy.InstallerMetadata
if payload.AutomaticInstallQuery != "" {
installerMetadata = automatic_policy.FMAInstallerMetadata{
Title: payload.Title,
Platform: payload.Platform,
Query: payload.AutomaticInstallQuery,
}
} else {
installerMetadata = automatic_policy.FullInstallerMetadata{
Title: payload.Title,
Extension: payload.Extension,
BundleIdentifier: payload.BundleIdentifier,
PackageIDs: payload.PackageIDs,
UpgradeCode: payload.UpgradeCode,
}
}
generatedPolicyData, err := automatic_policy.Generate(installerMetadata)
if err != nil {
return ctxerr.Wrap(ctx, err, "generate automatic policy query data")
}
policy, err := ds.createAutomaticPolicy(ctx, tx, *generatedPolicyData, payload.TeamID, ptr.Uint(installerID), nil)
if err != nil {
return ctxerr.Wrap(ctx, err, "create automatic policy")
}
payload.AddedAutomaticInstallPolicy = policy
}
return nil
}); err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "insert software installer")
}
return installerID, titleID, nil
}
func (ds *Datastore) getTeamName(ctx context.Context, teamID *uint) (string, error) {
teamName := fleet.TeamNameNoTeam
if teamID != nil && *teamID > 0 {
tm, err := ds.TeamLite(ctx, *teamID)
if err != nil {
return "", ctxerr.Wrap(ctx, err)
}
teamName = tm.Name
}
return teamName, nil
}
func setOrUpdateSoftwareInstallerCategoriesDB(ctx context.Context, tx sqlx.ExtContext, installerID uint, categoryIDs []uint, swType softwareType) error {
// remove existing categories
delArgs := []interface{}{installerID}
delStmt := fmt.Sprintf(`DELETE FROM %[1]s_software_categories WHERE %[1]s_id = ?`, swType)
if len(categoryIDs) > 0 {
inStmt, args, err := sqlx.In(` AND software_category_id NOT IN (?)`, categoryIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build delete existing software categories query")
}
delArgs = append(delArgs, args...)
delStmt += inStmt
}
_, err := tx.ExecContext(ctx, delStmt, delArgs...)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete existing software categories")
}
if len(categoryIDs) > 0 {
stmt := `INSERT IGNORE INTO %[1]s_software_categories (%[1]s_id, software_category_id) VALUES %s`
var placeholders string
var insertArgs []any
for _, lid := range categoryIDs {
placeholders += "(?, ?),"
insertArgs = append(insertArgs, installerID, lid)
}
placeholders = strings.TrimSuffix(placeholders, ",")
_, err = tx.ExecContext(ctx, fmt.Sprintf(stmt, swType, placeholders), insertArgs...)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert software software categories")
}
}
return nil
}
func (ds *Datastore) createAutomaticPolicy(ctx context.Context, tx sqlx.ExtContext, policyData automatic_policy.PolicyData, teamID *uint, softwareInstallerID *uint, vppAppsTeamsID *uint) (*fleet.Policy, error) {
tmID := fleet.PolicyNoTeamID
if teamID != nil {
tmID = *teamID
}
availablePolicyName, err := getAvailablePolicyName(ctx, tx, tmID, policyData.Name)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get available policy name")
}
var userID *uint
if ctxUser := authz.UserFromContext(ctx); ctxUser != nil {
userID = &ctxUser.ID
}
policy, err := newTeamPolicy(ctx, tx, tmID, userID, fleet.PolicyPayload{
Name: availablePolicyName,
Query: policyData.Query,
Platform: policyData.Platform,
Description: policyData.Description,
SoftwareInstallerID: softwareInstallerID,
VPPAppsTeamsID: vppAppsTeamsID,
Type: fleet.PolicyTypeDynamic,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create automatic policy query")
}
return policy, nil
}
func getAvailablePolicyName(ctx context.Context, db sqlx.QueryerContext, teamID uint, tentativePolicyName string) (string, error) {
availableName := tentativePolicyName
for i := 2; ; i++ {
var count int
if err := sqlx.GetContext(ctx, db, &count, `SELECT COUNT(*) FROM policies WHERE team_id = ? AND name = ?`, teamID, availableName); err != nil {
return "", ctxerr.Wrapf(ctx, err, "get policy by team and name")
}
if count == 0 {
break
}
availableName = fmt.Sprintf("%s %d", tentativePolicyName, i)
}
return availableName, nil
}
func (ds *Datastore) getOrGenerateSoftwareInstallerTitleID(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) {
selectStmt := `SELECT id FROM software_titles WHERE name = ? AND source = ? AND extension_for = ''`
selectArgs := []any{payload.Title, payload.Source}
insertStmt := `INSERT INTO software_titles (name, source, extension_for) VALUES (?, ?, '')`
insertArgs := []any{payload.Title, payload.Source}
// upgrade_code should be set to NULL for non-Windows software, empty or non-empty string for Windows software
if payload.Source == "programs" {
// select by either name or upgrade code, preferring upgrade code
if payload.UpgradeCode != "" {
selectStmt = `SELECT id FROM software_titles WHERE (name = ? AND source = ? AND extension_for = '' AND upgrade_code = '') OR upgrade_code = ? ORDER BY upgrade_code = ? DESC LIMIT 1`
selectArgs = []any{payload.Title, payload.Source, payload.UpgradeCode, payload.UpgradeCode}
}
insertStmt = `INSERT INTO software_titles (name, source, extension_for, upgrade_code) VALUES (?, ?, '', ?)`
insertArgs = []any{payload.Title, payload.Source, payload.UpgradeCode}
}
if payload.BundleIdentifier != "" {
// match by bundle identifier and source first, or standard matching if we don't have a bundle identifier match
selectStmt = `SELECT id FROM software_titles WHERE (bundle_identifier = ? AND source = ?) OR (name = ? AND source = ? AND extension_for = '') ORDER BY bundle_identifier = ? DESC LIMIT 1`
selectArgs = []any{payload.BundleIdentifier, payload.Source, payload.Title, payload.Source, payload.BundleIdentifier}
insertStmt = `INSERT INTO software_titles (name, source, bundle_identifier, extension_for) VALUES (?, ?, ?, '')`
insertArgs = []any{payload.Title, payload.Source, payload.BundleIdentifier}
}
titleID, err := ds.optimisticGetOrInsert(ctx,
&parameterizedStmt{
Statement: selectStmt,
Args: selectArgs,
},
&parameterizedStmt{
Statement: insertStmt,
Args: insertArgs,
},
)
if err != nil {
return 0, err
}
// update the upgrade code for a title, since optimisticGetOrInsert uses only the select if it already exists
if payload.Source == "programs" && payload.UpgradeCode != "" {
updateStmt := `UPDATE software_titles SET upgrade_code = ? WHERE id = ?`
updateArgs := []any{payload.UpgradeCode, titleID}
_, err := ds.writer(ctx).ExecContext(ctx, updateStmt, updateArgs...)
if err != nil {
return 0, err
}
}
return titleID, nil
}
func (ds *Datastore) addSoftwareTitleToMatchingSoftware(ctx context.Context, titleID uint, payload *fleet.UploadSoftwareInstallerPayload) error {
whereClause := "WHERE (s.name, s.source, s.extension_for) = (?, ?, '')"
whereArgs := []any{payload.Title, payload.Source}
if payload.BundleIdentifier != "" {
whereClause = "WHERE s.bundle_identifier = ? AND source = ?"
whereArgs = []any{payload.BundleIdentifier, payload.Source}
}
if payload.UpgradeCode != "" {
// match only by upgrade code
whereClause = "WHERE s.upgrade_code = ?"
whereArgs = []any{payload.UpgradeCode}
}
args := make([]any, 0, len(whereArgs))
args = append(args, titleID)
args = append(args, whereArgs...)
updateSoftwareStmt := fmt.Sprintf(`
UPDATE software s
SET s.title_id = ?
%s`, whereClause)
_, err := ds.writer(ctx).ExecContext(ctx, updateSoftwareStmt, args...)
return ctxerr.Wrap(ctx, err, "adding fk reference in software to software_titles")
}
type softwareType string
const (
softwareTypeInstaller softwareType = "software_installer"
softwareTypeVPP softwareType = "vpp_app_team"
softwareTypeInHouseApp softwareType = "in_house_app"
)
// setOrUpdateSoftwareInstallerLabelsDB sets or updates the label associations for the specified software
// installer. If no labels are provided, it will remove all label associations with the software installer.
func setOrUpdateSoftwareInstallerLabelsDB(ctx context.Context, tx sqlx.ExtContext, installerID uint, labels fleet.LabelIdentsWithScope, softwareType softwareType) error {
labelIds := make([]uint, 0, len(labels.ByName))
for _, label := range labels.ByName {
labelIds = append(labelIds, label.LabelID)
}
// remove existing labels
delArgs := []interface{}{installerID}
delStmt := fmt.Sprintf(`DELETE FROM %[1]s_labels WHERE %[1]s_id = ?`, softwareType)
if len(labelIds) > 0 {
inStmt, args, err := sqlx.In(` AND label_id NOT IN (?)`, labelIds)
if err != nil {
return ctxerr.Wrap(ctx, err, "build delete existing software labels query")
}
delArgs = append(delArgs, args...)
delStmt += inStmt
}
_, err := tx.ExecContext(ctx, delStmt, delArgs...)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete existing software labels")
}
// insert new labels
if len(labelIds) > 0 {
var exclude, requireAll bool
switch labels.LabelScope {
case fleet.LabelScopeIncludeAny:
exclude = false
requireAll = false
case fleet.LabelScopeExcludeAny:
exclude = true
requireAll = false
case fleet.LabelScopeIncludeAll:
exclude = false
requireAll = true
default:
// this should never happen
return ctxerr.New(ctx, "invalid label scope")
}
stmt := `INSERT INTO %[1]s_labels (%[1]s_id, label_id, exclude, require_all) VALUES %s
ON DUPLICATE KEY UPDATE exclude = VALUES(exclude), require_all = VALUES(require_all)`
var placeholders string
var insertArgs []interface{}
for _, lid := range labelIds {
placeholders += "(?, ?, ?, ?),"
insertArgs = append(insertArgs, installerID, lid, exclude, requireAll)
}
placeholders = strings.TrimSuffix(placeholders, ",")
_, err = tx.ExecContext(ctx, fmt.Sprintf(stmt, softwareType, placeholders), insertArgs...)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert software label")
}
}
return nil
}
func (ds *Datastore) UpdateInstallerSelfServiceFlag(ctx context.Context, selfService bool, id uint) error {
_, err := ds.writer(ctx).ExecContext(ctx, `UPDATE software_installers SET self_service = ? WHERE id = ?`, selfService, id)
if err != nil {
return ctxerr.Wrap(ctx, err, "update software installer")
}
return nil
}
func (ds *Datastore) SaveInstallerUpdates(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) error {
if payload.InstallScript == nil || payload.UninstallScript == nil || payload.PreInstallQuery == nil || payload.SelfService == nil {
return ctxerr.Wrap(ctx, errors.New("missing installer update payload fields"), "update installer record")
}
installScriptID, err := ds.getOrGenerateScriptContentsID(ctx, *payload.InstallScript)
if err != nil {
return ctxerr.Wrap(ctx, err, "get or generate install script contents ID")
}
uninstallScriptID, err := ds.getOrGenerateScriptContentsID(ctx, *payload.UninstallScript)
if err != nil {
return ctxerr.Wrap(ctx, err, "get or generate uninstall script contents ID")
}
var postInstallScriptID *uint
if payload.PostInstallScript != nil && *payload.PostInstallScript != "" { // pointer because optional
sid, err := ds.getOrGenerateScriptContentsID(ctx, *payload.PostInstallScript)
if err != nil {
return ctxerr.Wrap(ctx, err, "get or generate post-install script contents ID")
}
postInstallScriptID = &sid
}
var touchUploaded string
if payload.InstallerFile != nil {
// installer cannot be changed when associated with an FMA
touchUploaded = ", uploaded_at = NOW()"
}
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
stmt := fmt.Sprintf(`UPDATE software_installers SET
storage_id = ?,
filename = ?,
version = ?,
package_ids = ?,
install_script_content_id = ?,
pre_install_query = ?,
post_install_script_content_id = ?,
uninstall_script_content_id = ?,
self_service = ?,
upgrade_code = ?,
user_id = ?,
user_name = (SELECT name FROM users WHERE id = ?),
user_email = (SELECT email FROM users WHERE id = ?)%s
WHERE id = ?`, touchUploaded)
args := []interface{}{
payload.StorageID,
payload.Filename,
payload.Version,
strings.Join(payload.PackageIDs, ","),
installScriptID,
*payload.PreInstallQuery,
postInstallScriptID,
uninstallScriptID,
*payload.SelfService,
payload.UpgradeCode,
payload.UserID,
payload.UserID,
payload.UserID,
payload.InstallerID,
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "update software installer")
}
if payload.ValidatedLabels != nil {
if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, payload.InstallerID, *payload.ValidatedLabels, softwareTypeInstaller); err != nil {
return ctxerr.Wrap(ctx, err, "upsert software installer labels")
}
}
if payload.CategoryIDs != nil {
if err := setOrUpdateSoftwareInstallerCategoriesDB(ctx, tx, payload.InstallerID, payload.CategoryIDs, softwareTypeInstaller); err != nil {
return ctxerr.Wrap(ctx, err, "upsert software installer categories")
}
}
if payload.DisplayName != nil {
if err := updateSoftwareTitleDisplayName(ctx, tx, payload.TeamID, payload.TitleID, *payload.DisplayName); err != nil {
return ctxerr.Wrap(ctx, err, "update software title display name")
}
}
// When an installer is modified reset attempt numbers for policy automations
if err := ds.resetInstallerPolicyAutomationAttempts(ctx, tx, payload.InstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "resetting policy automation attempts for installer")
}
return nil
})
if err != nil {
return ctxerr.Wrap(ctx, err, "update software installer")
}
return nil
}
// resetInstallerPolicyAutomationAttempts resets all attempt numbers for software installer executions for policy automations
func (ds *Datastore) resetInstallerPolicyAutomationAttempts(ctx context.Context, db sqlx.ExecerContext, installerID uint) error {
_, err := db.ExecContext(ctx, `
UPDATE host_software_installs
SET attempt_number = 0
WHERE software_installer_id = ? AND policy_id IS NOT NULL AND (attempt_number > 0 OR attempt_number IS NULL)
`, installerID)
if err != nil {
return ctxerr.Wrap(ctx, err, "reset policy automation installer attempts")
}
return nil
}
// ResetNonPolicyInstallAttempts resets all attempt numbers for non-policy
// software installer executions for a host and cancels any pending retry
// installs. This is called before user-initiated installs to ensure a fresh
// retry sequence and to allow the new install to proceed without being
// blocked by a pending retry.
//
// Cancellation uses cancelHostUpcomingActivity to handle edge cases like
// marking setup experience entries as failed and activating the next
// upcoming activity.
func (ds *Datastore) ResetNonPolicyInstallAttempts(ctx context.Context, hostID, softwareInstallerID uint) error {
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
// Reset attempt_number for old installs. Setting attempt_number to 0
// marks these records as superseded by a new install request. The
// filters (attempt_number > 0 OR attempt_number IS NULL) throughout
// the codebase skip these records when counting attempts.
_, err := tx.ExecContext(ctx, `
UPDATE host_software_installs
SET attempt_number = 0
WHERE host_id = ?
AND software_installer_id = ?
AND policy_id IS NULL
AND (attempt_number > 0 OR attempt_number IS NULL)
`, hostID, softwareInstallerID)
if err != nil {
return ctxerr.Wrap(ctx, err, "reset non-policy install attempts")
}
// Find activated pending retry installs to cancel. Only cancel
// activities that have been activated (activated_at IS NOT NULL);
// non-activated upcoming activities should be left unchanged.
var executionIDs []string
if err := sqlx.SelectContext(ctx, tx, &executionIDs, `
SELECT ua.execution_id
FROM upcoming_activities ua
INNER JOIN software_install_upcoming_activities siua
ON ua.id = siua.upcoming_activity_id
WHERE ua.host_id = ?
AND siua.software_installer_id = ?
AND siua.policy_id IS NULL
AND ua.activity_type = 'software_install'
AND ua.activated_at IS NOT NULL
`, hostID, softwareInstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "query pending non-policy install retries")
}
// Use cancelHostUpcomingActivity for each pending retry. This
// handles setup experience cleanup, marks host_software_installs
// as canceled, and activates the next upcoming activity.
// The returned ActivityDetails is discarded because this is an
// internal reset for a new install, not a user-initiated cancel.
for _, execID := range executionIDs {
if _, err := ds.cancelHostUpcomingActivity(ctx, tx, hostID, execID); err != nil {
return ctxerr.Wrap(ctx, err, "cancel pending non-policy install retry")
}
}
return nil
})
}
func (ds *Datastore) ValidateOrbitSoftwareInstallerAccess(ctx context.Context, hostID uint, installerID uint) (bool, error) {
// NOTE: this is ok to only look in host_software_installs (and ignore
// upcoming_activities), because orbit should not be able to get the
// installer until it is ready to install.
query := `
SELECT 1
FROM
host_software_installs
WHERE
software_installer_id = ? AND
host_id = ? AND
install_script_exit_code IS NULL AND
canceled = 0
`
var access bool
err := sqlx.GetContext(ctx, ds.reader(ctx), &access, query, installerID, hostID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, ctxerr.Wrap(ctx, err, "check software installer association to host")
}
return true, nil
}
func (ds *Datastore) GetSoftwareInstallerMetadataByID(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) {
query := `
SELECT
si.id,
si.team_id,
si.title_id,
si.storage_id,
si.package_ids,
si.filename,
si.extension,
si.version,
si.install_script_content_id,
si.pre_install_query,
si.post_install_script_content_id,
si.uninstall_script_content_id,
si.uploaded_at,
COALESCE(st.name, '') AS software_title,
si.platform,
si.fleet_maintained_app_id,
si.upgrade_code
FROM
software_installers si
LEFT OUTER JOIN software_titles st ON st.id = si.title_id
WHERE
si.id = ?`
var dest fleet.SoftwareInstaller
err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, id)
if err != nil {
if err == sql.ErrNoRows {
return nil, ctxerr.Wrap(ctx, notFound("SoftwareInstaller").WithID(id), "get software installer metadata")
}
return nil, ctxerr.Wrap(ctx, err, "get software installer metadata")
}
var tmID uint
if dest.TeamID != nil {
tmID = *dest.TeamID
}
displayName, err := ds.getSoftwareTitleDisplayName(ctx, tmID, *dest.TitleID)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "get display name for software installer")
}
dest.DisplayName = displayName
return &dest, nil
}
func (ds *Datastore) installerAvailableForInstallForTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (installerID uint, vppAppID *fleet.VPPAppID, inHouseID uint, err error) {
const stmt = `
SELECT
si.id AS installer_id,
NULL as vpp_adam_id,
NULL as vpp_platform,
NULL as in_house_id
FROM
software_installers si
WHERE
si.title_id = ? AND si.global_or_team_id = ?
UNION ALL
SELECT
NULL AS installer_id,
vap.adam_id AS vpp_adam_id,
vap.platform AS vpp_platform,
NULL as in_house_id
FROM
vpp_apps vap
JOIN vpp_apps_teams vat ON vap.adam_id = vat.adam_id AND vap.platform = vat.platform
WHERE
vap.title_id = ? AND vat.global_or_team_id = ?
UNION ALL
SELECT
NULL AS installer_id,
NULL as vpp_adam_id,
NULL as vpp_platform,
iha.id as in_house_id
FROM
in_house_apps iha
WHERE
iha.title_id = ? AND iha.global_or_team_id = ?
`
var tmID uint
if teamID != nil {
tmID = *teamID
}
type resultRow struct {
InstallerID sql.Null[uint] `db:"installer_id"`
VPPAdamID sql.Null[string] `db:"vpp_adam_id"`
VPPPlatform sql.Null[string] `db:"vpp_platform"`
InHouseID sql.Null[uint] `db:"in_house_id"`
}
var row resultRow
err = sqlx.GetContext(ctx, ds.reader(ctx), &row, stmt,
titleID, tmID, titleID, tmID, titleID, tmID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil, 0, nil
}
return 0, nil, 0, ctxerr.Wrap(ctx, err, "check installer/vpp/in-house app availability")
}
if row.VPPAdamID.Valid {
vppAppID = &fleet.VPPAppID{
AdamID: row.VPPAdamID.V,
Platform: fleet.InstallableDevicePlatform(row.VPPPlatform.V),
}
}
return row.InstallerID.V, vppAppID, row.InHouseID.V, nil
}
func (ds *Datastore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) {
var scriptContentsSelect, scriptContentsFrom string
if withScriptContents {
scriptContentsSelect = ` , inst.contents AS install_script, COALESCE(pinst.contents, '') AS post_install_script, uninst.contents AS uninstall_script `
scriptContentsFrom = ` LEFT OUTER JOIN script_contents inst ON inst.id = si.install_script_content_id
LEFT OUTER JOIN script_contents pinst ON pinst.id = si.post_install_script_content_id
LEFT OUTER JOIN script_contents uninst ON uninst.id = si.uninstall_script_content_id`
}
query := fmt.Sprintf(`
SELECT
si.id,
si.team_id,
si.title_id,
si.storage_id,
si.fleet_maintained_app_id,
si.package_ids,
si.upgrade_code,
si.filename,
si.extension,
si.version,
si.platform,
si.install_script_content_id,
si.pre_install_query,
si.post_install_script_content_id,
si.uninstall_script_content_id,
si.uploaded_at,
si.self_service,
si.url,
COALESCE(st.name, '') AS software_title,
COALESCE(st.bundle_identifier, '') AS bundle_identifier,
si.patch_query
%s
FROM
software_installers si
JOIN software_titles st ON st.id = si.title_id
LEFT JOIN fleet_maintained_apps fma ON fma.id = si.fleet_maintained_app_id
%s
WHERE
si.title_id = ? AND si.global_or_team_id = ?
AND si.is_active = 1
ORDER BY si.uploaded_at DESC, si.id DESC
LIMIT 1`,
scriptContentsSelect, scriptContentsFrom)
var tmID uint
if teamID != nil {
tmID = *teamID
}
var dest fleet.SoftwareInstaller
err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, titleID, tmID)
if err != nil {
if err == sql.ErrNoRows {
return nil, ctxerr.Wrap(ctx, notFound("SoftwareInstaller"), "get software installer metadata")
}
return nil, ctxerr.Wrap(ctx, err, "get software installer metadata")
}
// TODO: do we want to include labels on other queries that return software installer metadata
// (e.g., GetSoftwareInstallerMetadataByID)?
labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID, softwareTypeInstaller)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get software installer labels")
}
var exclAny, inclAny, inclAll []fleet.SoftwareScopeLabel
for _, l := range labels {
switch {
case l.Exclude && !l.RequireAll:
exclAny = append(exclAny, l)
case !l.Exclude && l.RequireAll:
inclAll = append(inclAll, l)
case !l.Exclude && !l.RequireAll:
inclAny = append(inclAny, l)
default:
ds.logger.WarnContext(ctx, "software installer has an unsupported label scope", "installer_id", dest.InstallerID, "invalid_label", fmt.Sprintf("%#v", l))
}
}
var count int
for _, set := range [][]fleet.SoftwareScopeLabel{exclAny, inclAny, inclAll} {
if len(set) > 0 {
count++
}
}
if count > 1 {
ds.logger.WarnContext(ctx, "software installer has more than one scope of labels", "installer_id", dest.InstallerID, "include_any", fmt.Sprintf("%v", inclAny), "exclude_any", fmt.Sprintf("%v", exclAny), "include_all", fmt.Sprintf("%v", inclAll))
}
dest.LabelsExcludeAny = exclAny
dest.LabelsIncludeAny = inclAny
dest.LabelsIncludeAll = inclAll
categoryMap, err := ds.GetCategoriesForSoftwareTitles(ctx, []uint{titleID}, teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting categories for software installer metadata")
}
if categories, ok := categoryMap[titleID]; ok {
dest.Categories = categories
}
displayName, err := ds.getSoftwareTitleDisplayName(ctx, tmID, titleID)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "get software title display name")
}
dest.DisplayName = displayName
if teamID != nil {
policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{titleID}, *teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get policies by software title ID")
}
dest.AutomaticInstallPolicies = policies
icon, err := ds.GetSoftwareTitleIcon(ctx, *teamID, titleID)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "get software title icon")
}
if icon != nil {
dest.IconUrl = ptr.String(icon.IconUrl())
}
}
return &dest, nil
}
func (ds *Datastore) getSoftwareInstallerLabels(ctx context.Context, installerID uint, softwareType softwareType) ([]fleet.SoftwareScopeLabel, error) {
query := fmt.Sprintf(`
SELECT
label_id,
exclude,
l.name as label_name,
si.title_id,
require_all
FROM
%[1]s_labels sil
JOIN %[1]ss si ON si.id = sil.%[1]s_id
JOIN labels l ON l.id = sil.label_id
WHERE
%[1]s_id = ?`, softwareType)
var labels []fleet.SoftwareScopeLabel
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &labels, query, installerID); err != nil {
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("get %s labels", softwareType))
}
return labels, nil
}
var (
errDeleteInstallerWithAssociatedInstallPolicy = &fleet.ConflictError{Message: "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."}
errDeleteInstallerInstalledDuringSetup = &fleet.ConflictError{Message: "Couldn't delete. This software is installed during new host setup. Please remove software in Controls > Setup experience and try again."}
errDeleteInstallerWithAssociatedPatchPolicy = &fleet.ConflictError{Message: "Couldnt delete. This software has a patch policy. Please remove the patch policy and try again."}
)
func (ds *Datastore) DeleteSoftwareInstaller(ctx context.Context, id uint) error {
var activateAffectedHostIDs []uint
// check if there is a patch policy that uses this title
var policyExists bool
err := sqlx.GetContext(ctx, ds.reader(ctx), &policyExists, `SELECT EXISTS (
SELECT 1 FROM policies p
JOIN software_installers si ON si.title_id = p.patch_software_title_id AND si.global_or_team_id = p.team_id
WHERE si.id = ?
)`, id)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking if patch policy exists for software installer")
}
if policyExists {
return errDeleteInstallerWithAssociatedPatchPolicy
}
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
affectedHostIDs, err := ds.runInstallerUpdateSideEffectsInTransaction(ctx, tx, id, true, true)
if err != nil {
return ctxerr.Wrap(ctx, err, "clean up related installs and uninstalls")
}
activateAffectedHostIDs = affectedHostIDs
// allow delete only if install_during_setup is false
res, err := tx.ExecContext(ctx, `DELETE FROM software_installers WHERE id = ? AND install_during_setup = 0`, id)
if err != nil {
if isMySQLForeignKey(err) {
// Check if the software installer is referenced by a policy automation.
var count int
if err := sqlx.GetContext(ctx, tx, &count, `SELECT COUNT(*) FROM policies WHERE software_installer_id = ?`, id); err != nil {
return ctxerr.Wrapf(ctx, err, "getting reference from policies")
}
if count > 0 {
return errDeleteInstallerWithAssociatedInstallPolicy
}
}
return ctxerr.Wrap(ctx, err, "delete software installer")
}
rows, _ := res.RowsAffected()
if rows == 0 {
// could be that the software installer does not exist, or it is installed
// during setup, do additional check.
var installDuringSetup bool
if err := sqlx.GetContext(ctx, tx, &installDuringSetup,
`SELECT install_during_setup FROM software_installers WHERE id = ?`, id); err != nil && !errors.Is(err, sql.ErrNoRows) {
return ctxerr.Wrap(ctx, err, "check if software installer is installed during setup")
}
if installDuringSetup {
return errDeleteInstallerInstalledDuringSetup
}
return notFound("SoftwareInstaller").WithID(id)
}
return nil
})
if err != nil {
return err
}
return ds.activateNextUpcomingActivityForBatchOfHosts(ctx, activateAffectedHostIDs)
}
// deletePendingSoftwareInstallsForPolicy should be called after a policy is
// deleted to remove any pending software installs
func (ds *Datastore) deletePendingSoftwareInstallsForPolicy(ctx context.Context, teamID *uint, policyID uint) error {
var globalOrTeamID uint
if teamID != nil {
globalOrTeamID = *teamID
}
// NOTE(mna): I'm adding the deletion for the upcoming_activities too, but I
// don't think the existing code works as intended anyway as the
// host_software_installs.policy_id column has a ON DELETE SET NULL foreign
// key, so the deletion statement will not find any row.
const deleteStmt = `
DELETE FROM
host_software_installs
WHERE
policy_id = ? AND
status = ? AND
software_installer_id IN (
SELECT id FROM software_installers WHERE global_or_team_id = ?
)
`
_, err := ds.writer(ctx).ExecContext(ctx, deleteStmt, policyID, fleet.SoftwareInstallPending, globalOrTeamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete pending software installs for policy")
}
const loadAffectedHostsStmt = `
SELECT
DISTINCT host_id
FROM
upcoming_activities ua
INNER JOIN software_install_upcoming_activities siua
ON ua.id = siua.upcoming_activity_id
WHERE
ua.activity_type = 'software_install' AND
ua.activated_at IS NOT NULL AND
siua.policy_id = ? AND
siua.software_installer_id IN (
SELECT id FROM software_installers WHERE global_or_team_id = ?
)`
var affectedHosts []uint
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &affectedHosts,
loadAffectedHostsStmt, policyID, globalOrTeamID); err != nil {
return ctxerr.Wrap(ctx, err, "load affected hosts for software installs")
}
const deleteUAStmt = `
DELETE FROM
upcoming_activities
USING
upcoming_activities
INNER JOIN software_install_upcoming_activities siua
ON upcoming_activities.id = siua.upcoming_activity_id
WHERE
upcoming_activities.activity_type = 'software_install' AND
siua.policy_id = ? AND
siua.software_installer_id IN (
SELECT id FROM software_installers WHERE global_or_team_id = ?
)
`
_, err = ds.writer(ctx).ExecContext(ctx, deleteUAStmt, policyID, globalOrTeamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete upcoming software installs for policy")
}
return ds.activateNextUpcomingActivityForBatchOfHosts(ctx, affectedHosts)
}
func (ds *Datastore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) {
const (
getInstallerStmt = `
SELECT
filename, "version", title_id, COALESCE(st.name, '[deleted title]') title_name, st.source
FROM
software_installers si
LEFT JOIN software_titles st
ON si.title_id = st.id
WHERE si.id = ?`
insertUAStmt = `
INSERT INTO upcoming_activities
(host_id, priority, user_id, fleet_initiated, activity_type, execution_id, payload)
VALUES
(?, ?, ?, ?, 'software_install', ?,
JSON_OBJECT(
'self_service', ?,
'installer_filename', ?,
'version', ?,
'software_title_name', ?,
'source', ?,
'with_retries', ?,
'user', (SELECT JSON_OBJECT('name', name, 'email', email, 'gravatar_url', gravatar_url) FROM users WHERE id = ?)
)
)`
insertSIUAStmt = `
INSERT INTO software_install_upcoming_activities
(upcoming_activity_id, software_installer_id, policy_id, software_title_id)
VALUES
(?, ?, ?, ?)`
hostExistsStmt = `SELECT 1 FROM hosts WHERE id = ?`
)
// we need to explicitly do this check here because we can't set a FK constraint on the schema
var hostExists bool
err := sqlx.GetContext(ctx, ds.reader(ctx), &hostExists, hostExistsStmt, hostID)
if err != nil {
if err == sql.ErrNoRows {
return "", notFound("Host").WithID(hostID)
}
return "", ctxerr.Wrap(ctx, err, "checking if host exists")
}
var installerDetails struct {
Filename string `db:"filename"`
Version string `db:"version"`
TitleID *uint `db:"title_id"`
TitleName *string `db:"title_name"`
Source *string `db:"source"`
}
if err = sqlx.GetContext(ctx, ds.reader(ctx), &installerDetails, getInstallerStmt, softwareInstallerID); err != nil {
if err == sql.ErrNoRows {
return "", notFound("SoftwareInstaller").WithID(softwareInstallerID)
}
return "", ctxerr.Wrap(ctx, err, "getting installer data")
}
var userID *uint
if opts.UserID != nil {
userID = opts.UserID
} else if ctxUser := authz.UserFromContext(ctx); ctxUser != nil && opts.PolicyID == nil {
userID = &ctxUser.ID
}
execID := uuid.NewString()
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
res, err := tx.ExecContext(ctx, insertUAStmt,
hostID,
opts.Priority(),
userID,
opts.IsFleetInitiated(),
execID,
opts.SelfService,
installerDetails.Filename,
installerDetails.Version,
installerDetails.TitleName,
installerDetails.Source,
opts.WithRetries,
userID,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert software install request")
}
activityID, _ := res.LastInsertId()
_, err = tx.ExecContext(ctx, insertSIUAStmt,
activityID,
softwareInstallerID,
opts.PolicyID,
installerDetails.TitleID,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert software install request join table")
}
if _, err := ds.activateNextUpcomingActivity(ctx, tx, hostID, ""); err != nil {
return ctxerr.Wrap(ctx, err, "activate next activity")
}
return nil
})
return execID, ctxerr.Wrap(ctx, err, "inserting new install software request")
}
func (ds *Datastore) ProcessInstallerUpdateSideEffects(ctx context.Context, installerID uint, wasMetadataUpdated bool, wasPackageUpdated bool) error {
var activateAffectedHostIDs []uint
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
affectedHostIDs, err := ds.runInstallerUpdateSideEffectsInTransaction(ctx, tx, installerID, wasMetadataUpdated, wasPackageUpdated)
if err != nil {
return err
}
activateAffectedHostIDs = affectedHostIDs
return nil
})
if err != nil {
return err
}
return ds.activateNextUpcomingActivityForBatchOfHosts(ctx, activateAffectedHostIDs)
}
func (ds *Datastore) runInstallerUpdateSideEffectsInTransaction(ctx context.Context, tx sqlx.ExtContext, installerID uint, wasMetadataUpdated bool, wasPackageUpdated bool) (affectedHostIDs []uint, err error) {
if wasMetadataUpdated || wasPackageUpdated { // cancel pending installs/uninstalls
// TODO make this less naive; this assumes that installs/uninstalls execute and report back immediately
_, err := tx.ExecContext(ctx, `DELETE FROM host_script_results WHERE execution_id IN (
SELECT execution_id FROM host_software_installs WHERE software_installer_id = ? AND status = 'pending_uninstall'
)`, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "delete pending uninstall scripts")
}
_, err = tx.ExecContext(ctx, `UPDATE setup_experience_status_results SET status=? WHERE status IN (?, ?) AND host_software_installs_execution_id IN (
SELECT execution_id FROM host_software_installs WHERE software_installer_id = ? AND status IN ('pending_install', 'pending_uninstall')
UNION
SELECT ua.execution_id FROM upcoming_activities ua INNER JOIN software_install_upcoming_activities siua ON ua.id = siua.upcoming_activity_id
WHERE siua.software_installer_id = ? AND activity_type IN ('software_install', 'software_uninstall')
)`, fleet.SetupExperienceStatusCancelled, fleet.SetupExperienceStatusPending, fleet.SetupExperienceStatusRunning, installerID, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "fail setup experience results dependant on deleted software install")
}
_, err = tx.ExecContext(ctx, `DELETE FROM host_software_installs
WHERE software_installer_id = ? AND status IN('pending_install', 'pending_uninstall')`, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "delete pending host software installs/uninstalls")
}
if err := sqlx.SelectContext(ctx, tx, &affectedHostIDs, `SELECT
DISTINCT host_id
FROM
upcoming_activities ua
INNER JOIN software_install_upcoming_activities siua
ON ua.id = siua.upcoming_activity_id
WHERE
siua.software_installer_id = ? AND
ua.activated_at IS NOT NULL AND
ua.activity_type IN ('software_install', 'software_uninstall')`, installerID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "select affected host IDs for software installs/uninstalls")
}
_, err = tx.ExecContext(ctx, `DELETE FROM upcoming_activities
USING
upcoming_activities
INNER JOIN software_install_upcoming_activities siua
ON upcoming_activities.id = siua.upcoming_activity_id
WHERE siua.software_installer_id = ? AND activity_type IN ('software_install', 'software_uninstall')`, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "delete upcoming host software installs/uninstalls")
}
}
if wasPackageUpdated { // hide existing install counts
_, err := tx.ExecContext(ctx, `UPDATE host_software_installs SET removed = TRUE
WHERE software_installer_id = ? AND status IS NOT NULL AND host_deleted_at IS NULL`, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "hide existing install counts")
}
_, err = tx.ExecContext(ctx, `DELETE FROM software_title_display_names WHERE (software_title_id, team_id) IN
(SELECT title_id, global_or_team_id FROM software_installers WHERE id = ?)`, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "delete software title display name that matches installer")
}
}
return affectedHostIDs, nil
}
func (ds *Datastore) InsertSoftwareUninstallRequest(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint, selfService bool) error {
const (
getInstallerStmt = `SELECT title_id, COALESCE(st.name, '[deleted title]') title_name, st.source
FROM software_installers si LEFT JOIN software_titles st ON si.title_id = st.id WHERE si.id = ?`
insertUAStmt = `
INSERT INTO upcoming_activities
(host_id, priority, user_id, fleet_initiated, activity_type, execution_id, payload)
VALUES
(?, ?, ?, ?, 'software_uninstall', ?,
JSON_OBJECT(
'installer_filename', '',
'version', 'unknown',
'software_title_name', ?,
'source', ?,
'user', (SELECT JSON_OBJECT('name', name, 'email', email, 'gravatar_url', gravatar_url) FROM users WHERE id = ?),
'self_service', ?
)
)`
insertSIUAStmt = `
INSERT INTO software_install_upcoming_activities
(upcoming_activity_id, software_installer_id, software_title_id)
VALUES
(?, ?, ?)`
hostExistsStmt = `SELECT 1 FROM hosts WHERE id = ?`
)
// we need to explicitly do this check here because we can't set a FK constraint on the schema
var hostExists bool
err := sqlx.GetContext(ctx, ds.reader(ctx), &hostExists, hostExistsStmt, hostID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return notFound("Host").WithID(hostID)
}
return ctxerr.Wrap(ctx, err, "checking if host exists")
}
var installerDetails struct {
TitleID *uint `db:"title_id"`
TitleName *string `db:"title_name"`
Source *string `db:"source"`
}
if err = sqlx.GetContext(ctx, ds.reader(ctx), &installerDetails, getInstallerStmt, softwareInstallerID); err != nil {
if err == sql.ErrNoRows {
return notFound("SoftwareInstaller").WithID(softwareInstallerID)
}
return ctxerr.Wrap(ctx, err, "getting installer data")
}
var userID *uint
if ctxUser := authz.UserFromContext(ctx); ctxUser != nil {
userID = &ctxUser.ID
}
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
res, err := tx.ExecContext(ctx, insertUAStmt,
hostID,
0, // Uninstalls are never used in setup experience, so always default priority
userID,
false,
executionID,
installerDetails.TitleName,
installerDetails.Source,
userID,
selfService,
)
if err != nil {
return err
}
activityID, _ := res.LastInsertId()
_, err = tx.ExecContext(ctx, insertSIUAStmt,
activityID,
softwareInstallerID,
installerDetails.TitleID,
)
if err != nil {
return err
}
if _, err := ds.activateNextUpcomingActivity(ctx, tx, hostID, ""); err != nil {
return ctxerr.Wrap(ctx, err, "activate next activity")
}
return nil
})
return ctxerr.Wrap(ctx, err, "inserting new uninstall software request")
}
func (ds *Datastore) GetSoftwareInstallResults(ctx context.Context, resultsUUID string) (*fleet.HostSoftwareInstallerResult, error) {
query := `
SELECT
hsi.execution_id AS execution_id,
hsi.pre_install_query_output,
hsi.post_install_script_output,
hsi.install_script_output,
hsi.host_id AS host_id,
COALESCE(st.name, hsi.software_title_name) AS software_title,
hsi.software_title_id,
hsi.software_installer_id,
COALESCE(hsi.execution_status, '') AS status,
hsi.installer_filename AS software_package,
hsi.user_id AS user_id,
hsi.post_install_script_exit_code,
hsi.install_script_exit_code,
hsi.self_service,
hsi.host_deleted_at,
hsi.policy_id,
hsi.created_at as created_at,
hsi.updated_at as updated_at,
st.source,
hsi.attempt_number
FROM
host_software_installs hsi
LEFT JOIN software_titles st ON hsi.software_title_id = st.id
WHERE
hsi.execution_id = :execution_id AND
hsi.uninstall = 0 AND
hsi.canceled = 0
UNION
SELECT
ua.execution_id AS execution_id,
NULL AS pre_install_query_output,
NULL AS post_install_script_output,
NULL AS install_script_output,
ua.host_id AS host_id,
COALESCE(st.name, ua.payload->>'$.software_title_name') AS software_title,
siua.software_title_id,
siua.software_installer_id,
'pending_install' AS status,
ua.payload->>'$.installer_filename' AS software_package,
ua.user_id AS user_id,
NULL AS post_install_script_exit_code,
NULL AS install_script_exit_code,
ua.payload->'$.self_service' AS self_service,
NULL AS host_deleted_at,
siua.policy_id AS policy_id,
ua.created_at as created_at,
ua.updated_at as updated_at,
st.source,
NULL AS attempt_number
FROM
upcoming_activities ua
INNER JOIN software_install_upcoming_activities siua
ON ua.id = siua.upcoming_activity_id
LEFT JOIN software_titles st
ON siua.software_title_id = st.id
WHERE
ua.execution_id = :execution_id AND
ua.activity_type = 'software_install' AND
ua.activated_at IS NULL -- if already activated, covered by the other SELECT
`
stmt, args, err := sqlx.Named(query, map[string]any{
"execution_id": resultsUUID,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build named query for get software install results")
}
var dest fleet.HostSoftwareInstallerResult
err = sqlx.GetContext(ctx, ds.reader(ctx), &dest, stmt, args...)
if err != nil {
if err == sql.ErrNoRows {
return nil, ctxerr.Wrap(ctx, notFound("HostSoftwareInstallerResult"), "get host software installer results")
}
return nil, ctxerr.Wrap(ctx, err, "get host software installer results")
}
return &dest, nil
}
func (ds *Datastore) GetSummaryHostSoftwareInstalls(ctx context.Context, installerID uint) (*fleet.SoftwareInstallerStatusSummary, error) {
var dest fleet.SoftwareInstallerStatusSummary
stmt := `WITH
-- select most recent upcoming activities for each host
upcoming AS (
SELECT
ua.host_id,
IF(ua.activity_type = 'software_install', :software_status_pending_install, :software_status_pending_uninstall) AS status
FROM
upcoming_activities ua
JOIN software_install_upcoming_activities siua ON ua.id = siua.upcoming_activity_id
JOIN hosts h ON host_id = h.id
LEFT JOIN (
upcoming_activities ua2
INNER JOIN software_install_upcoming_activities siua2
ON ua2.id = siua2.upcoming_activity_id
) ON ua.host_id = ua2.host_id AND
siua.software_installer_id = siua2.software_installer_id AND
ua.activity_type = ua2.activity_type AND
(ua2.priority < ua.priority OR ua2.created_at > ua.created_at)
WHERE
ua.activity_type IN('software_install', 'software_uninstall')
AND ua2.id IS NULL
AND siua.software_installer_id = :installer_id
),
-- select most recent past activities for each host
past AS (
SELECT
hsi.host_id,
hsi.status
FROM
host_software_installs hsi
JOIN hosts h ON host_id = h.id
LEFT JOIN host_software_installs hsi2
ON hsi.host_id = hsi2.host_id AND
hsi.software_installer_id = hsi2.software_installer_id AND
hsi2.removed = 0 AND
hsi2.canceled = 0 AND
hsi2.host_deleted_at IS NULL AND
(hsi.created_at < hsi2.created_at OR (hsi.created_at = hsi2.created_at AND hsi.id < hsi2.id))
WHERE
hsi2.id IS NULL
AND hsi.software_installer_id = :installer_id
AND hsi.host_id NOT IN(SELECT host_id FROM upcoming) -- antijoin to exclude hosts with upcoming activities
AND hsi.host_deleted_at IS NULL
AND hsi.removed = 0
AND hsi.canceled = 0
)
-- count each status
SELECT
COALESCE(SUM( IF(status = :software_status_pending_install, 1, 0)), 0) AS pending_install,
COALESCE(SUM( IF(status = :software_status_failed_install, 1, 0)), 0) AS failed_install,
COALESCE(SUM( IF(status = :software_status_pending_uninstall, 1, 0)), 0) AS pending_uninstall,
COALESCE(SUM( IF(status = :software_status_failed_uninstall, 1, 0)), 0) AS failed_uninstall,
COALESCE(SUM( IF(status = :software_status_installed, 1, 0)), 0) AS installed
FROM (
-- union most recent past and upcoming activities after joining to get statuses for most recent activities
SELECT
past.host_id,
past.status
FROM past
UNION
SELECT
upcoming.host_id,
upcoming.status
FROM upcoming
) t`
query, args, err := sqlx.Named(stmt, map[string]interface{}{
"installer_id": installerID,
"software_status_pending_install": fleet.SoftwareInstallPending,
"software_status_failed_install": fleet.SoftwareInstallFailed,
"software_status_pending_uninstall": fleet.SoftwareUninstallPending,
"software_status_failed_uninstall": fleet.SoftwareUninstallFailed,
"software_status_installed": fleet.SoftwareInstalled,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get summary host software installs: named query")
}
err = sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, args...)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get summary host software install status")
}
return &dest, nil
}
func (ds *Datastore) vppAppJoin(appID fleet.VPPAppID, status fleet.SoftwareInstallerStatus) (string, []interface{}, error) {
// for pending status, we'll join through upcoming_activities
// EXCEPT for android VPP apps, which currently bypass upcoming activities
// NOTE: this should change when standard VPP app installs are supported for Android
// (in https://github.com/fleetdm/fleet/issues/25595)
if status == fleet.SoftwarePending || status == fleet.SoftwareInstallPending || status == fleet.SoftwareUninstallPending {
stmt := `JOIN (
SELECT DISTINCT
host_id
FROM (
SELECT host_id
FROM upcoming_activities ua
JOIN vpp_app_upcoming_activities vppua ON ua.id = vppua.upcoming_activity_id
WHERE
%s
UNION
SELECT host_id
FROM host_vpp_software_installs hvsi
WHERE
hvsi.adam_id = ? AND
hvsi.platform = ? AND
hvsi.platform = 'android' AND
hvsi.verification_at IS NULL AND
hvsi.verification_failed_at IS NULL
) combined_pending
) hss ON hss.host_id = h.id`
filter := "vppua.adam_id = ? AND vppua.platform = ?"
switch status {
case fleet.SoftwareInstallPending:
filter += " AND ua.activity_type = 'vpp_app_install'"
case fleet.SoftwareUninstallPending:
// TODO: Update this when VPP supports uninstall, for now we map uninstall to install to preserve existing behavior of VPP filters
filter += " AND ua.activity_type = 'vpp_app_install'"
default:
// no change, we're just filtering by app_id and platform so it will pick up any
// activity type that is associated with the app (i.e. both install and uninstall)
}
return fmt.Sprintf(stmt, filter), []any{appID.AdamID, appID.Platform, appID.AdamID, appID.Platform}, nil
}
// TODO: Update this when VPP supports uninstall so that we map for now we map the generic failed status to the install statuses
if status == fleet.SoftwareFailed {
status = fleet.SoftwareInstallFailed // TODO: When VPP supports uninstall this should become STATUS IN ('failed_install', 'failed_uninstall')
}
// NOTE(mna): the pre-unified queue version of this query did not check for
// removed = 0, so I am porting the same behavior (there's even a test that
// fails if I add removed = 0 condition).
stmt := fmt.Sprintf(`JOIN (
SELECT
hvsi.host_id
FROM
host_vpp_software_installs hvsi
LEFT JOIN
nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid
LEFT JOIN host_vpp_software_installs hvsi2
ON hvsi.host_id = hvsi2.host_id AND
hvsi.adam_id = hvsi2.adam_id AND
hvsi.platform = hvsi2.platform AND
hvsi2.canceled = 0 AND
(hvsi.created_at < hvsi2.created_at OR (hvsi.created_at = hvsi2.created_at AND hvsi.id < hvsi2.id))
WHERE
hvsi2.id IS NULL
AND hvsi.adam_id = :adam_id
AND hvsi.platform = :platform
AND hvsi.canceled = 0
AND (ncr.id IS NOT NULL OR (:platform = 'android' AND ncr.id IS NULL))
AND (%s) = :status
AND NOT EXISTS (
SELECT 1
FROM
upcoming_activities ua
JOIN vpp_app_upcoming_activities vaua ON ua.id = vaua.upcoming_activity_id
WHERE
ua.host_id = hvsi.host_id
AND vaua.adam_id = hvsi.adam_id
AND vaua.platform = hvsi.platform
AND ua.activity_type = 'vpp_app_install'
)
) hss ON hss.host_id = h.id
`, vppAppHostStatusNamedQuery("hvsi", "ncr", ""))
return sqlx.Named(stmt, map[string]interface{}{
"status": status,
"adam_id": appID.AdamID,
"platform": appID.Platform,
"software_status_installed": fleet.SoftwareInstalled,
"software_status_failed": fleet.SoftwareInstallFailed,
"software_status_pending": fleet.SoftwareInstallPending,
"mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged,
"mdm_status_error": fleet.MDMAppleStatusError,
"mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError,
})
}
func (ds *Datastore) softwareInstallerJoin(titleID uint, status fleet.SoftwareInstallerStatus) (string, []interface{}, error) {
// for pending status, we'll join through upcoming_activities
if status == fleet.SoftwarePending || status == fleet.SoftwareInstallPending || status == fleet.SoftwareUninstallPending {
stmt := `JOIN (
SELECT DISTINCT
host_id
FROM
upcoming_activities ua
JOIN software_install_upcoming_activities siua ON ua.id = siua.upcoming_activity_id
WHERE
%s) hss ON hss.host_id = h.id`
filter := "siua.software_title_id = ?"
switch status {
case fleet.SoftwareInstallPending:
filter += " AND ua.activity_type = 'software_install'"
case fleet.SoftwareUninstallPending:
filter += " AND ua.activity_type = 'software_uninstall'"
default:
// no change
}
return fmt.Sprintf(stmt, filter), []interface{}{titleID}, nil
}
// for non-pending statuses, we'll join through host_software_installs filtered by the status
statusFilter := "hsi.status = :status"
if status == fleet.SoftwareFailed {
// failed is a special case, we must include both install and uninstall failures
statusFilter = "hsi.status IN (:installFailed, :uninstallFailed)"
}
stmt := fmt.Sprintf(`JOIN (
SELECT
hsi.host_id
FROM
host_software_installs hsi
LEFT JOIN host_software_installs hsi2
ON hsi.host_id = hsi2.host_id AND
hsi.software_title_id = hsi2.software_title_id AND
hsi2.removed = 0 AND
hsi2.canceled = 0 AND
(hsi.created_at < hsi2.created_at OR (hsi.created_at = hsi2.created_at AND hsi.id < hsi2.id))
WHERE
hsi2.id IS NULL
AND hsi.software_title_id = :title_id
AND hsi.removed = 0
AND hsi.canceled = 0
AND %s
AND NOT EXISTS (
SELECT 1
FROM
upcoming_activities ua
JOIN software_install_upcoming_activities siua ON ua.id = siua.upcoming_activity_id
WHERE
ua.host_id = hsi.host_id
AND siua.software_title_id = hsi.software_title_id
AND ua.activity_type = 'software_install'
)
) hss ON hss.host_id = h.id
`, statusFilter)
return sqlx.Named(stmt, map[string]interface{}{
"status": status,
"installFailed": fleet.SoftwareInstallFailed,
"uninstallFailed": fleet.SoftwareUninstallFailed,
"title_id": titleID,
})
}
func (ds *Datastore) inHouseAppJoin(inHouseID uint, status fleet.SoftwareInstallerStatus) (string, []any, error) {
// for pending status, we'll join through upcoming_activities
if status == fleet.SoftwarePending || status == fleet.SoftwareInstallPending || status == fleet.SoftwareUninstallPending {
stmt := `JOIN (
SELECT DISTINCT
host_id
FROM
upcoming_activities ua
JOIN in_house_app_upcoming_activities ihua ON ua.id = ihua.upcoming_activity_id
WHERE
%s) hss ON hss.host_id = h.id`
filter := "ihua.in_house_app_id = ?"
switch status {
case fleet.SoftwareInstallPending:
filter += " AND ua.activity_type = 'in_house_app_install'"
case fleet.SoftwareUninstallPending:
// TODO: Update this when in-house supports uninstall, for now we map
// uninstall to install to preserve existing behavior of VPP filters
filter += " AND ua.activity_type = 'in_house_app_install'"
default:
// no change, we're just filtering by title id so it will pick up any
// activity type that is associated with the app (i.e. both install and
// uninstall)
}
return fmt.Sprintf(stmt, filter), []any{inHouseID}, nil
}
// TODO: Update this when in-house app supports uninstall for now we map the
// generic failed status to the install status
if status == fleet.SoftwareFailed {
status = fleet.SoftwareInstallFailed // TODO: When in-house supports uninstall this should become STATUS IN ('failed_install', 'failed_uninstall')
}
stmt := fmt.Sprintf(`JOIN (
SELECT
hihsi.host_id
FROM
host_in_house_software_installs hihsi
INNER JOIN
nano_command_results ncr ON ncr.command_uuid = hihsi.command_uuid
LEFT JOIN host_in_house_software_installs hihsi2
ON hihsi.host_id = hihsi2.host_id AND
hihsi.in_house_app_id = hihsi2.in_house_app_id AND
hihsi2.canceled = 0 AND
hihsi2.removed = 0 AND
(hihsi.created_at < hihsi2.created_at OR (hihsi.created_at = hihsi2.created_at AND hihsi.id < hihsi2.id))
WHERE
hihsi2.id IS NULL
AND hihsi.in_house_app_id = :in_house_app_id
AND hihsi.canceled = 0
AND hihsi.removed = 0
AND (%s) = :status
AND NOT EXISTS (
SELECT 1
FROM
upcoming_activities ua
JOIN in_house_app_upcoming_activities ihua ON ua.id = ihua.upcoming_activity_id
WHERE
ua.host_id = hihsi.host_id
AND ihua.in_house_app_id = hihsi.in_house_app_id
AND ua.activity_type = 'in_house_app_install'
)
) hss ON hss.host_id = h.id
`, inHouseAppHostStatusNamedQuery("hihsi", "ncr", ""))
return sqlx.Named(stmt, map[string]any{
"status": status,
"in_house_app_id": inHouseID,
"software_status_installed": fleet.SoftwareInstalled,
"software_status_failed": fleet.SoftwareInstallFailed,
"software_status_pending": fleet.SoftwareInstallPending,
"mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged,
"mdm_status_error": fleet.MDMAppleStatusError,
"mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError,
})
}
func (ds *Datastore) GetHostLastInstallData(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) {
hostLastInstall, err := ds.getLatestUpcomingInstall(ctx, hostID, installerID)
if err != nil && errors.Is(err, sql.ErrNoRows) {
hostLastInstall, err = ds.getLatestPastInstall(ctx, hostID, installerID)
}
if err != nil && errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return hostLastInstall, err
}
func (ds *Datastore) getLatestUpcomingInstall(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) {
var hostLastInstall fleet.HostLastInstallData
stmt := `
SELECT
execution_id,
'pending_install' AS status
FROM
upcoming_activities
WHERE
id = (
SELECT
MAX(ua.id)
FROM
upcoming_activities ua
JOIN
software_install_upcoming_activities siua ON ua.id = siua.upcoming_activity_id
WHERE
ua.activity_type = 'software_install' AND ua.host_id = ? AND siua.software_installer_id = ?)`
if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostLastInstall, stmt, hostID, installerID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get latest upcoming install")
}
return &hostLastInstall, nil
}
func (ds *Datastore) getLatestPastInstall(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) {
var hostLastInstall fleet.HostLastInstallData
stmt := `
SELECT
execution_id,
status
FROM
host_software_installs
WHERE
id = (
SELECT
MAX(hsi.id)
FROM
host_software_installs hsi
WHERE
hsi.host_id = ? AND hsi.software_installer_id = ? AND hsi.canceled = 0)`
if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostLastInstall, stmt, hostID, installerID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get latest past install")
}
return &hostLastInstall, nil
}
func (ds *Datastore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwareInstallStore fleet.SoftwareInstallerStore, removeCreatedBefore time.Time) error {
if softwareInstallStore == nil {
// no-op in this case, possible if not running with a Premium license
return nil
}
// get the list of software installers hashes that are in use
var storageIDs []string
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &storageIDs, `
SELECT storage_id FROM software_installers
UNION
SELECT storage_id FROM in_house_apps`,
); err != nil {
return ctxerr.Wrap(ctx, err, "get list of software installers in use")
}
// Add in house apps to software installers in use
_, err := softwareInstallStore.Cleanup(ctx, storageIDs, removeCreatedBefore)
return ctxerr.Wrap(ctx, err, "cleanup unused software installers")
}
const maxCachedFMAVersions = 2
func (ds *Datastore) BatchSetSoftwareInstallers(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
const upsertSoftwareTitles = `
INSERT INTO software_titles
(name, source, extension_for, bundle_identifier, upgrade_code)
VALUES
%s
ON DUPLICATE KEY UPDATE
name = VALUES(name),
source = VALUES(source),
extension_for = VALUES(extension_for),
bundle_identifier = VALUES(bundle_identifier)
`
const loadSoftwareTitles = `
SELECT
id
FROM
software_titles
WHERE (unique_identifier, source) IN (%s) AND extension_for = ''
`
const getSoftwareTitle = `
SELECT
id
FROM
software_titles
WHERE
unique_identifier = ? AND source = ? AND extension_for = ''
`
const unsetAllInstallersFromPolicies = `
UPDATE
policies
SET
software_installer_id = NULL
WHERE
team_id = ?
`
const deleteAllPendingUninstallScriptExecutions = `
DELETE FROM host_script_results WHERE execution_id IN (
SELECT execution_id FROM host_software_installs WHERE status = 'pending_uninstall'
AND software_installer_id IN (
SELECT id FROM software_installers WHERE global_or_team_id = ?
)
)
`
const cancelSetupExperienceStatusForAllDeletedPendingSoftwareInstalls = `
UPDATE setup_experience_status_results SET status=? WHERE status IN (?, ?) AND host_software_installs_execution_id IN (
SELECT execution_id FROM host_software_installs hsi INNER JOIN software_installers si ON hsi.software_installer_id=si.id
WHERE hsi.status IN ('pending_install', 'pending_uninstall') AND si.global_or_team_id = ?
UNION
SELECT ua.execution_id FROM upcoming_activities ua INNER JOIN software_install_upcoming_activities siua ON ua.id = siua.upcoming_activity_id
INNER JOIN software_installers si ON siua.software_installer_id=si.id
WHERE ua.activity_type IN ('software_install', 'software_uninstall') AND si.global_or_team_id = ?
)
`
const deleteAllPendingSoftwareInstallsHSI = `
DELETE FROM host_software_installs
WHERE status IN('pending_install', 'pending_uninstall')
AND software_installer_id IN (
SELECT id FROM software_installers WHERE global_or_team_id = ?
)
`
const loadAffectedHostsPendingSoftwareInstallsUA = `
SELECT
DISTINCT host_id
FROM
upcoming_activities ua
INNER JOIN software_install_upcoming_activities siua
ON ua.id = siua.upcoming_activity_id
WHERE
ua.activity_type IN ('software_install', 'software_uninstall') AND
ua.activated_at IS NOT NULL AND
siua.software_installer_id IN (
SELECT id FROM software_installers WHERE global_or_team_id = ?
)
`
const deleteAllPendingSoftwareInstallsUA = `
DELETE FROM upcoming_activities
USING upcoming_activities
INNER JOIN software_install_upcoming_activities siua
ON upcoming_activities.id = siua.upcoming_activity_id
WHERE
activity_type IN ('software_install', 'software_uninstall') AND
siua.software_installer_id IN (
SELECT id FROM software_installers WHERE global_or_team_id = ?
)
`
const markAllSoftwareInstallsAsRemoved = `
UPDATE host_software_installs SET removed = TRUE
WHERE status IS NOT NULL AND host_deleted_at IS NULL
AND software_installer_id IN (
SELECT id FROM software_installers WHERE global_or_team_id = ?
)
`
const deleteAllInstallersInTeam = `
DELETE FROM
software_installers
WHERE
global_or_team_id = ?
`
const deletePendingUninstallScriptExecutionsNotInList = `
DELETE FROM host_script_results WHERE execution_id IN (
SELECT execution_id FROM host_software_installs WHERE status = 'pending_uninstall'
AND software_installer_id IN (
SELECT id FROM software_installers WHERE global_or_team_id = ? AND title_id NOT IN (?)
)
)
`
const cancelSetupExperienceStatusForDeletedSoftwareInstalls = `
UPDATE setup_experience_status_results SET status=? WHERE status IN (?, ?) AND host_software_installs_execution_id IN (
SELECT execution_id FROM host_software_installs hsi INNER JOIN software_installers si ON hsi.software_installer_id=si.id
WHERE hsi.status IN ('pending_install', 'pending_uninstall') AND si.global_or_team_id = ? AND si.title_id NOT IN (?)
UNION
SELECT ua.execution_id FROM upcoming_activities ua INNER JOIN software_install_upcoming_activities siua ON ua.id = siua.upcoming_activity_id
INNER JOIN software_installers si ON siua.software_installer_id=si.id
WHERE ua.activity_type IN ('software_install', 'software_uninstall') AND si.global_or_team_id = ? AND si.title_id NOT IN (?)
)
`
const deletePendingSoftwareInstallsNotInListHSI = `
DELETE FROM host_software_installs
WHERE status IN('pending_install', 'pending_uninstall')
AND software_installer_id IN (
SELECT id FROM software_installers WHERE global_or_team_id = ? AND title_id NOT IN (?)
)
`
const loadAffectedHostsPendingSoftwareInstallsNotInListUA = `
SELECT
DISTINCT host_id
FROM
upcoming_activities ua
INNER JOIN software_install_upcoming_activities siua
ON ua.id = siua.upcoming_activity_id
WHERE
ua.activity_type IN ('software_install', 'software_uninstall') AND
ua.activated_at IS NOT NULL AND
siua.software_installer_id IN (
SELECT id FROM software_installers WHERE global_or_team_id = ? AND title_id NOT IN (?)
)
`
const deletePendingSoftwareInstallsNotInListUA = `
DELETE FROM upcoming_activities
USING upcoming_activities
INNER JOIN software_install_upcoming_activities siua
ON upcoming_activities.id = siua.upcoming_activity_id
WHERE
activity_type IN ('software_install', 'software_uninstall') AND
siua.software_installer_id IN (
SELECT id FROM software_installers WHERE global_or_team_id = ? AND title_id NOT IN (?)
)
`
const markSoftwareInstallsNotInListAsRemoved = `
UPDATE host_software_installs SET removed = TRUE
WHERE status IS NOT NULL AND host_deleted_at IS NULL
AND software_installer_id IN (
SELECT id FROM software_installers WHERE global_or_team_id = ? AND title_id NOT IN (?)
)
`
const unsetInstallersNotInListFromPolicies = `
UPDATE
policies
SET
software_installer_id = NULL
WHERE
software_installer_id IN (
SELECT id FROM software_installers
WHERE global_or_team_id = ? AND
title_id NOT IN (?)
)
`
const countInstallDuringSetupNotInList = `
SELECT
COUNT(*)
FROM
software_installers
WHERE
global_or_team_id = ? AND
title_id NOT IN (?) AND
install_during_setup = 1
`
const deleteInstallersNotInList = `
DELETE FROM
software_installers
WHERE
global_or_team_id = ? AND
title_id NOT IN (?)
`
const checkExistingInstaller = `
SELECT
id,
storage_id != ? is_package_modified,
install_script_content_id != ? OR uninstall_script_content_id != ? OR pre_install_query != ? OR
COALESCE(post_install_script_content_id != ? OR
(post_install_script_content_id IS NULL AND ? IS NOT NULL) OR
(? IS NULL AND post_install_script_content_id IS NOT NULL)
, FALSE) is_metadata_modified
FROM
software_installers
WHERE
global_or_team_id = ? AND
title_id = ?
`
const insertNewOrEditedInstaller = `
INSERT INTO software_installers (
team_id,
global_or_team_id,
storage_id,
filename,
extension,
version,
install_script_content_id,
uninstall_script_content_id,
pre_install_query,
post_install_script_content_id,
platform,
self_service,
upgrade_code,
title_id,
user_id,
user_name,
user_email,
url,
package_ids,
install_during_setup,
fleet_maintained_app_id,
is_active,
patch_query
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
(SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ?, COALESCE(?, false), ?, ?, ?
)
ON DUPLICATE KEY UPDATE
install_script_content_id = VALUES(install_script_content_id),
uninstall_script_content_id = VALUES(uninstall_script_content_id),
post_install_script_content_id = VALUES(post_install_script_content_id),
storage_id = VALUES(storage_id),
filename = VALUES(filename),
extension = VALUES(extension),
version = VALUES(version),
pre_install_query = VALUES(pre_install_query),
platform = VALUES(platform),
self_service = VALUES(self_service),
upgrade_code = VALUES(upgrade_code),
user_id = VALUES(user_id),
user_name = VALUES(user_name),
user_email = VALUES(user_email),
url = VALUES(url),
install_during_setup = COALESCE(?, install_during_setup),
is_active = VALUES(is_active),
patch_query = VALUES(patch_query)
`
const updateInstaller = `
UPDATE
software_installers
SET
install_during_setup = COALESCE(?, install_during_setup),
self_service = ?,
install_script_content_id = ?,
uninstall_script_content_id = ?,
post_install_script_content_id = ?,
pre_install_query = ?,
patch_query = ?
WHERE id = ?
`
const loadSoftwareInstallerID = `
SELECT
id
FROM
software_installers
WHERE
global_or_team_id = ? AND
title_id = ?
ORDER BY uploaded_at DESC, id DESC
LIMIT 1
`
const deleteInstallerLabelsNotInList = `
DELETE FROM
software_installer_labels
WHERE
software_installer_id = ? AND
label_id NOT IN (?)
`
const deleteAllInstallerLabels = `
DELETE FROM
software_installer_labels
WHERE
software_installer_id = ?
`
const upsertInstallerLabels = `
INSERT INTO
software_installer_labels (
software_installer_id,
label_id,
exclude,
require_all
)
VALUES
%s
ON DUPLICATE KEY UPDATE
exclude = VALUES(exclude),
require_all = VALUES(require_all)
`
const loadExistingInstallerLabels = `
SELECT
label_id,
exclude,
require_all
FROM
software_installer_labels
WHERE
software_installer_id = ?
`
const deleteAllInstallerCategories = `
DELETE FROM
software_installer_software_categories
WHERE
software_installer_id = ?
`
const deleteInstallerCategoriesNotInList = `
DELETE FROM
software_installer_software_categories
WHERE
software_installer_id = ? AND
software_category_id NOT IN (?)
`
const upsertInstallerCategories = `
INSERT IGNORE INTO
software_installer_software_categories (
software_installer_id,
software_category_id
)
VALUES
%s
`
const getDisplayNamesForTeam = `
SELECT
stdn.software_title_id, stdn.display_name
FROM
software_title_display_names stdn
INNER JOIN
software_installers si ON stdn.software_title_id = si.title_id AND stdn.team_id = si.global_or_team_id
WHERE
stdn.team_id = ?
`
const deleteDisplayNamesNotInList = `
DELETE
stdn
FROM
software_title_display_names stdn
INNER JOIN
software_installers si ON stdn.software_title_id = si.title_id AND stdn.team_id = si.global_or_team_id
WHERE
stdn.team_id = ? AND stdn.software_title_id NOT IN (?)
`
// use a team id of 0 if no-team
var globalOrTeamID uint
teamName := fleet.TeamNameNoTeam
if tmID != nil {
globalOrTeamID = *tmID
tm, err := ds.TeamLite(ctx, *tmID)
if err != nil {
return ctxerr.Wrap(ctx, err, "fetch team for batch set software installers")
}
teamName = tm.Name
}
// if we're batch-setting installers and replacing the ones installed during
// setup in the same go, no need to validate that we don't delete one marked
// as install during setup (since we're overwriting those). This is always
// called from fleetctl gitops, so it should always be the case anyway.
var replacingInstallDuringSetup bool
if len(installers) == 0 || installers[0].InstallDuringSetup != nil {
replacingInstallDuringSetup = true
}
var activateAffectedHostIDs []uint
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// if no installers are provided, just delete whatever was in
// the table
if len(installers) == 0 {
if _, err := tx.ExecContext(ctx, unsetAllInstallersFromPolicies, globalOrTeamID); err != nil {
return ctxerr.Wrap(ctx, err, "unset all obsolete installers in policies")
}
if _, err := tx.ExecContext(ctx, deleteAllPendingUninstallScriptExecutions, globalOrTeamID); err != nil {
return ctxerr.Wrap(ctx, err, "delete all pending uninstall script executions")
}
if _, err := tx.ExecContext(ctx, cancelSetupExperienceStatusForAllDeletedPendingSoftwareInstalls, fleet.SetupExperienceStatusCancelled, fleet.SetupExperienceStatusPending, fleet.SetupExperienceStatusRunning,
globalOrTeamID, globalOrTeamID); err != nil {
return ctxerr.Wrap(ctx, err, "cancel pending setup experience software installs")
}
if _, err := tx.ExecContext(ctx, deleteAllPendingSoftwareInstallsHSI, globalOrTeamID); err != nil {
return ctxerr.Wrap(ctx, err, "delete all pending host software install records")
}
var affectedHostIDs []uint
if err := sqlx.SelectContext(ctx, tx, &affectedHostIDs,
loadAffectedHostsPendingSoftwareInstallsUA, globalOrTeamID); err != nil {
return ctxerr.Wrap(ctx, err, "load affected hosts for upcoming software installs")
}
activateAffectedHostIDs = affectedHostIDs
if _, err := tx.ExecContext(ctx, deleteAllPendingSoftwareInstallsUA, globalOrTeamID); err != nil {
return ctxerr.Wrap(ctx, err, "delete all upcoming pending host software install records")
}
if _, err := tx.ExecContext(ctx, markAllSoftwareInstallsAsRemoved, globalOrTeamID); err != nil {
return ctxerr.Wrap(ctx, err, "mark all host software installs as removed")
}
// reuse query to delete all display names associated with installers, by providing just 0
// which should make the WHERE clause equivalent to WHERE stdn.team_id = ? AND TRUE
if _, err := tx.ExecContext(ctx, deleteDisplayNamesNotInList, globalOrTeamID, 0); err != nil {
return ctxerr.Wrap(ctx, err, "delete all display names associated with software installers")
}
if _, err := tx.ExecContext(ctx, deleteAllInstallersInTeam, globalOrTeamID); err != nil {
return ctxerr.Wrap(ctx, err, "delete obsolete software installers")
}
return nil
}
var args []any
for _, installer := range installers {
// check for installers that target macOS if any package installer is
// associated with a software title that already has a VPP app for the same
// platform (if that platform is macOS), then this is a conflict.
// See https://github.com/fleetdm/fleet/issues/32082
if installer.Platform == string(fleet.MacOSPlatform) {
exists, err := ds.checkVPPAppExistsForTitleIdentifier(ctx, tx, tmID, installer.Platform, installer.BundleIdentifier, installer.Source, "")
if err != nil {
return ctxerr.Wrap(ctx, err, "check existing VPP app for installer title identifier")
}
if exists {
return ctxerr.Wrap(ctx, fleet.ConflictError{
Message: fmt.Sprintf(fleet.CantAddSoftwareConflictMessage,
installer.Title, teamName),
}, "vpp app conflicts with existing software installer")
}
}
args = append(
args,
installer.Title,
installer.Source,
"",
installer.GetBundleIdentifierForDB(),
installer.GetUpgradeCodeForDB(),
)
}
values := strings.TrimSuffix(
strings.Repeat("(?,?,?,?,?),", len(installers)),
",",
)
if _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertSoftwareTitles, values), args...); err != nil {
return ctxerr.Wrap(ctx, err, "insert new/edited software title")
}
var titleIDs []uint
args = []any{}
for _, installer := range installers {
args = append(args, installer.UniqueIdentifier(), installer.Source)
}
values = strings.TrimSuffix(
strings.Repeat(`(?,?),`, len(installers)),
",",
)
if err := sqlx.SelectContext(ctx, tx, &titleIDs, fmt.Sprintf(loadSoftwareTitles, values), args...); err != nil {
return ctxerr.Wrap(ctx, err, "load existing titles")
}
stmt, args, err := sqlx.In(unsetInstallersNotInListFromPolicies, globalOrTeamID, titleIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to unset obsolete installers from policies")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "unset obsolete software installers from policies")
}
// check if any in the list are install_during_setup, fail if there is one
if !replacingInstallDuringSetup {
stmt, args, err = sqlx.In(countInstallDuringSetupNotInList, globalOrTeamID, titleIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to check installers install_during_setup")
}
var countInstallDuringSetup int
if err := sqlx.GetContext(ctx, tx, &countInstallDuringSetup, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "check installers installed during setup")
}
if countInstallDuringSetup > 0 {
return errDeleteInstallerInstalledDuringSetup
}
}
stmt, args, err = sqlx.In(deletePendingUninstallScriptExecutionsNotInList, globalOrTeamID, titleIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to delete pending uninstall script executions")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "delete obsolete pending uninstall script executions")
}
stmt, args, err = sqlx.In(cancelSetupExperienceStatusForDeletedSoftwareInstalls, fleet.SetupExperienceStatusCancelled, fleet.SetupExperienceStatusPending, fleet.SetupExperienceStatusRunning,
globalOrTeamID, titleIDs, globalOrTeamID, titleIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to cancel pending setup experience software installs")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "cancel pending setup experience software installs for obsolete host software install records")
}
stmt, args, err = sqlx.In(deletePendingSoftwareInstallsNotInListHSI, globalOrTeamID, titleIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to delete pending software installs")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "delete obsolete pending host software install records")
}
stmt, args, err = sqlx.In(loadAffectedHostsPendingSoftwareInstallsNotInListUA, globalOrTeamID, titleIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to load affected hosts for upcoming software installs")
}
var affectedHostIDs []uint
if err := sqlx.SelectContext(ctx, tx, &affectedHostIDs, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "load affected hosts for upcoming software installs")
}
activateAffectedHostIDs = affectedHostIDs
stmt, args, err = sqlx.In(deletePendingSoftwareInstallsNotInListUA, globalOrTeamID, titleIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to delete upcoming pending software installs")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "delete obsolete upcoming pending host software install records")
}
stmt, args, err = sqlx.In(markSoftwareInstallsNotInListAsRemoved, globalOrTeamID, titleIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to mark obsolete host software installs as removed")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "mark obsolete host software installs as removed")
}
stmt, args, err = sqlx.In(deleteDisplayNamesNotInList, globalOrTeamID, titleIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to delete obsolete display names")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "delete obsolete display names")
}
stmt, args, err = sqlx.In(deleteInstallersNotInList, globalOrTeamID, titleIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to delete obsolete installers")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "delete obsolete software installers")
}
// Fill a map of title IDs for this team that have a display name
var titlesWithDisplayNames []struct {
TitleID uint `db:"software_title_id"`
Name string `db:"display_name"`
}
if err := sqlx.SelectContext(ctx, tx, &titlesWithDisplayNames, getDisplayNamesForTeam, globalOrTeamID); err != nil {
return ctxerr.Wrap(ctx, err, "load display names for updating")
}
displayNameIDMap := make(map[uint]string, len(titlesWithDisplayNames))
for _, d := range titlesWithDisplayNames {
displayNameIDMap[d.TitleID] = d.Name
}
for _, installer := range installers {
if installer.ValidatedLabels == nil {
return ctxerr.Errorf(ctx, "labels have not been validated for installer with name %s", installer.Filename)
}
isRes, err := insertScriptContents(ctx, tx, installer.InstallScript)
if err != nil {
return ctxerr.Wrapf(ctx, err, "inserting install script contents for software installer with name %q", installer.Filename)
}
installScriptID, _ := isRes.LastInsertId()
uisRes, err := insertScriptContents(ctx, tx, installer.UninstallScript)
if err != nil {
return ctxerr.Wrapf(ctx, err, "inserting uninstall script contents for software installer with name %q", installer.Filename)
}
uninstallScriptID, _ := uisRes.LastInsertId()
var postInstallScriptID *int64
if installer.PostInstallScript != "" {
pisRes, err := insertScriptContents(ctx, tx, installer.PostInstallScript)
if err != nil {
return ctxerr.Wrapf(ctx, err, "inserting post-install script contents for software installer with name %q", installer.Filename)
}
insertID, _ := pisRes.LastInsertId()
postInstallScriptID = &insertID
}
var titleID uint
err = sqlx.GetContext(ctx, tx, &titleID, getSoftwareTitle, installer.UniqueIdentifier(), installer.Source)
if err != nil {
return ctxerr.Wrapf(ctx, err, "getting software title id for software installer with name %q", installer.Filename)
}
wasUpdatedArgs := []interface{}{
// package update
installer.StorageID,
// metadata update
installScriptID,
uninstallScriptID,
installer.PreInstallQuery,
postInstallScriptID,
postInstallScriptID,
postInstallScriptID,
// WHERE clause
globalOrTeamID,
titleID,
}
// pull existing installer state if it exists so we can diff for side effects post-update
type existingInstallerUpdateCheckResult struct {
InstallerID uint `db:"id"`
IsPackageModified bool `db:"is_package_modified"`
IsMetadataModified bool `db:"is_metadata_modified"`
}
var existing []existingInstallerUpdateCheckResult
err = sqlx.SelectContext(ctx, tx, &existing, checkExistingInstaller, wasUpdatedArgs...)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return ctxerr.Wrapf(ctx, err, "checking for existing installer with name %q", installer.Filename)
}
}
// Non-FMA installers are always active, FMA installers start inactive and are activated later
isActive := 0
if installer.FleetMaintainedAppID == nil {
isActive = 1
}
args := []interface{}{
tmID,
globalOrTeamID,
installer.StorageID,
installer.Filename,
installer.Extension,
installer.Version,
installScriptID,
uninstallScriptID,
installer.PreInstallQuery,
postInstallScriptID,
installer.Platform,
installer.SelfService,
installer.UpgradeCode,
titleID,
installer.UserID,
installer.UserID,
installer.UserID,
installer.URL,
strings.Join(installer.PackageIDs, ","),
installer.InstallDuringSetup,
installer.FleetMaintainedAppID,
isActive,
installer.PatchQuery,
installer.InstallDuringSetup,
}
// For FMA installers, skip the insert if this exact version is already cached
// for this team+title. This prevents duplicate rows from repeated batch sets
// that re-download the same latest version.
var skipInsert bool
var existingID uint
if installer.FleetMaintainedAppID != nil {
err := sqlx.GetContext(ctx, tx, &existingID, `
SELECT id FROM software_installers
WHERE global_or_team_id = ? AND title_id = ? AND fleet_maintained_app_id IS NOT NULL AND version = ?
LIMIT 1
`, globalOrTeamID, titleID, installer.Version)
if err == nil {
skipInsert = true
} else if !errors.Is(err, sql.ErrNoRows) {
return ctxerr.Wrapf(ctx, err, "check existing FMA version %q for %q", installer.Version, installer.Filename)
}
}
if skipInsert {
// some fields still need to be updated
args := []any{
installer.InstallDuringSetup,
installer.SelfService,
installScriptID,
uninstallScriptID,
postInstallScriptID,
installer.PreInstallQuery,
installer.PatchQuery,
existingID,
}
if _, err := tx.ExecContext(ctx, updateInstaller, args...); err != nil {
return ctxerr.Wrapf(ctx, err, "updating existing installer with name %q", installer.Filename)
}
} else {
upsertQuery := insertNewOrEditedInstaller
if len(existing) > 0 && existing[0].IsPackageModified { // update uploaded_at for updated installer package
upsertQuery = fmt.Sprintf("%s, uploaded_at = NOW()", upsertQuery)
}
if _, err := tx.ExecContext(ctx, upsertQuery, args...); err != nil {
return ctxerr.Wrapf(ctx, err, "insert new/edited installer with name %q", installer.Filename)
}
}
// now that the software installer is created/updated, load its installer
// ID (cannot use res.LastInsertID due to the upsert statement, won't
// give the id in case of update)
var installerID uint
if err := sqlx.GetContext(ctx, tx, &installerID, loadSoftwareInstallerID, globalOrTeamID, titleID); err != nil {
return ctxerr.Wrapf(ctx, err, "load id of new/edited installer with name %q", installer.Filename)
}
// For non-FMA (custom) packages, enforce one installer per title per team.
// With the unique constraint on (global_or_team_id, title_id, version),
// a version change inserts a new row instead of replacing — clean up the old one.
if installer.FleetMaintainedAppID == nil {
// Re-point any policies that reference the old installer to the new one
// before deleting, because policies.software_installer_id has a FK
// constraint without ON DELETE CASCADE.
if _, err := tx.ExecContext(ctx, `
UPDATE policies SET software_installer_id = ?
WHERE software_installer_id IN (
SELECT id FROM software_installers
WHERE global_or_team_id = ? AND title_id = ? AND id != ?
)
`, installerID, globalOrTeamID, titleID, installerID); err != nil {
return ctxerr.Wrapf(ctx, err, "re-point policies for old versions of custom installer %q", installer.Filename)
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM software_installers
WHERE global_or_team_id = ? AND title_id = ? AND id != ?
`, globalOrTeamID, titleID, installerID); err != nil {
return ctxerr.Wrapf(ctx, err, "clean up old versions of custom installer %q", installer.Filename)
}
}
// For FMA installers: determine the active version, then evict old versions
// (protecting the active one from eviction).
if installer.FleetMaintainedAppID != nil {
// Determine which installer should be "active" for this FMA+team.
// If RollbackVersion is specified, find the cached installer with that version;
// otherwise default to the newest (just inserted/updated).
activeInstallerID := installerID
if installer.RollbackVersion != "" {
var pinnedID uint
err := sqlx.GetContext(ctx, tx, &pinnedID, `
SELECT id FROM software_installers
WHERE global_or_team_id = ? AND title_id = ? AND fleet_maintained_app_id IS NOT NULL AND version = ?
ORDER BY uploaded_at DESC, id DESC LIMIT 1
`, globalOrTeamID, titleID, installer.RollbackVersion)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: fmt.Sprintf(
"Couldn't edit %q: specified version is not available. Available versions are listed in the Fleet UI under Actions > Edit software.",
installer.Filename,
),
})
}
return ctxerr.Wrapf(ctx, err, "find cached FMA installer version %q for %q", installer.RollbackVersion, installer.Filename)
}
activeInstallerID = pinnedID
}
// Evict old FMA versions beyond the max per title per team.
// Always keep the active installer; fill remaining slots with
// the most recent versions, evict everything else.
fmaVersions, err := ds.getFleetMaintainedVersionsByTitleIDs(ctx, tx, []uint{titleID}, globalOrTeamID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "list FMA installer versions for eviction for %q", installer.Filename)
}
versions := fmaVersions[titleID]
if len(versions) > maxCachedFMAVersions {
// Build the keep set: active installer + most recent up to max.
keepSet := map[uint]bool{activeInstallerID: true}
for _, v := range versions {
if len(keepSet) >= maxCachedFMAVersions {
break
}
keepSet[v.ID] = true
}
keepIDs := slices.Collect(maps.Keys(keepSet))
// Re-point any policies referencing evicted installers to the active one.
rePointStmt, rePointArgs, err := sqlx.In(
`UPDATE policies SET software_installer_id = ?
WHERE software_installer_id IN (
SELECT id FROM software_installers
WHERE global_or_team_id = ? AND title_id = ? AND fleet_maintained_app_id IS NOT NULL AND id NOT IN (?)
)`,
activeInstallerID,
globalOrTeamID,
titleID,
keepIDs,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "build FMA policy re-point query")
}
if _, err := tx.ExecContext(ctx, rePointStmt, rePointArgs...); err != nil {
return ctxerr.Wrapf(ctx, err, "re-point policies for evicted FMA versions of %q", installer.Filename)
}
stmt, args, err := sqlx.In(
`DELETE FROM software_installers WHERE global_or_team_id = ? AND title_id = ? AND fleet_maintained_app_id IS NOT NULL AND id NOT IN (?)`,
globalOrTeamID,
titleID,
keepIDs,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "build FMA eviction query")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrapf(ctx, err, "evict old FMA versions for %q", installer.Filename)
}
}
// Update the active installer and set all others to inactive
if _, err := tx.ExecContext(ctx, `
UPDATE software_installers
SET is_active = (id = ?)
WHERE global_or_team_id = ? AND fleet_maintained_app_id = ?
`, activeInstallerID, globalOrTeamID, installer.FleetMaintainedAppID); err != nil {
return ctxerr.Wrapf(ctx, err, "setting active installer for %q", installer.Filename)
}
}
// process the labels associated with that software installer
if len(installer.ValidatedLabels.ByName) == 0 {
// no label to apply, so just delete all existing labels if any
res, err := tx.ExecContext(ctx, deleteAllInstallerLabels, installerID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "delete installer labels for %s", installer.Filename)
}
if n, _ := res.RowsAffected(); n > 0 && len(existing) > 0 {
// if it did delete a row, then the target changed so pending
// installs/uninstalls must be deleted
existing[0].IsMetadataModified = true
}
} else {
// there are new labels to apply, delete only the obsolete ones
labelIDs := make([]uint, 0, len(installer.ValidatedLabels.ByName))
for _, lbl := range installer.ValidatedLabels.ByName {
labelIDs = append(labelIDs, lbl.LabelID)
}
stmt, args, err := sqlx.In(deleteInstallerLabelsNotInList, installerID, labelIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to delete installer labels not in list")
}
res, err := tx.ExecContext(ctx, stmt, args...)
if err != nil {
return ctxerr.Wrapf(ctx, err, "delete installer labels not in list for %s", installer.Filename)
}
if n, _ := res.RowsAffected(); n > 0 && len(existing) > 0 {
// if it did delete a row, then the target changed so pending
// installs/uninstalls must be deleted
existing[0].IsMetadataModified = true
}
excludeLabels := installer.ValidatedLabels.LabelScope == fleet.LabelScopeExcludeAny
requireAllLabels := installer.ValidatedLabels.LabelScope == fleet.LabelScopeIncludeAll
if len(existing) > 0 && !existing[0].IsMetadataModified {
// load the remaining labels for that installer, so that we can detect
// if any label changed (if the counts differ, then labels did change,
// otherwise if the exclude/require all bool changed, the target did change).
var existingLabels []struct {
LabelID uint `db:"label_id"`
Exclude bool `db:"exclude"`
RequireAll bool `db:"require_all"`
}
if err := sqlx.SelectContext(ctx, tx, &existingLabels, loadExistingInstallerLabels, installerID); err != nil {
return ctxerr.Wrapf(ctx, err, "load existing labels for installer with name %q", installer.Filename)
}
if len(existingLabels) != len(labelIDs) {
existing[0].IsMetadataModified = true
}
if len(existingLabels) > 0 && (existingLabels[0].Exclude != excludeLabels || existingLabels[0].RequireAll != requireAllLabels) {
// same labels are provided, but the include <-> exclude or require all changed
existing[0].IsMetadataModified = true
}
}
// upsert the new labels now that obsolete ones have been deleted
var upsertLabelArgs []any
for _, lblID := range labelIDs {
upsertLabelArgs = append(upsertLabelArgs, installerID, lblID, excludeLabels, requireAllLabels)
}
upsertLabelValues := strings.TrimSuffix(strings.Repeat("(?,?,?,?),", len(installer.ValidatedLabels.ByName)), ",")
_, err = tx.ExecContext(ctx, fmt.Sprintf(upsertInstallerLabels, upsertLabelValues), upsertLabelArgs...)
if err != nil {
return ctxerr.Wrapf(ctx, err, "insert new/edited labels for installer with name %q", installer.Filename)
}
}
if len(installer.CategoryIDs) == 0 {
// delete all categories if there are any
_, err := tx.ExecContext(ctx, deleteAllInstallerCategories, installerID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "delete installer categories for %s", installer.Filename)
}
} else {
// there are new categories to apply, delete only the obsolete ones
stmt, args, err := sqlx.In(deleteInstallerCategoriesNotInList, installerID, installer.CategoryIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build statement to delete installer categories not in list")
}
_, err = tx.ExecContext(ctx, stmt, args...)
if err != nil {
return ctxerr.Wrapf(ctx, err, "delete installer categories not in list for %s", installer.Filename)
}
var upsertCategoriesArgs []any
for _, catID := range installer.CategoryIDs {
upsertCategoriesArgs = append(upsertCategoriesArgs, installerID, catID)
}
upsertCategoriesValues := strings.TrimSuffix(strings.Repeat("(?,?),", len(installer.CategoryIDs)), ",")
_, err = tx.ExecContext(ctx, fmt.Sprintf(upsertInstallerCategories, upsertCategoriesValues), upsertCategoriesArgs...)
if err != nil {
return ctxerr.Wrapf(ctx, err, "insert new/edited categories for installer with name %q", installer.Filename)
}
}
// update display name for the software title if it needs to be updated or inserted
// no deletions will happen, display names will be set to empty if needed
if name, ok := displayNameIDMap[titleID]; (ok && name != installer.DisplayName) || (!ok && installer.DisplayName != "") {
if err := updateSoftwareTitleDisplayName(ctx, tx, tmID, titleID, installer.DisplayName); err != nil {
return ctxerr.Wrapf(ctx, err, "update software title display name for installer with name %q", installer.Filename)
}
}
// perform side effects if this was an update (related to pending (un)install requests)
if len(existing) > 0 {
affectedHostIDs, err := ds.runInstallerUpdateSideEffectsInTransaction(
ctx,
tx,
existing[0].InstallerID,
existing[0].IsMetadataModified,
existing[0].IsPackageModified,
)
if err != nil {
return ctxerr.Wrapf(ctx, err, "processing installer with name %q", installer.Filename)
}
activateAffectedHostIDs = append(activateAffectedHostIDs, affectedHostIDs...)
}
}
return nil
})
if err != nil {
return err
}
return ds.activateNextUpcomingActivityForBatchOfHosts(ctx, activateAffectedHostIDs)
}
func (ds *Datastore) HasSelfServiceSoftwareInstallers(ctx context.Context, hostPlatform string, hostTeamID *uint) (bool, error) {
if fleet.IsLinux(hostPlatform) {
hostPlatform = "linux"
}
stmt := `SELECT 1
WHERE EXISTS (
SELECT 1
FROM software_installers
WHERE self_service = 1
AND (platform = ? OR (extension = 'sh' AND platform = 'linux' AND ? = 'darwin'))
AND global_or_team_id = ?
) OR EXISTS (
SELECT 1
FROM vpp_apps_teams
WHERE self_service = 1 AND platform = ? AND global_or_team_id = ?
)`
var globalOrTeamID uint
if hostTeamID != nil {
globalOrTeamID = *hostTeamID
}
args := []any{hostPlatform, hostPlatform, globalOrTeamID, hostPlatform, globalOrTeamID}
var hasInstallers bool
err := sqlx.GetContext(ctx, ds.reader(ctx), &hasInstallers, stmt, args...)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, ctxerr.Wrap(ctx, err, "check for self-service software installers")
}
return hasInstallers, nil
}
func (ds *Datastore) GetDetailsForUninstallFromExecutionID(ctx context.Context, executionID string) (string, bool, error) {
stmt := `
SELECT COALESCE(st.name, hsi.software_title_name) name, hsi.self_service
FROM software_titles st
INNER JOIN software_installers si ON si.title_id = st.id
INNER JOIN host_software_installs hsi ON hsi.software_installer_id = si.id
WHERE hsi.execution_id = ? AND hsi.uninstall = TRUE
UNION
SELECT st.name, COALESCE(ua.payload->'$.self_service', FALSE) self_service
FROM
software_titles st
INNER JOIN software_installers si ON si.title_id = st.id
INNER JOIN software_install_upcoming_activities siua
ON siua.software_installer_id = si.id
INNER JOIN upcoming_activities ua ON ua.id = siua.upcoming_activity_id
WHERE
ua.execution_id = ? AND
ua.activity_type = 'software_uninstall'
`
var result struct {
Name string `db:"name"`
SelfService bool `db:"self_service"`
}
err := sqlx.GetContext(ctx, ds.reader(ctx), &result, stmt, executionID, executionID)
if err != nil {
return "", false, ctxerr.Wrap(ctx, err, "get software details for uninstall activity from execution ID")
}
return result.Name, result.SelfService, nil
}
func (ds *Datastore) GetSoftwareInstallersPendingUninstallScriptPopulation(ctx context.Context) (map[uint]string, error) {
query := `SELECT id, storage_id FROM software_installers WHERE package_ids = ''
AND extension NOT IN ('exe', 'tar.gz', 'dmg', 'zip')`
type result struct {
ID uint `db:"id"`
StorageID string `db:"storage_id"`
}
var results []result
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, query); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get software installers without package ID")
}
if len(results) == 0 {
return nil, nil
}
idMap := make(map[uint]string, len(results))
for _, r := range results {
idMap[r.ID] = r.StorageID
}
return idMap, nil
}
func (ds *Datastore) GetMSIInstallersWithoutUpgradeCode(ctx context.Context) (map[uint]string, error) {
query := `SELECT id, storage_id FROM software_installers WHERE extension = 'msi' AND upgrade_code = ''`
type result struct {
ID uint `db:"id"`
StorageID string `db:"storage_id"`
}
var results []result
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, query); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get MSI installers without upgrade code")
}
if len(results) == 0 {
return nil, nil
}
idMap := make(map[uint]string, len(results))
for _, r := range results {
idMap[r.ID] = r.StorageID
}
return idMap, nil
}
func (ds *Datastore) UpdateInstallerUpgradeCode(ctx context.Context, id uint, upgradeCode string) error {
query := `UPDATE software_installers SET upgrade_code = ? WHERE id = ?`
_, err := ds.writer(ctx).ExecContext(ctx, query, upgradeCode, id)
if err != nil {
return ctxerr.Wrap(ctx, err, "update software installer upgrade code")
}
return nil
}
func (ds *Datastore) UpdateSoftwareInstallerWithoutPackageIDs(ctx context.Context, id uint,
payload fleet.UploadSoftwareInstallerPayload,
) error {
uninstallScriptID, err := ds.getOrGenerateScriptContentsID(ctx, payload.UninstallScript)
if err != nil {
return ctxerr.Wrap(ctx, err, "get or generate uninstall script contents ID")
}
query := `
UPDATE software_installers
SET package_ids = ?, uninstall_script_content_id = ?, extension = ?
WHERE id = ?
`
_, err = ds.writer(ctx).ExecContext(ctx, query, strings.Join(payload.PackageIDs, ","), uninstallScriptID, payload.Extension, id)
if err != nil {
return ctxerr.Wrap(ctx, err, "update software installer without package ID")
}
return nil
}
// GetSoftwareInstallers returns all software installers, including in-house
// apps, for the specified team. The reason why installers and in-house apps
// are returned together is that this is used in the gitops flow, where both
// types of installers are specified in the same "packages" key in the yaml.
func (ds *Datastore) GetSoftwareInstallers(ctx context.Context, teamID uint) ([]fleet.SoftwarePackageResponse, error) {
const loadInsertedSoftwareInstallers = `
SELECT
si.team_id,
si.title_id,
si.url,
si.storage_id AS hash_sha256,
si.fleet_maintained_app_id,
COALESCE(fma.slug, '') AS slug,
COALESCE(icons.filename, '') AS icon_filename,
COALESCE(icons.storage_id, '') AS icon_hash_sha256
FROM
software_installers si
LEFT JOIN software_title_icons icons ON
icons.software_title_id = si.title_id AND icons.team_id = si.global_or_team_id
LEFT JOIN fleet_maintained_apps fma ON
si.fleet_maintained_app_id = fma.id
WHERE
global_or_team_id = ?
UNION ALL
SELECT
iha.team_id,
iha.title_id,
iha.url,
iha.storage_id as hash_sha256,
NULL as fleet_maintained_app_id,
'' as slug,
COALESCE(icons.filename, '') AS icon_filename,
COALESCE(icons.storage_id, '') AS icon_hash_sha256
FROM
in_house_apps iha
LEFT JOIN software_title_icons icons ON
icons.software_title_id = iha.title_id AND icons.team_id = iha.global_or_team_id
WHERE
iha.global_or_team_id = ?
`
var softwarePackages []fleet.SoftwarePackageResponse
// Using ds.writer(ctx) on purpose because this method is to be called after applying software.
if err := sqlx.SelectContext(ctx, ds.writer(ctx), &softwarePackages,
loadInsertedSoftwareInstallers, teamID, teamID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get software installers")
}
return softwarePackages, nil
}
func (ds *Datastore) IsSoftwareInstallerLabelScoped(ctx context.Context, installerID, hostID uint) (bool, error) {
return ds.isSoftwareLabelScoped(ctx, installerID, hostID, softwareTypeInstaller)
}
func (ds *Datastore) IsVPPAppLabelScoped(ctx context.Context, vppAppTeamID, hostID uint) (bool, error) {
return ds.isSoftwareLabelScoped(ctx, vppAppTeamID, hostID, softwareTypeVPP)
}
func (ds *Datastore) isSoftwareLabelScoped(ctx context.Context, softwareID, hostID uint, swType softwareType) (bool, error) {
stmt := `
SELECT 1 FROM (
-- no labels
SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels
WHERE NOT EXISTS (
SELECT 1 FROM %[1]s_labels sil WHERE sil.%[1]s_id = :software_id
)
UNION
-- include any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
0 as count_host_updated_after_labels
FROM
%[1]s_labels sil
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
AND lm.host_id = :host_id
WHERE
sil.%[1]s_id = :software_id
AND sil.exclude = 0
AND sil.require_all = 0
HAVING
count_installer_labels > 0 AND count_host_labels > 0
UNION
-- exclude any, ignore software that depends on labels created
-- _after_ the label_updated_at timestamp of the host (because
-- we don't have results for that label yet, the host may or may
-- not be a member).
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
SUM(CASE
WHEN
lbl.created_at IS NOT NULL AND lbl.label_membership_type = 0 AND (SELECT label_updated_at FROM hosts WHERE id = :host_id) >= lbl.created_at THEN 1
WHEN
lbl.created_at IS NOT NULL AND lbl.label_membership_type = 1 THEN 1
ELSE
0
END) as count_host_updated_after_labels
FROM
%[1]s_labels sil
LEFT OUTER JOIN labels lbl
ON lbl.id = sil.label_id
LEFT OUTER JOIN label_membership lm
ON lm.label_id = sil.label_id AND lm.host_id = :host_id
WHERE
sil.%[1]s_id = :software_id
AND sil.exclude = 1
AND sil.require_all = 0
HAVING
count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0
UNION
-- include all
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
0 as count_host_updated_after_labels
FROM
%[1]s_labels sil
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
AND lm.host_id = :host_id
WHERE
sil.%[1]s_id = :software_id
AND sil.exclude = 0
AND sil.require_all = 1
HAVING
count_installer_labels > 0 AND count_host_labels = count_installer_labels
) t
`
stmt = fmt.Sprintf(stmt, swType)
namedArgs := map[string]any{
"host_id": hostID,
"software_id": softwareID,
}
stmt, args, err := sqlx.Named(stmt, namedArgs)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "build named query for is software label scoped")
}
var res bool
if err := sqlx.GetContext(ctx, ds.reader(ctx), &res, stmt, args...); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, ctxerr.Wrap(ctx, err, "is software label scoped")
}
return res, nil
}
const labelScopedFilter = `
SELECT
1
FROM (
-- no labels
SELECT
0 AS count_installer_labels,
0 AS count_host_labels,
0 AS count_host_updated_after_labels
WHERE NOT EXISTS ( SELECT 1 FROM %[1]s_labels sil WHERE sil.%[1]s_id = ?)
UNION
-- include any
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
0 AS count_host_updated_after_labels
FROM
%[1]s_labels sil
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id
AND lm.host_id = h.id
WHERE
sil.%[1]s_id = ?
AND sil.exclude = 0
HAVING
count_installer_labels > 0
AND count_host_labels > 0
UNION
-- exclude any, ignore software that depends on labels created
-- _after_ the label_updated_at timestamp of the host (because
-- we don't have results for that label yet, the host may or may
-- not be a member).
SELECT
COUNT(*) AS count_installer_labels,
COUNT(lm.label_id) AS count_host_labels,
SUM(
CASE
WHEN lbl.created_at IS NOT NULL AND lbl.label_membership_type = 0 AND (SELECT label_updated_at FROM hosts WHERE id = h.id) >= lbl.created_at THEN 1
WHEN lbl.created_at IS NOT NULL AND lbl.label_membership_type = 1 THEN 1
ELSE 0 END) AS count_host_updated_after_labels
FROM
%[1]s_labels sil
LEFT OUTER JOIN labels lbl ON lbl.id = sil.label_id
LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id AND lm.host_id = h.id
WHERE
sil.%[1]s_id = ?
AND sil.exclude = 1
HAVING
count_installer_labels > 0
AND count_installer_labels = count_host_updated_after_labels
AND count_host_labels = 0) t`
func (ds *Datastore) GetIncludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) {
return ds.getIncludedHostIDMapForSoftware(ctx, ds.writer(ctx), installerID, softwareTypeInstaller)
}
func (ds *Datastore) getIncludedHostIDMapForSoftware(ctx context.Context, tx sqlx.ExtContext, softwareID uint, swType softwareType) (map[uint]struct{}, error) {
filter := fmt.Sprintf(labelScopedFilter, swType)
stmt := fmt.Sprintf(`SELECT
h.id
FROM
hosts h
WHERE
EXISTS (%s)
`, filter)
var hostIDs []uint
if err := sqlx.SelectContext(ctx, tx, &hostIDs, stmt, softwareID, softwareID, softwareID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "listing host ids included in software scope")
}
res := make(map[uint]struct{}, len(hostIDs))
for _, id := range hostIDs {
res[id] = struct{}{}
}
return res, nil
}
func (ds *Datastore) GetIncludedHostUUIDMapForAppStoreApp(ctx context.Context, vppAppTeamID uint) (map[string]string, error) {
return ds.getIncludedHostUUIDMapForSoftware(ctx, ds.writer(ctx), vppAppTeamID, softwareTypeVPP)
}
func (ds *Datastore) getIncludedHostUUIDMapForSoftware(ctx context.Context, tx sqlx.ExtContext, softwareID uint, swType softwareType) (map[string]string, error) {
filter := fmt.Sprintf(labelScopedFilter, swType)
stmt := fmt.Sprintf(`SELECT
h.uuid AS uuid,
ad.applied_policy_id AS applied_policy_id
FROM
hosts h
JOIN android_devices ad ON ad.enterprise_specific_id = h.uuid
JOIN vpp_apps_teams vat ON vat.team_id <=> h.team_id AND vat.id = ?
WHERE
EXISTS (%s)
AND h.platform = 'android'
`, filter)
var queryResults []struct {
UUID string `db:"uuid"`
AppliedPolicyID *string `db:"applied_policy_id"`
}
if err := sqlx.SelectContext(ctx, tx, &queryResults, stmt, softwareID, softwareID, softwareID, softwareID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "listing hosts included in software scope")
}
res := make(map[string]string, len(queryResults))
for _, result := range queryResults {
polID := ptr.ValOrZero(result.AppliedPolicyID)
res[result.UUID] = polID
}
return res, nil
}
func (ds *Datastore) GetExcludedHostIDMapForSoftwareInstaller(ctx context.Context, installerID uint) (map[uint]struct{}, error) {
return ds.getExcludedHostIDMapForSoftware(ctx, installerID, softwareTypeInstaller)
}
func (ds *Datastore) getExcludedHostIDMapForSoftware(ctx context.Context, softwareID uint, swType softwareType) (map[uint]struct{}, error) {
filter := fmt.Sprintf(labelScopedFilter, swType)
stmt := fmt.Sprintf(`SELECT
h.id
FROM
hosts h
WHERE
NOT EXISTS (%s)
`, filter)
var hostIDs []uint
if err := sqlx.SelectContext(ctx, ds.writer(ctx), &hostIDs, stmt, softwareID, softwareID, softwareID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "listing hosts excluded from software scope")
}
res := make(map[uint]struct{}, len(hostIDs))
for _, id := range hostIDs {
res[id] = struct{}{}
}
return res, nil
}
// GetTeamsWithInstallerByHash retrieves all software installers and in-house apps
// matching the given sha256 hash (storage_id) and optional URL, grouped by team ID.
// Software installers can only have at most 1 installer per team for the given hash,
// while in-house apps can have multiple (1 for ios and 1 for ipados).
func (ds *Datastore) GetTeamsWithInstallerByHash(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) {
stmt := `
SELECT
si.id AS installer_id,
si.team_id AS team_id,
si.filename AS filename,
si.extension AS extension,
si.version AS version,
si.platform AS platform,
st.source AS source,
st.bundle_identifier AS bundle_identifier,
st.name AS title,
si.package_ids AS package_ids
FROM
software_installers si
JOIN software_titles st ON si.title_id = st.id
WHERE
si.storage_id = ? AND si.is_active = 1 %s
UNION ALL
SELECT
iha.id AS installer_id,
iha.team_id AS team_id,
iha.filename AS filename,
'ipa' AS extension,
iha.version AS version,
iha.platform AS platform,
st.source AS source,
st.bundle_identifier AS bundle_identifier,
st.name AS title,
'' AS package_ids
FROM
in_house_apps iha
JOIN software_titles st ON iha.title_id = st.id
WHERE
iha.storage_id = ? %s
`
var urlFilter string
args := []any{sha256}
if url != "" {
urlFilter = " AND url = ?"
args = append(args, url)
}
stmt = fmt.Sprintf(stmt, urlFilter, urlFilter)
args = append(args, args...)
var installers []*fleet.ExistingSoftwareInstaller
if err := sqlx.SelectContext(ctx, ds.writer(ctx), &installers, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get software installer by hash")
}
byTeam := make(map[uint][]*fleet.ExistingSoftwareInstaller, len(installers))
for _, installer := range installers {
// team ID 0 is No team in this context
var tmID uint
if installer.TeamID != nil {
tmID = *installer.TeamID
}
if _, ok := byTeam[tmID]; ok && installer.Extension != "ipa" {
return nil, ctxerr.New(ctx, fmt.Sprintf("cannot have multiple installers with the same hash %q on one team", sha256))
}
if installer.PackageIDList != "" {
installer.PackageIDs = strings.Split(installer.PackageIDList, ",")
}
byTeam[tmID] = append(byTeam[tmID], installer)
}
return byTeam, nil
}
func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error {
// if this is an in-house app, check if an installer exists
if payload.Extension == "ipa" {
// at the point where this method is called, we attempt to create both iOS and iPadOS entries
// for ipa apps, so check for conflicts on either platform.
for platform, source := range map[string]string{
string(fleet.IOSPlatform): "ios_apps",
string(fleet.IPadOSPlatform): "ipados_apps",
} {
exists, err := ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), payload.TeamID, payload.BundleIdentifier, platform, softwareTypeInstaller)
if err != nil {
return ctxerr.Wrap(ctx, err, "check if software installer exists for title identifier")
}
if exists {
return alreadyExists("software installer", payload.Title)
}
exists, err = ds.checkVPPAppExistsForTitleIdentifier(ctx, ds.reader(ctx), payload.TeamID, platform, payload.BundleIdentifier, source, "")
if err != nil {
return ctxerr.Wrap(ctx, err, "check if VPP app exists for title identifier")
}
if exists {
return alreadyExists("VPP app", payload.Title)
}
}
} else {
// check if a VPP app already exists for that software title in the same
// platform and team.
if payload.Platform == string(fleet.MacOSPlatform) || payload.Platform == string(fleet.IOSPlatform) || payload.Platform == string(fleet.IPadOSPlatform) {
exists, err := ds.checkVPPAppExistsForTitleIdentifier(ctx, ds.reader(ctx), payload.TeamID, payload.Platform, payload.BundleIdentifier, payload.Source, "")
if err != nil {
return ctxerr.Wrap(ctx, err, "check if VPP app exists for title identifier")
}
if exists {
return alreadyExists("VPP app", payload.Title)
}
}
// Check if an in-house app with the same bundle id already exists.
// Also check if equivalent installers exist, since we relaxed the uniqueness constraints to allow
// multiple FMA installer versions.
if payload.BundleIdentifier != "" {
exists, err := ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), payload.TeamID, payload.BundleIdentifier, payload.Platform, softwareTypeInHouseApp)
if err != nil {
return ctxerr.Wrap(ctx, err, "check if in-house app exists for title identifier")
}
if exists {
return alreadyExists("in-house app", payload.Title)
}
exists, err = ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), payload.TeamID, payload.BundleIdentifier, payload.Platform, softwareTypeInstaller)
if err != nil {
return ctxerr.Wrap(ctx, err, "check if installer exists for title identifier")
}
if exists {
return alreadyExists("installer", payload.Title)
}
}
if payload.Platform == "windows" {
exists, err := ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), payload.TeamID, payload.Title, payload.Platform, softwareTypeInstaller)
if err != nil {
return ctxerr.Wrap(ctx, err, "check if installer exists for title identifier")
}
if exists {
return alreadyExists("installer", payload.Title)
}
}
}
return nil
}