<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #49416 - Adds patch_when_closed to the new policy, update policy, and update package endpoints - Sets pre_install_query in the software title endpoint to software_installers.app_open_query if the policy is a patch when closed policy (discussed in standup) - Deletes the existing pre_install_query if setting patch_when_closed (discussed in standup) - Fixes a small error in the patch_policy package - Fixes the default macOS query in patch_policy package to use to escape symbols in a.path # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] 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. - [ ] Timeouts are implemented and retries are limited to avoid infinite loops - [ ] 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) - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a “patch when closed” option for patch policies and Fleet-maintained apps. * Added installer controls for enabling patching and configuring whether apps must be closed before installation. * Automatically manages pre-install behavior and continuous automation requirements for these policies. * **Bug Fixes** * Improved app detection based on application path prefixes on macOS. * Corrected RStudio process detection on Windows. * Added validation to prevent incompatible patch policy settings. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
4766 lines
169 KiB
Go
4766 lines
169 KiB
Go
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/pkg/patch_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,
|
||
si.app_open_query AS app_open_query,
|
||
COALESCE(p.patch_when_closed, 0) AS patch_when_closed,
|
||
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
|
||
policies p
|
||
ON p.id = hsi.policy_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,
|
||
si.app_open_query AS app_open_query,
|
||
COALESCE(p.patch_when_closed, 0) AS patch_when_closed,
|
||
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
|
||
policies p
|
||
ON p.id = siua.policy_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")
|
||
}
|
||
|
||
// A patch-when-closed policy install uses the installer's app open query as its pre-install condition.
|
||
if result.PatchWhenClosed {
|
||
result.PreInstallCondition = result.AppOpenQuery
|
||
}
|
||
|
||
// Install scripts run per-host, so custom host vitals resolve against the target host.
|
||
expand := func(script, kind string) (string, error) {
|
||
expanded, err := ds.ExpandEmbeddedSecrets(ctx, script)
|
||
if err != nil {
|
||
return "", ctxerr.Wrapf(ctx, err, "expanding secrets in %s script", kind)
|
||
}
|
||
expanded, err = ds.ExpandCustomHostVitals(ctx, result.HostID, expanded)
|
||
if err != nil {
|
||
return "", ctxerr.Wrapf(ctx, err, "expanding custom host vitals in %s script", kind)
|
||
}
|
||
return expanded, nil
|
||
}
|
||
|
||
var err error
|
||
if result.InstallScript, err = expand(result.InstallScript, "install"); err != nil {
|
||
return nil, err
|
||
}
|
||
if result.PostInstallScript, err = expand(result.PostInstallScript, "post-install"); err != nil {
|
||
return nil, err
|
||
}
|
||
if result.UninstallScript, err = expand(result.UninstallScript, "uninstall"); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 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 {
|
||
return 0, 0, err
|
||
}
|
||
|
||
// 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,
|
||
Configuration: payload.Configuration,
|
||
})
|
||
if err != nil {
|
||
return 0, 0, ctxerr.Wrap(ctx, err, "insert in house app")
|
||
}
|
||
return installerID, titleID, err
|
||
}
|
||
|
||
titleID, err = ds.getOrGenerateSoftwareInstallerTitleID(ctx, ds.writer(ctx), payload)
|
||
if err != nil {
|
||
return 0, 0, ctxerr.Wrap(ctx, err, "get or generate software installer title ID")
|
||
}
|
||
|
||
// Script packages dedupe by content team-wide: identical bytes are the same script, so
|
||
// they can't be added under a different title. Same-title duplicates are already caught
|
||
// by the per-title hash check. Binary installers can legitimately ship the same content
|
||
// with different install scripts, so they are not deduped this way.
|
||
if payload.StorageID != "" && fleet.IsScriptPackage(payload.Extension) {
|
||
teamsByHash, err := ds.GetTeamsWithInstallerByHash(ctx, payload.StorageID, "")
|
||
if err != nil {
|
||
return 0, 0, ctxerr.Wrap(ctx, err, "check duplicate installer by hash")
|
||
}
|
||
if _, exists := teamsByHash[ptr.ValOrZero(payload.TeamID)]; exists {
|
||
return 0, 0, fleet.NewInvalidArgumentError(
|
||
"software",
|
||
"Couldn't add software. An installer with identical contents already exists on this fleet.",
|
||
)
|
||
}
|
||
}
|
||
|
||
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,
|
||
app_open_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,
|
||
payload.AppOpenQuery,
|
||
}
|
||
|
||
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 softwareInstallerTitleSelect(payload *fleet.UploadSoftwareInstallerPayload) (string, []any) {
|
||
switch {
|
||
case payload.BundleIdentifier != "":
|
||
// match by bundle identifier and source first, or standard matching if we don't have a bundle identifier match
|
||
return `SELECT id FROM software_titles WHERE (bundle_identifier = ? AND source = ?) OR (name = ? AND source = ? AND extension_for = '') ORDER BY bundle_identifier = ? DESC LIMIT 1`,
|
||
[]any{payload.BundleIdentifier, payload.Source, payload.Title, payload.Source, payload.BundleIdentifier}
|
||
case payload.Source == "programs" && payload.UpgradeCode != "":
|
||
// select by either name or upgrade code, preferring upgrade code
|
||
return `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`,
|
||
[]any{payload.Title, payload.Source, payload.UpgradeCode, payload.UpgradeCode}
|
||
default:
|
||
return `SELECT id FROM software_titles WHERE name = ? AND source = ? AND extension_for = ''`,
|
||
[]any{payload.Title, payload.Source}
|
||
}
|
||
}
|
||
|
||
// GetExistingSoftwareInstallerTitleID resolves the software title an installer payload identifies
|
||
// (by bundle_identifier / upgrade_code / name+source). Returns a NotFound error if none matches.
|
||
func (ds *Datastore) GetExistingSoftwareInstallerTitleID(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) {
|
||
stmt, args := softwareInstallerTitleSelect(payload)
|
||
var titleID uint
|
||
switch err := sqlx.GetContext(ctx, ds.reader(ctx), &titleID, stmt, args...); {
|
||
case err == nil:
|
||
return titleID, nil
|
||
case errors.Is(err, sql.ErrNoRows):
|
||
return 0, notFound("SoftwareTitle")
|
||
default:
|
||
return 0, ctxerr.Wrap(ctx, err, "get existing software installer title id")
|
||
}
|
||
}
|
||
|
||
func (ds *Datastore) getOrGenerateSoftwareInstallerTitleID(ctx context.Context, tx sqlx.ExtContext, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) {
|
||
selectStmt, selectArgs := softwareInstallerTitleSelect(payload)
|
||
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" {
|
||
insertStmt = `INSERT INTO software_titles (name, source, extension_for, upgrade_code) VALUES (?, ?, '', ?)`
|
||
insertArgs = []any{payload.Title, payload.Source, payload.UpgradeCode}
|
||
}
|
||
|
||
if 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.optimisticGetOrInsertWithWriter(ctx, tx,
|
||
¶meterizedStmt{
|
||
Statement: selectStmt,
|
||
Args: selectArgs,
|
||
},
|
||
¶meterizedStmt{
|
||
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}
|
||
|
||
// Update the software title name if this is a Windows FMA with an upgrade code. We already update
|
||
// software titles with macOS FMA names on FMA catalog sync, so we only do Windows here.
|
||
if payload.FleetMaintainedAppID != nil {
|
||
updateStmt = `UPDATE software_titles SET name = ?, upgrade_code = ? WHERE id = ?`
|
||
updateArgs = []any{payload.Title, payload.UpgradeCode, titleID}
|
||
}
|
||
|
||
if _, err := tx.ExecContext(ctx, updateStmt, updateArgs...); 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) SetFleetMaintainedAppActiveInstaller(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload, activeInstallerID uint) error {
|
||
tmID := ptr.ValOrZero(payload.TeamID)
|
||
|
||
var affectedHostIDs []uint
|
||
if err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
||
// Capture the currently-active installer before flipping so installs queued
|
||
// against it can be redirected to the new active version in the same transaction.
|
||
var previousActiveID uint
|
||
switch err := sqlx.GetContext(ctx, tx, &previousActiveID, `
|
||
SELECT id FROM software_installers
|
||
WHERE global_or_team_id = ? AND title_id = ? AND is_active = 1
|
||
LIMIT 1 FOR UPDATE`, tmID, payload.TitleID); {
|
||
case errors.Is(err, sql.ErrNoRows):
|
||
// No active row yet (nothing to redirect away from).
|
||
case err != nil:
|
||
return ctxerr.Wrap(ctx, err, "getting current active fleet-maintained app installer")
|
||
}
|
||
|
||
if _, err := tx.ExecContext(ctx, `
|
||
UPDATE software_installers
|
||
SET is_active = (id = ?)
|
||
WHERE global_or_team_id = ? AND title_id = ?
|
||
`, activeInstallerID, tmID, payload.TitleID); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "setting active fleet-maintained app installer")
|
||
}
|
||
|
||
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 != ?
|
||
)
|
||
`, activeInstallerID, tmID, payload.TitleID, activeInstallerID); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "re-pointing policies to active fleet-maintained app installer")
|
||
}
|
||
|
||
if previousActiveID != 0 && previousActiveID != activeInstallerID {
|
||
hostIDs, err := ds.redirectPendingInstallsToActiveInstaller(ctx, tx, previousActiveID, activeInstallerID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
affectedHostIDs = hostIDs
|
||
}
|
||
|
||
// A nil pin means the caller manages the pin row separately and it must be
|
||
// left as-is. The auto-update cron relies on this so it can flip the active
|
||
// installer without clobbering a pin an admin changed concurrently. A
|
||
// non-nil pin is authoritative: empty clears it (Latest), else it's upserted.
|
||
if payload.PinnedVersion == nil {
|
||
return nil
|
||
}
|
||
if *payload.PinnedVersion == "" {
|
||
if err := deletePinnedVersionDB(ctx, tx, tmID, payload.TitleID); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "clearing Fleet-maintained app pin")
|
||
}
|
||
} else if err := setPinnedVersionDB(ctx, tx, tmID, payload.TitleID, *payload.PinnedVersion); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "pinning Fleet-maintained app version")
|
||
}
|
||
|
||
return nil
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Regenerate the patch policy query for the newly-active installer, if one exists.
|
||
patchPolicy, err := ds.GetPatchPolicy(ctx, payload.TeamID, payload.TitleID)
|
||
switch {
|
||
case fleet.IsNotFound(err):
|
||
// No patch policy for this title; nothing to regenerate.
|
||
case err != nil:
|
||
return ctxerr.Wrap(ctx, err, "getting patch policy")
|
||
default:
|
||
activeInstaller, err := ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx, payload.TeamID, payload.TitleID, activeInstallerID, false)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "getting active installer for patch policy")
|
||
}
|
||
|
||
generated, err := patch_policy.GenerateFromInstaller(patch_policy.PolicyData{}, activeInstaller)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "generating patch policy query")
|
||
}
|
||
|
||
if _, err := ds.writer(ctx).ExecContext(ctx, `UPDATE policies SET query = ? WHERE id = ?`, generated.Query, patchPolicy.ID); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "updating patch policy query")
|
||
}
|
||
if err := ds.ResetPolicy(ctx, patchPolicy.ID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// Activation must run outside the transaction (it reads/writes via the
|
||
// datastore's own connection), mirroring ProcessInstallerUpdateSideEffects.
|
||
return ds.activateNextUpcomingActivityForBatchOfHosts(ctx, affectedHostIDs)
|
||
}
|
||
|
||
// redirectPendingInstallsToActiveInstaller moves installs queued against a superseded
|
||
// Fleet-maintained app installer to the newly-active one, so a host never
|
||
// installs a version other than the one Fleet currently displays. Not-yet-activated
|
||
// install activities are re-pointed (and their cached version/filename refreshed)
|
||
// so they install the new version; already-dispatched installs and any uninstalls
|
||
// are canceled via the shared side-effects path (they can't be recalled from the
|
||
// host, so their automation re-queues them against the active installer). Returns
|
||
// the hosts whose activity queue must be advanced after the transaction commits.
|
||
func (ds *Datastore) redirectPendingInstallsToActiveInstaller(ctx context.Context, tx sqlx.ExtContext, previousActiveID, activeInstallerID uint) ([]uint, error) {
|
||
if _, err := tx.ExecContext(ctx, `
|
||
UPDATE upcoming_activities ua
|
||
JOIN software_install_upcoming_activities siua ON siua.upcoming_activity_id = ua.id
|
||
JOIN software_installers si ON si.id = ?
|
||
SET ua.payload = JSON_SET(ua.payload, '$.version', si.version, '$.installer_filename', si.filename)
|
||
WHERE siua.software_installer_id = ?
|
||
AND ua.activated_at IS NULL
|
||
AND ua.activity_type = 'software_install'
|
||
`, activeInstallerID, previousActiveID); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "refreshing queued install payload to active installer")
|
||
}
|
||
|
||
if _, err := tx.ExecContext(ctx, `
|
||
UPDATE software_install_upcoming_activities siua
|
||
JOIN upcoming_activities ua ON ua.id = siua.upcoming_activity_id
|
||
SET siua.software_installer_id = ?
|
||
WHERE siua.software_installer_id = ?
|
||
AND ua.activated_at IS NULL
|
||
AND ua.activity_type = 'software_install'
|
||
`, activeInstallerID, previousActiveID); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "re-pointing queued installs to active installer")
|
||
}
|
||
|
||
return ds.runInstallerUpdateSideEffectsInTransaction(ctx, tx, previousActiveID, true, false, true)
|
||
}
|
||
|
||
// ResolveActiveInstallerForRetry returns the currently-active installer for the
|
||
// title of the given (possibly superseded) installer, so a retried install targets
|
||
// the version Fleet currently displays rather than the one the original attempt was
|
||
// queued against. It returns the input id unchanged when that installer is already
|
||
// active, has no active sibling, or no longer exists.
|
||
func (ds *Datastore) ResolveActiveInstallerForRetry(ctx context.Context, installerID uint) (uint, error) {
|
||
// Only Fleet-maintained apps have the single-active-version semantics this
|
||
// resolves against: an FMA title has exactly one is_active=1 row, so the active
|
||
// sibling is unambiguous. Custom titles can have several packages all flagged
|
||
// is_active=1, and each is a distinct package (not a version of the other), so a
|
||
// retry must stay on its own installer — the query returns no row for them and
|
||
// the given id is used unchanged.
|
||
var activeID uint
|
||
err := sqlx.GetContext(ctx, ds.reader(ctx), &activeID, `
|
||
SELECT active.id
|
||
FROM software_installers given
|
||
JOIN software_installers active
|
||
ON active.global_or_team_id = given.global_or_team_id
|
||
AND active.title_id = given.title_id
|
||
AND active.fleet_maintained_app_id = given.fleet_maintained_app_id
|
||
AND active.is_active = 1
|
||
WHERE given.id = ?
|
||
AND given.fleet_maintained_app_id IS NOT NULL
|
||
LIMIT 1`, installerID)
|
||
switch {
|
||
case errors.Is(err, sql.ErrNoRows):
|
||
return installerID, nil
|
||
case err != nil:
|
||
return 0, ctxerr.Wrap(ctx, err, "resolving active installer for retry")
|
||
default:
|
||
return activeID, nil
|
||
}
|
||
}
|
||
|
||
func (ds *Datastore) ListFleetMaintainedAppActiveInstallers(ctx context.Context) ([]fleet.FMAAutoUpdateCandidate, error) {
|
||
var candidates []fleet.FMAAutoUpdateCandidate
|
||
// team_id is NULL for the no-team scope, so it scans straight into the nil *uint.
|
||
err := sqlx.SelectContext(ctx, ds.reader(ctx), &candidates, `
|
||
SELECT
|
||
si.team_id,
|
||
si.title_id,
|
||
si.fleet_maintained_app_id,
|
||
si.id AS installer_id,
|
||
si.version,
|
||
fma.slug
|
||
FROM software_installers si
|
||
INNER JOIN fleet_maintained_apps fma ON fma.id = si.fleet_maintained_app_id
|
||
WHERE si.fleet_maintained_app_id IS NOT NULL AND si.is_active = 1
|
||
`)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "listing active fleet-maintained app installers")
|
||
}
|
||
return candidates, nil
|
||
}
|
||
|
||
// InsertFleetMaintainedAppVersion caches a newly downloaded version of an
|
||
// already-installed Fleet-maintained app. It clones the currently active
|
||
// installer (activeInstallerID) so the team's per-installer configuration —
|
||
// self-service, labels, categories, pre-install query, attribution — is carried
|
||
// forward, overriding only the version-specific fields from the manifest
|
||
// payload. The new row is inserted inactive (is_active = 0); the caller promotes
|
||
// it separately via SetFleetMaintainedAppActiveInstaller. The team pin is never
|
||
// written here. Versions beyond maxCachedFMAVersions are evicted, always
|
||
// protecting the active installer, with policies on evicted rows re-pointed to
|
||
// it. The call is idempotent: if the version is already cached, its existing
|
||
// installer ID is returned without inserting.
|
||
func (ds *Datastore) InsertFleetMaintainedAppVersion(ctx context.Context, activeInstallerID uint, payload *fleet.UploadSoftwareInstallerPayload) (installerID uint, err error) {
|
||
// Resolve script content IDs outside the transaction (matches MatchOrCreateSoftwareInstaller).
|
||
installScriptID, err := ds.getOrGenerateScriptContentsID(ctx, payload.InstallScript)
|
||
if err != nil {
|
||
return 0, ctxerr.Wrap(ctx, err, "get or generate install script contents ID")
|
||
}
|
||
uninstallScriptID, err := ds.getOrGenerateScriptContentsID(ctx, payload.UninstallScript)
|
||
if err != nil {
|
||
return 0, ctxerr.Wrap(ctx, err, "get or generate uninstall script contents ID")
|
||
}
|
||
|
||
// Read the scope (team, title) from the active installer so the cron
|
||
// doesn't need to pass them and they always agree with the row being cloned.
|
||
var src struct {
|
||
TitleID uint `db:"title_id"`
|
||
GlobalOrTeamID uint `db:"global_or_team_id"`
|
||
}
|
||
if err := sqlx.GetContext(ctx, ds.reader(ctx), &src,
|
||
`SELECT title_id, global_or_team_id FROM software_installers WHERE id = ?`,
|
||
activeInstallerID,
|
||
); err != nil {
|
||
return 0, ctxerr.Wrap(ctx, err, "load active installer scope")
|
||
}
|
||
|
||
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||
// Resolve the live active row inside the tx and use it as the clone source,
|
||
// so per-team config the admin edited on a row they promoted during the
|
||
// cron's download window isn't cloned from the caller's stale view. FOR
|
||
// UPDATE serializes against a concurrent promotion. Falls back to the
|
||
// caller-supplied id only if nothing is active.
|
||
cloneFromID := activeInstallerID
|
||
var liveActiveID uint
|
||
switch err := sqlx.GetContext(ctx, tx, &liveActiveID, `
|
||
SELECT id FROM software_installers
|
||
WHERE global_or_team_id = ? AND title_id = ? AND fleet_maintained_app_id IS NOT NULL AND is_active = 1
|
||
LIMIT 1 FOR UPDATE`, src.GlobalOrTeamID, src.TitleID); {
|
||
case err == nil:
|
||
cloneFromID = liveActiveID
|
||
case errors.Is(err, sql.ErrNoRows):
|
||
// no active row; keep caller-supplied id
|
||
default:
|
||
return ctxerr.Wrap(ctx, err, "resolve live active installer for clone")
|
||
}
|
||
|
||
res, err := tx.ExecContext(ctx, `
|
||
INSERT INTO software_installers (
|
||
team_id, global_or_team_id, title_id, pre_install_query, platform,
|
||
self_service, user_id, user_name, user_email, fleet_maintained_app_id,
|
||
post_install_script_content_id, install_during_setup,
|
||
storage_id, filename, extension, version,
|
||
install_script_content_id, uninstall_script_content_id,
|
||
url, upgrade_code, is_active, patch_query, app_open_query, package_ids
|
||
)
|
||
SELECT
|
||
team_id, global_or_team_id, title_id, pre_install_query, platform,
|
||
self_service, user_id, user_name, user_email, fleet_maintained_app_id,
|
||
post_install_script_content_id, install_during_setup,
|
||
?, ?, ?, ?,
|
||
?, ?,
|
||
?, ?, 0, ?, ?, ?
|
||
FROM software_installers WHERE id = ?`,
|
||
payload.StorageID, payload.Filename, payload.Extension, payload.Version,
|
||
installScriptID, uninstallScriptID,
|
||
payload.URL, payload.UpgradeCode, payload.PatchQuery, payload.AppOpenQuery, strings.Join(payload.PackageIDs, ","),
|
||
cloneFromID,
|
||
)
|
||
if err != nil {
|
||
if IsDuplicate(err) {
|
||
// Version already cached for this team/title; return the existing row.
|
||
return sqlx.GetContext(ctx, tx, &installerID, `
|
||
SELECT id FROM software_installers
|
||
WHERE global_or_team_id = ? AND title_id = ? AND version = ?`,
|
||
src.GlobalOrTeamID, src.TitleID, payload.Version)
|
||
}
|
||
return ctxerr.Wrap(ctx, err, "insert fleet-maintained app version")
|
||
}
|
||
id, _ := res.LastInsertId()
|
||
installerID = uint(id) //nolint:gosec // dismiss G115
|
||
|
||
// Clone the active installer's labels and categories onto the new version.
|
||
if _, err := tx.ExecContext(ctx, `
|
||
INSERT INTO software_installer_labels (software_installer_id, label_id, exclude, require_all)
|
||
SELECT ?, label_id, exclude, require_all FROM software_installer_labels WHERE software_installer_id = ?`,
|
||
installerID, cloneFromID,
|
||
); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "clone installer labels")
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `
|
||
INSERT INTO software_installer_software_categories (software_installer_id, software_category_id)
|
||
SELECT ?, software_category_id FROM software_installer_software_categories WHERE software_installer_id = ?`,
|
||
installerID, cloneFromID,
|
||
); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "clone installer categories")
|
||
}
|
||
|
||
// Evict versions beyond the cap, protecting the live active row (the clone
|
||
// source) and the row we just inserted.
|
||
return ds.evictOldFMAVersions(ctx, tx, src.GlobalOrTeamID, src.TitleID, installerID, cloneFromID)
|
||
})
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return installerID, nil
|
||
}
|
||
|
||
// GetSoftwareInstallerMetadataByStorageID returns the package IDs and upgrade
|
||
// code of any cached installer (active or inactive) with the given storage_id.
|
||
// A content hash uniquely identifies the bytes, so the metadata is the same
|
||
// regardless of which row is currently active — the auto-update cron uses this to
|
||
// recover uninstall-script substitution values on the byte-dedup path without
|
||
// re-downloading. Returns empty values (no error) when nothing matches.
|
||
func (ds *Datastore) GetSoftwareInstallerMetadataByStorageID(ctx context.Context, storageID string) (packageIDs []string, upgradeCode string, err error) {
|
||
var row struct {
|
||
PackageIDs string `db:"package_ids"`
|
||
UpgradeCode string `db:"upgrade_code"`
|
||
}
|
||
// Prefer a row that actually carries package IDs (the MSI/EXE row).
|
||
err = sqlx.GetContext(ctx, ds.reader(ctx), &row, `
|
||
SELECT package_ids, upgrade_code FROM software_installers
|
||
WHERE storage_id = ?
|
||
ORDER BY (package_ids = '') ASC, id ASC
|
||
LIMIT 1`, storageID)
|
||
switch {
|
||
case err == nil:
|
||
if row.PackageIDs != "" {
|
||
packageIDs = strings.Split(row.PackageIDs, ",")
|
||
}
|
||
return packageIDs, row.UpgradeCode, nil
|
||
case errors.Is(err, sql.ErrNoRows):
|
||
return nil, "", nil
|
||
default:
|
||
return nil, "", ctxerr.Wrap(ctx, err, "get software installer metadata by storage id")
|
||
}
|
||
}
|
||
|
||
// evictOldFMAVersions caps cached FMA versions for a (team, title) at
|
||
// maxCachedFMAVersions. It always keeps activeID (the live is_active=1 row,
|
||
// resolved by the caller under FOR UPDATE) and newInstallerID (the row just
|
||
// inserted, about to be promoted), then the most recently uploaded versions.
|
||
// Policies on evicted rows are re-pointed to the active installer before the rows
|
||
// are deleted. Mirrors the eviction logic in BatchSetSoftwareInstallers.
|
||
func (ds *Datastore) evictOldFMAVersions(ctx context.Context, tx sqlx.ExtContext, globalOrTeamID, titleID, newInstallerID, activeID uint) error {
|
||
fmaVersions, err := ds.getFleetMaintainedVersionsByTitleIDs(ctx, tx, []uint{titleID}, globalOrTeamID, false)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "list FMA installer versions for eviction")
|
||
}
|
||
versions := fmaVersions[titleID]
|
||
if len(versions) <= maxCachedFMAVersions {
|
||
return nil
|
||
}
|
||
|
||
// Keep set: live active + newly inserted, then most recent up to the cap.
|
||
keepSet := map[uint]bool{newInstallerID: true, activeID: true}
|
||
for _, v := range versions {
|
||
if len(keepSet) >= maxCachedFMAVersions {
|
||
break
|
||
}
|
||
keepSet[v.ID] = true
|
||
}
|
||
keepIDs := slices.Collect(maps.Keys(keepSet))
|
||
|
||
// Re-point policies referencing soon-to-be-evicted versions 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 (?)
|
||
)`,
|
||
activeID, 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.Wrap(ctx, err, "re-point policies for evicted FMA versions")
|
||
}
|
||
|
||
// Delete evicted rows (with side effects), skipping the kept ones.
|
||
for _, v := range versions {
|
||
if keepSet[v.ID] {
|
||
continue
|
||
}
|
||
if _, err := ds.runInstallerUpdateSideEffectsInTransaction(ctx, tx, v.ID, true, true, false); err != nil {
|
||
return ctxerr.Wrapf(ctx, err, "side effects for evicted installer id %d", v.ID)
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `DELETE FROM software_installers WHERE id = ?`, v.ID); err != nil {
|
||
return ctxerr.Wrapf(ctx, err, "delete evicted installer id %d", v.ID)
|
||
}
|
||
}
|
||
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 i, execID := range executionIDs {
|
||
// only the last cancellation triggers activation of the next activity; the others
|
||
// would activate something that is about to be canceled in the next iteration.
|
||
activateNext := i == len(executionIDs)-1
|
||
if _, err := ds.cancelHostUpcomingActivity(ctx, tx, hostID, execID, activateNext); 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
|
||
}
|
||
|
||
// GetSoftwareInstallerIDsByTeamAndFilenamePlatform resolves installer IDs
|
||
// from zipped (filename, platform) pairs on the given team. Only active
|
||
// installers are returned; missing pairs are omitted rather than erroring,
|
||
// so callers must handle a short result set.
|
||
func (ds *Datastore) GetSoftwareInstallerIDsByTeamAndFilenamePlatform(
|
||
ctx context.Context, teamID uint, filenames []string, platforms []string,
|
||
) ([]fleet.SoftwareInstallerLookupRow, error) {
|
||
if len(filenames) != len(platforms) {
|
||
return nil, ctxerr.New(ctx, "filenames and platforms slices must have the same length")
|
||
}
|
||
if len(filenames) == 0 {
|
||
return nil, nil
|
||
}
|
||
// sqlx.In can't expand tuple IN, so build the placeholders manually.
|
||
rowPlaceholders := strings.Join(slices.Repeat([]string{"(?,?)"}, len(filenames)), ",")
|
||
args := make([]any, 0, len(filenames)*2+1)
|
||
args = append(args, teamID)
|
||
for i := range filenames {
|
||
args = append(args, filenames[i], platforms[i])
|
||
}
|
||
stmt := fmt.Sprintf(`
|
||
SELECT
|
||
id,
|
||
filename,
|
||
platform
|
||
FROM software_installers
|
||
WHERE global_or_team_id = ?
|
||
AND is_active = 1
|
||
AND (filename, platform) IN (%s)`, rowPlaceholders)
|
||
var rows []fleet.SoftwareInstallerLookupRow
|
||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, stmt, args...); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "look up installer ids by team and filename+platform")
|
||
}
|
||
return rows, 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 = ? AND si.is_active = 1
|
||
-- A title can hold several packages; report the first-added (smallest id) deterministically.
|
||
AND si.id = (
|
||
SELECT MIN(si2.id) FROM software_installers si2
|
||
WHERE si2.title_id = si.title_id AND si2.global_or_team_id = si.global_or_team_id AND si2.is_active = 1
|
||
)
|
||
|
||
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) {
|
||
return ds.getSoftwareInstallerMetadata(ctx, teamID, titleID, nil, withScriptContents)
|
||
}
|
||
|
||
// GetSoftwareInstallerMetadataByTeamTitleAndInstallerID returns the fully-hydrated
|
||
// metadata for a specific installer (rather than the first-added one), so add/edit
|
||
// responses can echo the affected package.
|
||
func (ds *Datastore) GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx context.Context, teamID *uint, titleID uint, installerID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) {
|
||
return ds.getSoftwareInstallerMetadata(ctx, teamID, titleID, &installerID, withScriptContents)
|
||
}
|
||
|
||
func (ds *Datastore) getSoftwareInstallerMetadata(ctx context.Context, teamID *uint, titleID uint, installerID *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`
|
||
}
|
||
|
||
var tmID uint
|
||
if teamID != nil {
|
||
tmID = *teamID
|
||
}
|
||
|
||
// nil installerID selects the first-added active package; otherwise that specific one.
|
||
whereClause := `si.title_id = ? AND si.global_or_team_id = ?
|
||
AND si.is_active = 1
|
||
ORDER BY si.id ASC
|
||
LIMIT 1`
|
||
args := []any{titleID, tmID}
|
||
if installerID != nil {
|
||
whereClause = `si.id = ? AND si.title_id = ? AND si.global_or_team_id = ?`
|
||
args = []any{*installerID, titleID, tmID}
|
||
}
|
||
|
||
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,
|
||
si.app_open_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
|
||
%s`,
|
||
scriptContentsSelect, scriptContentsFrom, whereClause)
|
||
|
||
var dest fleet.SoftwareInstaller
|
||
err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, args...)
|
||
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)?
|
||
dest.LabelsExcludeAny, dest.LabelsIncludeAny, dest.LabelsIncludeAll, err = ds.scopedSoftwareInstallerLabels(ctx, dest.InstallerID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if installerID != nil {
|
||
// a specific package returns its own categories, not the title-merged set
|
||
categoryMap, err := ds.GetCategoriesForSoftwareInstallers(ctx, []uint{dest.InstallerID})
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "getting categories for software installer metadata")
|
||
}
|
||
dest.Categories = categoryMap[dest.InstallerID]
|
||
} else {
|
||
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) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) {
|
||
// Join script contents so the detail shape and the edit path get the full package.
|
||
const query = `
|
||
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,
|
||
si.app_open_query,
|
||
inst.contents AS install_script,
|
||
COALESCE(pinst.contents, '') AS post_install_script,
|
||
uninst.contents AS uninstall_script
|
||
FROM
|
||
software_installers si
|
||
JOIN software_titles st ON st.id = si.title_id
|
||
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
|
||
WHERE
|
||
si.title_id = ? AND si.global_or_team_id = ?
|
||
AND si.is_active = 1
|
||
ORDER BY si.id ASC`
|
||
|
||
var packages []*fleet.SoftwareInstaller
|
||
err := sqlx.SelectContext(ctx, ds.reader(ctx), &packages, query, titleID, ptr.ValOrZero(teamID))
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "list software packages by team and title")
|
||
}
|
||
|
||
for _, pkg := range packages {
|
||
pkg.LabelsExcludeAny, pkg.LabelsIncludeAny, pkg.LabelsIncludeAll, err = ds.scopedSoftwareInstallerLabels(ctx, pkg.InstallerID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
return packages, nil
|
||
}
|
||
|
||
func (ds *Datastore) scopedSoftwareInstallerLabels(ctx context.Context, installerID uint) (excludeAny []fleet.SoftwareScopeLabel, includeAny []fleet.SoftwareScopeLabel, includeAll []fleet.SoftwareScopeLabel, err error) {
|
||
labels, err := ds.getSoftwareInstallerLabels(ctx, installerID, softwareTypeInstaller)
|
||
if err != nil {
|
||
return nil, nil, nil, ctxerr.Wrap(ctx, err, "get software installer labels")
|
||
}
|
||
|
||
for _, l := range labels {
|
||
switch {
|
||
case l.Exclude && !l.RequireAll:
|
||
excludeAny = append(excludeAny, l)
|
||
case !l.Exclude && l.RequireAll:
|
||
includeAll = append(includeAll, l)
|
||
case !l.Exclude && !l.RequireAll:
|
||
includeAny = append(includeAny, l)
|
||
default:
|
||
ds.logger.WarnContext(ctx, "software installer has an unsupported label scope", "installer_id", installerID, "invalid_label", fmt.Sprintf("%#v", l))
|
||
}
|
||
}
|
||
|
||
var scopes int
|
||
for _, set := range [][]fleet.SoftwareScopeLabel{excludeAny, includeAny, includeAll} {
|
||
if len(set) > 0 {
|
||
scopes++
|
||
}
|
||
}
|
||
if scopes > 1 {
|
||
ds.logger.WarnContext(ctx, "software installer has more than one scope of labels", "installer_id", installerID, "include_any", fmt.Sprintf("%v", includeAny), "exclude_any", fmt.Sprintf("%v", excludeAny), "include_all", fmt.Sprintf("%v", includeAll))
|
||
}
|
||
|
||
return excludeAny, includeAny, includeAll, 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: "Couldn’t 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, false)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "clean up related installs and uninstalls")
|
||
}
|
||
activateAffectedHostIDs = affectedHostIDs
|
||
|
||
// The display name is title-level (shared across sibling packages), so only remove it
|
||
// when this is the last installer on the title/team.
|
||
if _, err := tx.ExecContext(ctx, `DELETE dn FROM software_title_display_names dn
|
||
JOIN software_installers si ON si.title_id = dn.software_title_id AND si.global_or_team_id = dn.team_id
|
||
WHERE si.id = ?
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM software_installers other
|
||
WHERE other.title_id = si.title_id AND other.global_or_team_id = si.global_or_team_id AND other.id != si.id
|
||
)`, id); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "delete software title display name for installer being deleted")
|
||
}
|
||
|
||
// If install-automation policies reference this package and the title has other active
|
||
// packages, re-point those policies to the first-added surviving package (first-added-wins),
|
||
// so deleting one package of several keeps the automation working. When this is the last
|
||
// package there is no survivor: the delete below then hits the policies FK (RESTRICT) and
|
||
// returns the 409 that tells the admin to disable the automation first.
|
||
var survivorID *uint
|
||
if err := sqlx.GetContext(ctx, tx, &survivorID, `
|
||
SELECT MIN(other.id)
|
||
FROM software_installers other
|
||
JOIN software_installers deleted ON deleted.id = ?
|
||
WHERE other.title_id = deleted.title_id
|
||
AND other.global_or_team_id = deleted.global_or_team_id
|
||
AND other.id != deleted.id
|
||
AND other.is_active = 1`, id); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "find surviving package to re-point policies")
|
||
}
|
||
if survivorID != nil {
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE policies SET software_installer_id = ? WHERE software_installer_id = ?`,
|
||
*survivorID, id); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "re-point policies to surviving package before delete")
|
||
}
|
||
}
|
||
|
||
// allow delete only if not selected for setup experience (natively or cross-platform)
|
||
res, err := tx.ExecContext(ctx, `
|
||
DELETE FROM software_installers WHERE id = ?
|
||
AND install_during_setup = 0
|
||
AND NOT EXISTS (SELECT 1 FROM setup_experience_software_installers WHERE software_installer_id = ?)
|
||
`, id, 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
|
||
// selected for setup experience (natively or cross-platform).
|
||
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
|
||
}
|
||
var crossCount int
|
||
if err := sqlx.GetContext(ctx, tx, &crossCount,
|
||
`SELECT COUNT(*) FROM setup_experience_software_installers WHERE software_installer_id = ?`, id); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "check if software installer is cross-selected during setup")
|
||
}
|
||
if crossCount > 0 {
|
||
return errDeleteInstallerInstalledDuringSetup
|
||
}
|
||
return notFound("SoftwareInstaller").WithID(id)
|
||
}
|
||
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return ds.activateNextUpcomingActivityForBatchOfHosts(ctx, activateAffectedHostIDs)
|
||
}
|
||
|
||
// deletePendingSoftwareInstallsForPolicy should be called before a policy is
|
||
// deleted to cancel any pending software installs
|
||
func (ds *Datastore) deletePendingSoftwareInstallsForPolicy(ctx context.Context, teamID *uint, policyID uint) error {
|
||
var globalOrTeamID uint
|
||
if teamID != nil {
|
||
globalOrTeamID = *teamID
|
||
}
|
||
|
||
const cancelPendingStmt = `
|
||
UPDATE host_software_installs SET canceled = 1
|
||
WHERE policy_id = ? AND status IN('pending_install', 'pending_uninstall')
|
||
AND software_installer_id IN (
|
||
SELECT id FROM software_installers WHERE global_or_team_id = ?
|
||
)
|
||
`
|
||
if _, err := ds.writer(ctx).ExecContext(ctx, cancelPendingStmt, policyID, globalOrTeamID); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "cancel 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, true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
activateAffectedHostIDs = affectedHostIDs
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return ds.activateNextUpcomingActivityForBatchOfHosts(ctx, activateAffectedHostIDs)
|
||
}
|
||
|
||
func (ds *Datastore) ClearPreInstallQueryForTitle(ctx context.Context, teamID uint, titleID uint) error {
|
||
// An FMA title has one is_active=1 row, so team and title identify the managed installer.
|
||
var installer fleet.SoftwareInstaller
|
||
err := sqlx.GetContext(ctx, ds.writer(ctx), &installer, `
|
||
SELECT id, COALESCE(pre_install_query, '') AS pre_install_query
|
||
FROM software_installers
|
||
WHERE global_or_team_id = ?
|
||
AND title_id = ?
|
||
AND fleet_maintained_app_id IS NOT NULL
|
||
AND is_active = 1
|
||
LIMIT 1`, teamID, titleID)
|
||
switch {
|
||
case errors.Is(err, sql.ErrNoRows):
|
||
return nil
|
||
case err != nil:
|
||
return ctxerr.Wrap(ctx, err, "get title installer")
|
||
case installer.PreInstallQuery == "":
|
||
return nil
|
||
}
|
||
|
||
if _, err := ds.writer(ctx).ExecContext(ctx,
|
||
`UPDATE software_installers SET pre_install_query = '' WHERE id = ?`, installer.InstallerID); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "clear pre-install query for title")
|
||
}
|
||
return ds.ProcessInstallerUpdateSideEffects(ctx, installer.InstallerID, true, false)
|
||
}
|
||
|
||
func (ds *Datastore) runInstallerUpdateSideEffectsInTransaction(ctx context.Context, tx sqlx.ExtContext, installerID uint, wasMetadataUpdated bool, wasPackageUpdated bool, isEdit 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")
|
||
}
|
||
|
||
// When editing an installer, we want to cancel any installs for it unless they are in setup experience, in which case
|
||
// cancelling would cause the installs to show up as failed and if requireAllSoftware is enabled, then the entire setup
|
||
// experience will fail and cancel.
|
||
// When deleting an installer, the setup_experience_status_results row will be deleted by the FK constraint, and we want
|
||
// to actually cancel the installs to avoid errors from any host installs that are still running.
|
||
var excludeSetupExperienceFromHSI, excludeSetupExperienceFromAffectedHosts, excludeSetupExperienceFromUpcomingDelete string
|
||
if isEdit {
|
||
excludeSetupExperienceFromHSI = `AND NOT EXISTS (
|
||
SELECT 1 FROM setup_experience_status_results sesr
|
||
WHERE sesr.host_software_installs_execution_id = host_software_installs.execution_id
|
||
)`
|
||
excludeSetupExperienceFromAffectedHosts = `AND NOT EXISTS (
|
||
SELECT 1 FROM setup_experience_status_results sesr
|
||
WHERE sesr.host_software_installs_execution_id = ua.execution_id
|
||
)`
|
||
excludeSetupExperienceFromUpcomingDelete = `AND NOT EXISTS (
|
||
SELECT 1 FROM setup_experience_status_results sesr
|
||
WHERE sesr.host_software_installs_execution_id = upcoming_activities.execution_id
|
||
)`
|
||
}
|
||
|
||
_, err = tx.ExecContext(ctx, `UPDATE host_software_installs SET canceled = 1
|
||
WHERE software_installer_id = ? AND status IN('pending_install', 'pending_uninstall')
|
||
`+excludeSetupExperienceFromHSI, installerID)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "cancel 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')
|
||
`+excludeSetupExperienceFromAffectedHosts, 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')
|
||
`+excludeSetupExperienceFromUpcomingDelete, installerID)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "delete upcoming host software installs/uninstalls")
|
||
}
|
||
}
|
||
|
||
if wasPackageUpdated { // hide existing install counts
|
||
var excludeSetupExperienceFromRemoved string
|
||
if isEdit {
|
||
excludeSetupExperienceFromRemoved = `AND NOT EXISTS (
|
||
SELECT 1 FROM setup_experience_status_results sesr
|
||
WHERE sesr.host_software_installs_execution_id = host_software_installs.execution_id
|
||
)`
|
||
}
|
||
_, err := tx.ExecContext(ctx, `UPDATE host_software_installs SET removed = 1
|
||
WHERE software_installer_id = ? AND status IS NOT NULL AND canceled = 0 AND host_deleted_at IS NULL
|
||
`+excludeSetupExperienceFromRemoved, installerID)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "hide existing install counts")
|
||
}
|
||
}
|
||
|
||
return affectedHostIDs, nil
|
||
}
|
||
|
||
func (ds *Datastore) deleteInstallerInBatch(ctx context.Context, tx sqlx.ExtContext, id uint) ([]uint, error) {
|
||
affectedHostIDs, err := ds.runInstallerUpdateSideEffectsInTransaction(ctx, tx, id, true, true, false)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrapf(ctx, err, "side effects for installer id %d", id)
|
||
}
|
||
if _, err := tx.ExecContext(ctx, `DELETE FROM software_installers WHERE id = ?`, id); err != nil {
|
||
return nil, ctxerr.Wrapf(ctx, err, "delete installer id %d", id)
|
||
}
|
||
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,
|
||
si.storage_id AS hash_sha256,
|
||
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,
|
||
COALESCE(p.patch_when_closed, 0) AS patch_when_closed
|
||
FROM
|
||
host_software_installs hsi
|
||
LEFT JOIN software_titles st ON hsi.software_title_id = st.id
|
||
LEFT JOIN software_installers si ON hsi.software_installer_id = si.id
|
||
LEFT JOIN policies p ON hsi.policy_id = p.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,
|
||
si.storage_id AS hash_sha256,
|
||
'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,
|
||
COALESCE(p.patch_when_closed, 0) AS patch_when_closed
|
||
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
|
||
LEFT JOIN software_installers si
|
||
ON siua.software_installer_id = si.id
|
||
LEFT JOIN policies p
|
||
ON siua.policy_id = p.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 activity per host (per activity type)
|
||
upcoming AS (
|
||
SELECT host_id, status FROM (
|
||
SELECT
|
||
ua.host_id,
|
||
IF(ua.activity_type = 'software_install', :software_status_pending_install, :software_status_pending_uninstall) AS status,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY ua.host_id, ua.activity_type
|
||
ORDER BY ua.priority ASC, ua.created_at DESC, ua.id DESC
|
||
) AS rn
|
||
FROM
|
||
upcoming_activities ua
|
||
JOIN software_install_upcoming_activities siua ON ua.id = siua.upcoming_activity_id
|
||
JOIN hosts h ON ua.host_id = h.id
|
||
WHERE
|
||
ua.activity_type IN('software_install', 'software_uninstall')
|
||
AND siua.software_installer_id = :installer_id
|
||
) ranked
|
||
WHERE rn = 1
|
||
),
|
||
|
||
-- 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
|
||
-- Allow rows with no nano_command_results — Fleet-side pre-flight failures
|
||
-- (unresolvable managed-config Fleet variable) record only the install row
|
||
-- with verification_failed_at set, no MDM command. See same comment in
|
||
-- vpp.go GetSummaryHostVPPAppInstalls.
|
||
AND (ncr.id IS NOT NULL OR hvsi.verification_failed_at 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
|
||
-- LEFT JOIN so Fleet-side pre-flight failures (unresolvable managed-config
|
||
-- Fleet variable) survive — those never enqueue an MDM command, so no ncr
|
||
-- row exists. The inHouseAppHostStatusNamedQuery CASE maps
|
||
-- verification_failed_at IS NOT NULL to failed before any ncr.status branch
|
||
-- is evaluated.
|
||
LEFT 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,
|
||
updated_at
|
||
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,
|
||
updated_at
|
||
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 unsetAllInstallersFromPolicies = `
|
||
UPDATE
|
||
policies
|
||
SET
|
||
software_installer_id = NULL
|
||
WHERE
|
||
team_id = ?
|
||
`
|
||
|
||
const unsetAllPatchPolicies = `
|
||
UPDATE
|
||
policies
|
||
SET
|
||
patch_software_title_id = NULL
|
||
WHERE
|
||
team_id = ? AND
|
||
type = 'patch'
|
||
`
|
||
|
||
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 cancelAllPendingSoftwareInstallsHSI = `
|
||
UPDATE host_software_installs SET canceled = 1
|
||
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 = 1
|
||
WHERE status IS NOT NULL AND canceled = 0 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 cancelPendingSoftwareInstallsNotInListHSI = `
|
||
UPDATE host_software_installs SET canceled = 1
|
||
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 = 1
|
||
WHERE status IS NOT NULL AND canceled = 0 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 unsetPatchPoliciesWithInstallersNotInList = `
|
||
UPDATE
|
||
policies
|
||
SET
|
||
patch_software_title_id = NULL
|
||
WHERE
|
||
team_id = ? AND
|
||
patch_software_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 countCrossPlatformSetupNotInList = `
|
||
SELECT
|
||
COUNT(*)
|
||
FROM
|
||
setup_experience_software_installers seti
|
||
JOIN software_installers si ON si.id = seti.software_installer_id
|
||
WHERE
|
||
si.global_or_team_id = ? AND
|
||
si.title_id NOT IN (?)
|
||
`
|
||
|
||
const deleteInstallersNotInList = `
|
||
DELETE FROM
|
||
software_installers
|
||
WHERE
|
||
global_or_team_id = ? AND
|
||
title_id NOT IN (?)
|
||
`
|
||
|
||
// Fleet-maintained app pins are keyed by (team, title) and are not
|
||
// cascade-deleted when installer rows go away (the FK cascades only on title
|
||
// deletion, which BatchSet doesn't do), so clear pins for removed titles
|
||
// explicitly. Reused with a sentinel 0 to clear all pins for the team.
|
||
const deletePinnedVersionsNotInList = `
|
||
DELETE FROM software_title_team_pins WHERE 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 = ? AND
|
||
dedup_token = ?
|
||
`
|
||
|
||
const checkExistingActiveInstaller = `
|
||
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 = ?
|
||
ORDER BY is_active DESC, id DESC
|
||
`
|
||
|
||
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,
|
||
http_etag,
|
||
patch_query,
|
||
app_open_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),
|
||
fleet_maintained_app_id = VALUES(fleet_maintained_app_id),
|
||
is_active = VALUES(is_active),
|
||
http_etag = VALUES(http_etag),
|
||
patch_query = VALUES(patch_query),
|
||
app_open_query = VALUES(app_open_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 = ?,
|
||
app_open_query = ?
|
||
WHERE id = ?
|
||
`
|
||
|
||
const loadSoftwareInstallerID = `
|
||
SELECT
|
||
id
|
||
FROM
|
||
software_installers
|
||
WHERE
|
||
global_or_team_id = ? AND
|
||
title_id = ? AND
|
||
dedup_token = ?
|
||
`
|
||
|
||
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 (?)
|
||
`
|
||
|
||
// custom packages on a kept title that this batch didn't write: dropped versions
|
||
// and any leftover FMA row when a title switches to custom packages.
|
||
const findDroppedPackages = `
|
||
SELECT id FROM software_installers
|
||
WHERE global_or_team_id = ? AND title_id IN (?) AND id NOT IN (?)
|
||
`
|
||
|
||
// re-point policies on dropped custom packages to the first-added surviving package
|
||
// (lowest id) of the same title, which always exists since the title is kept.
|
||
const repointDeletedInstallerPolicies = `
|
||
UPDATE policies p
|
||
JOIN software_installers d ON d.id = p.software_installer_id
|
||
JOIN (
|
||
SELECT title_id, MIN(id) AS survivor_id FROM software_installers
|
||
WHERE global_or_team_id = ? AND id IN (?)
|
||
GROUP BY title_id
|
||
) s ON s.title_id = d.title_id
|
||
SET p.software_installer_id = s.survivor_id
|
||
WHERE d.global_or_team_id = ? AND d.title_id IN (?) AND d.id NOT IN (?)
|
||
`
|
||
|
||
// custom rows left on a title that is switching to a Fleet-maintained app.
|
||
const findStaleCustomInstallers = `
|
||
SELECT id FROM software_installers
|
||
WHERE global_or_team_id = ? AND title_id = ? AND fleet_maintained_app_id IS NULL
|
||
`
|
||
|
||
// 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, unsetAllPatchPolicies, globalOrTeamID); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "unset all obsolete patch 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, cancelAllPendingSoftwareInstallsHSI, globalOrTeamID); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "cancel all pending host software installs")
|
||
}
|
||
|
||
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")
|
||
}
|
||
|
||
// reuse the NOT IN query with sentinel 0 to clear all FMA pins for the team
|
||
if _, err := tx.ExecContext(ctx, deletePinnedVersionsNotInList, globalOrTeamID, 0); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "delete all FMA version pins")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
titleIDs := make([]uint, 0, len(installers))
|
||
titleIDByInstaller := make(map[*fleet.UploadSoftwareInstallerPayload]uint, len(installers))
|
||
installersByTitle := make(map[uint][]*fleet.UploadSoftwareInstallerPayload, len(installers))
|
||
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")
|
||
}
|
||
}
|
||
|
||
// Resolve the title the same way the single-installer add path does, to avoid duplicate titles.
|
||
titleID, err := ds.getOrGenerateSoftwareInstallerTitleID(ctx, tx, installer)
|
||
if err != nil {
|
||
return ctxerr.Wrapf(ctx, err, "get or generate software title id for installer with name %q", installer.Filename)
|
||
}
|
||
titleIDs = append(titleIDs, titleID)
|
||
titleIDByInstaller[installer] = titleID
|
||
installersByTitle[titleID] = append(installersByTitle[titleID], installer)
|
||
}
|
||
|
||
// Validate the per-title rules inside the tx so a title created here rolls back
|
||
// on failure, and record which titles got a custom package for the
|
||
// source-of-truth delete after the loop.
|
||
var customPackageTitleIDs []uint
|
||
for titleID, group := range installersByTitle {
|
||
if err := fleet.ValidateTitlePackages(group, teamName); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "validate title packages")
|
||
}
|
||
for _, installer := range group {
|
||
if installer.FleetMaintainedAppID == nil {
|
||
customPackageTitleIDs = append(customPackageTitleIDs, titleID)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
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")
|
||
}
|
||
|
||
stmt, args, err = sqlx.In(unsetPatchPoliciesWithInstallersNotInList, globalOrTeamID, titleIDs)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "build statement to unset obsolete patch policies")
|
||
}
|
||
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "unset obsolete patch 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
|
||
}
|
||
|
||
// also block when a cross-platform setup-experience selection (e.g. linux .sh
|
||
// chosen for darwin) would be silently cascade-deleted
|
||
stmt, args, err = sqlx.In(countCrossPlatformSetupNotInList, globalOrTeamID, titleIDs)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "build statement to check cross-platform setup installers")
|
||
}
|
||
var countCrossPlatformSetup int
|
||
if err := sqlx.GetContext(ctx, tx, &countCrossPlatformSetup, stmt, args...); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "check cross-platform installers installed during setup")
|
||
}
|
||
if countCrossPlatformSetup > 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(cancelPendingSoftwareInstallsNotInListHSI, globalOrTeamID, titleIDs)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "build statement to cancel obsolete pending software installs")
|
||
}
|
||
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "cancel 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")
|
||
}
|
||
|
||
stmt, args, err = sqlx.In(deletePinnedVersionsNotInList, globalOrTeamID, titleIDs)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "build statement to delete obsolete FMA pins")
|
||
}
|
||
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "delete obsolete FMA version pins")
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// installer ids written by this batch, used after the loop to remove custom
|
||
// package versions dropped from the YAML.
|
||
keptInstallerIDs := make([]uint, 0, len(installers))
|
||
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
|
||
}
|
||
|
||
titleID := titleIDByInstaller[installer]
|
||
|
||
// dedup_token is storage_id for a custom package, version for an FMA.
|
||
dedupToken := installer.StorageID
|
||
if installer.FleetMaintainedAppID != nil {
|
||
dedupToken = installer.Version
|
||
}
|
||
|
||
wasUpdatedArgs := []interface{}{
|
||
// package update
|
||
installer.StorageID,
|
||
// metadata update
|
||
installScriptID,
|
||
uninstallScriptID,
|
||
installer.PreInstallQuery,
|
||
postInstallScriptID,
|
||
postInstallScriptID,
|
||
postInstallScriptID,
|
||
// WHERE clause
|
||
globalOrTeamID,
|
||
titleID,
|
||
}
|
||
|
||
// FMA matches the active version; a custom package matches its dedup_token.
|
||
wasUpdatedStmt := checkExistingActiveInstaller
|
||
if installer.FleetMaintainedAppID == nil {
|
||
wasUpdatedStmt = checkExistingInstaller
|
||
wasUpdatedArgs = append(wasUpdatedArgs, dedupToken)
|
||
}
|
||
|
||
// 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, wasUpdatedStmt, 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 match insertNewOrEditedInstaller column order.
|
||
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, // user_name subselect
|
||
installer.UserID, // user_email subselect
|
||
installer.URL,
|
||
strings.Join(installer.PackageIDs, ","),
|
||
installer.InstallDuringSetup,
|
||
installer.FleetMaintainedAppID,
|
||
isActive,
|
||
installer.HTTPETag,
|
||
installer.PatchQuery,
|
||
installer.AppOpenQuery,
|
||
installer.InstallDuringSetup, // ON DUPLICATE KEY
|
||
}
|
||
// 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,
|
||
installer.AppOpenQuery,
|
||
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, dedupToken); err != nil {
|
||
return ctxerr.Wrapf(ctx, err, "load id of new/edited installer with name %q", installer.Filename)
|
||
}
|
||
keptInstallerIDs = append(keptInstallerIDs, installerID)
|
||
|
||
var installerIDsToDelete []uint
|
||
|
||
// 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 and team. A literal RollbackVersion pins
|
||
// that exact cached version; a "^major" caret or an empty value falls through to the newest just
|
||
// inserted version, which the slug resolver already chose, so the caret string must not be matched here.
|
||
activeInstallerID := installerID
|
||
if installer.RollbackVersion != "" && !strings.HasPrefix(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, false)
|
||
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)
|
||
}
|
||
|
||
for _, v := range versions {
|
||
if !keepSet[v.ID] {
|
||
installerIDsToDelete = append(installerIDsToDelete, v.ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// Write the pinned version so the auto update cron job keeps this information
|
||
if installer.RollbackVersion != "" {
|
||
if err := setPinnedVersionDB(ctx, tx, globalOrTeamID, titleID, installer.RollbackVersion); err != nil {
|
||
return ctxerr.Wrapf(ctx, err, "pinning version for %q", installer.Filename)
|
||
}
|
||
} else if err := deletePinnedVersionDB(ctx, tx, globalOrTeamID, titleID); err != nil {
|
||
return ctxerr.Wrapf(ctx, err, "clearing pin for %q", installer.Filename)
|
||
}
|
||
|
||
// Re-point this title's policies to the active FMA installer.
|
||
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 != ?
|
||
)
|
||
`, activeInstallerID, globalOrTeamID, titleID, activeInstallerID); err != nil {
|
||
return ctxerr.Wrapf(ctx, err, "re-point policies to active FMA installer %q", installer.Filename)
|
||
}
|
||
// A title switching to an FMA can't also hold custom rows, so remove
|
||
// them. Their policies were already re-pointed to the active FMA.
|
||
var staleCustomIDs []uint
|
||
if err := sqlx.SelectContext(ctx, tx, &staleCustomIDs, findStaleCustomInstallers, globalOrTeamID, titleID); err != nil {
|
||
return ctxerr.Wrapf(ctx, err, "find stale custom installers for FMA title %q", installer.Filename)
|
||
}
|
||
installerIDsToDelete = append(installerIDsToDelete, staleCustomIDs...)
|
||
}
|
||
|
||
// 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,
|
||
true,
|
||
)
|
||
if err != nil {
|
||
return ctxerr.Wrapf(ctx, err, "processing installer with name %q", installer.Filename)
|
||
}
|
||
activateAffectedHostIDs = append(activateAffectedHostIDs, affectedHostIDs...)
|
||
}
|
||
|
||
// These installers were replaced by a newer version and had their policies
|
||
// re-pointed above, so delete them without touching policies.
|
||
for _, id := range installerIDsToDelete {
|
||
affectedHostIDs, err := ds.deleteInstallerInBatch(ctx, tx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
activateAffectedHostIDs = append(activateAffectedHostIDs, affectedHostIDs...)
|
||
}
|
||
}
|
||
|
||
// Source of truth for titles with custom packages: remove any row this batch
|
||
// didn't write, which drops old custom versions and any leftover FMA row when a
|
||
// title switches to custom packages. FMA titles keep their cached versions.
|
||
if len(customPackageTitleIDs) > 0 {
|
||
// Re-point policies off the dropped packages before deleting them, since the
|
||
// policies FK is RESTRICT. A title removed entirely is handled by the
|
||
// not-in-list cleanup above, so here the title always keeps a package.
|
||
repointStmt, repointArgs, err := sqlx.In(repointDeletedInstallerPolicies, globalOrTeamID, keptInstallerIDs, globalOrTeamID, customPackageTitleIDs, keptInstallerIDs)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "build statement to re-point dropped policies")
|
||
}
|
||
if _, err := tx.ExecContext(ctx, repointStmt, repointArgs...); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "re-point dropped policies")
|
||
}
|
||
|
||
droppedStmt, droppedArgs, err := sqlx.In(findDroppedPackages, globalOrTeamID, customPackageTitleIDs, keptInstallerIDs)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "build statement to find dropped packages")
|
||
}
|
||
var droppedInstallerIDs []uint
|
||
if err := sqlx.SelectContext(ctx, tx, &droppedInstallerIDs, droppedStmt, droppedArgs...); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "find dropped packages")
|
||
}
|
||
for _, id := range droppedInstallerIDs {
|
||
affectedHostIDs, err := ds.deleteInstallerInBatch(ctx, tx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
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 IN ('sh', 'py') 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) GetSoftwareInstallersPendingDeletion(ctx context.Context, tmID *uint, incoming []fleet.SoftwareTitleIdentifier) ([]fleet.DeletedSoftwarePackage, error) {
|
||
var globalOrTeamID uint
|
||
if tmID != nil {
|
||
globalOrTeamID = *tmID
|
||
}
|
||
|
||
// An installer survives a batch set iff its title matches an incoming
|
||
// (unique_identifier, source) key with extension_for = '' — the same
|
||
// matching BatchSetSoftwareInstallers uses to upsert titles before
|
||
// deleting installers with title_id NOT IN the upserted set. DISTINCT
|
||
// collapses multiple installer rows (cached FMA versions) of one title.
|
||
stmt := `
|
||
SELECT DISTINCT
|
||
si.team_id,
|
||
st.id AS title_id,
|
||
COALESCE(NULLIF(stdn.display_name, ''), st.name) AS display_name
|
||
FROM
|
||
software_installers si
|
||
JOIN software_titles st ON st.id = si.title_id
|
||
LEFT JOIN software_title_display_names stdn ON
|
||
stdn.software_title_id = st.id AND stdn.team_id = si.global_or_team_id
|
||
WHERE
|
||
si.global_or_team_id = ?`
|
||
|
||
args := []any{globalOrTeamID}
|
||
if len(incoming) > 0 {
|
||
stmt += fmt.Sprintf(` AND NOT (st.extension_for = '' AND (st.unique_identifier, st.source) IN (%s))`,
|
||
strings.TrimSuffix(strings.Repeat("(?,?),", len(incoming)), ","))
|
||
for _, ti := range incoming {
|
||
args = append(args, ti.UniqueIdentifier, ti.Source)
|
||
}
|
||
}
|
||
stmt += ` ORDER BY display_name, title_id`
|
||
|
||
var deleted []fleet.DeletedSoftwarePackage
|
||
// Using ds.writer(ctx) on purpose: this runs during batch-set processing and must
|
||
// see the same state the deletion will operate on (no replica lag).
|
||
if err := sqlx.SelectContext(ctx, ds.writer(ctx), &deleted, stmt, args...); err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "get software installers pending deletion")
|
||
}
|
||
return deleted, 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,
|
||
si.storage_id,
|
||
si.http_etag,
|
||
si.filename,
|
||
si.extension,
|
||
si.version,
|
||
si.platform,
|
||
st.source,
|
||
st.bundle_identifier,
|
||
st.name AS title,
|
||
si.package_ids,
|
||
si.install_script_content_id
|
||
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,
|
||
iha.storage_id,
|
||
NULL AS http_etag,
|
||
iha.filename,
|
||
'ipa' AS extension,
|
||
iha.version,
|
||
iha.platform,
|
||
st.source,
|
||
st.bundle_identifier,
|
||
st.name AS title,
|
||
'' AS package_ids,
|
||
0 AS install_script_content_id
|
||
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) GetInstallerByTeamAndURL(ctx context.Context, teamID *uint, url string) (*fleet.ExistingSoftwareInstaller, error) {
|
||
stmt := `
|
||
SELECT
|
||
si.id AS installer_id,
|
||
si.team_id AS team_id,
|
||
si.storage_id AS storage_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,
|
||
si.http_etag AS http_etag,
|
||
si.install_script_content_id AS install_script_content_id
|
||
FROM
|
||
software_installers si
|
||
JOIN software_titles st ON si.title_id = st.id
|
||
WHERE
|
||
si.url = ? AND si.is_active = 1`
|
||
|
||
args := []any{url}
|
||
if teamID != nil {
|
||
stmt += ` AND si.global_or_team_id = ?`
|
||
args = append(args, *teamID)
|
||
}
|
||
stmt += `
|
||
ORDER BY si.id DESC
|
||
LIMIT 1`
|
||
|
||
var installer fleet.ExistingSoftwareInstaller
|
||
// Use reader: the installer was written in a previous GitOps run. On rapid sequential
|
||
// runs, the replica may be slightly stale, which results in a cache miss (full download)
|
||
// rather than incorrect behavior. This is an acceptable trade-off vs loading the writer.
|
||
if err := sqlx.GetContext(ctx, ds.reader(ctx), &installer, stmt, args...); err != nil {
|
||
if err == sql.ErrNoRows {
|
||
return nil, nil
|
||
}
|
||
return nil, ctxerr.Wrap(ctx, err, "get installer by team and URL")
|
||
}
|
||
if installer.PackageIDList != "" {
|
||
installer.PackageIDs = strings.Split(installer.PackageIDList, ",")
|
||
}
|
||
return &installer, nil
|
||
}
|
||
|
||
func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error {
|
||
conflict := func(message string) error {
|
||
teamName, err := ds.getTeamName(ctx, payload.TeamID)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "get team for installer conflict error")
|
||
}
|
||
return ctxerr.Wrap(ctx, fleet.ConflictError{
|
||
Message: fmt.Sprintf(message, payload.Title, teamName),
|
||
}, "software conflicts with existing software on the title")
|
||
}
|
||
|
||
switch payload.Platform {
|
||
// currently, the platform will always be ios for .ipa files
|
||
case string(fleet.IOSPlatform), string(fleet.IPadOSPlatform):
|
||
// 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.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 conflict(fleet.SoftwareAlreadyHasVPPAppMessage)
|
||
}
|
||
|
||
// check if equivalent installers exist, duplicate in-house apps are checked in insertInHouseApp
|
||
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 conflict(fleet.SoftwareAlreadyHasPackageMessage)
|
||
}
|
||
}
|
||
case string(fleet.MacOSPlatform):
|
||
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 conflict(fleet.SoftwareAlreadyHasVPPAppMessage)
|
||
}
|
||
}
|
||
|
||
// custom packages and Fleet-maintained apps can't share a title
|
||
mixed, err := ds.checkFleetMaintainedAppExists(ctx, payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if mixed {
|
||
if payload.FleetMaintainedAppID != nil {
|
||
return conflict(fleet.SoftwareAlreadyHasPackageMessage)
|
||
}
|
||
return conflict(fleet.SoftwareAlreadyHasFleetMaintainedAppMessage)
|
||
}
|
||
|
||
if payload.FleetMaintainedAppID == nil {
|
||
titleID, err := ds.GetExistingSoftwareInstallerTitleID(ctx, payload)
|
||
if fleet.IsNotFound(err) {
|
||
if payload.TitleID != nil {
|
||
return &fleet.BadRequestError{Message: fmt.Sprintf(fleet.SoftwarePackageTitleMismatchMessage, payload.Filename)}
|
||
}
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if payload.TitleID != nil && titleID != *payload.TitleID {
|
||
return &fleet.BadRequestError{Message: fmt.Sprintf(fleet.SoftwarePackageTitleMismatchMessage, payload.Filename)}
|
||
}
|
||
|
||
// A package can't repeat the same bytes within its title. Scripts also dedupe
|
||
// team-wide in MatchOrCreateSoftwareInstaller.
|
||
var dup bool
|
||
err = sqlx.GetContext(ctx, ds.reader(ctx), &dup, `
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM software_installers
|
||
WHERE global_or_team_id = ? AND title_id = ? AND dedup_token = ?
|
||
)`, ptr.ValOrZero(payload.TeamID), titleID, payload.StorageID)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "check duplicate package by hash")
|
||
}
|
||
if dup {
|
||
return ctxerr.Wrap(ctx, fleet.ConflictError{
|
||
Message: fmt.Sprintf(fleet.SoftwarePackageHashConflictMessage, payload.Filename),
|
||
}, "duplicate package by hash")
|
||
}
|
||
|
||
// a title holds at most fleet.MaxPackagesPerTitle custom packages
|
||
var count int
|
||
err = sqlx.GetContext(ctx, ds.reader(ctx), &count, `
|
||
SELECT COUNT(*) FROM software_installers
|
||
WHERE global_or_team_id = ? AND title_id = ?`, ptr.ValOrZero(payload.TeamID), titleID)
|
||
if err != nil {
|
||
return ctxerr.Wrap(ctx, err, "count packages on the title")
|
||
}
|
||
if count >= fleet.MaxPackagesPerTitle {
|
||
return ctxerr.Wrap(ctx, fleet.ConflictError{
|
||
Message: fmt.Sprintf(fleet.SoftwarePackageLimitMessage, payload.Title, fleet.MaxPackagesPerTitle),
|
||
}, "package limit reached")
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (ds *Datastore) checkFleetMaintainedAppExists(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (bool, error) {
|
||
// look for the other kind of package on the title: an FMA when adding a custom
|
||
// package, a custom package when adding an FMA. Matched by bundle identifier on
|
||
// macOS and by name or upgrade code on Windows. FMAs only exist on those platforms.
|
||
wantFMA := payload.FleetMaintainedAppID == nil
|
||
var stmt string
|
||
var args []any
|
||
switch {
|
||
case payload.Platform == string(fleet.MacOSPlatform) && payload.BundleIdentifier != "":
|
||
stmt = `
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM software_installers si
|
||
JOIN software_titles st ON st.id = si.title_id
|
||
WHERE si.global_or_team_id = ? AND st.source = ? AND st.bundle_identifier = ?
|
||
AND (si.fleet_maintained_app_id IS NOT NULL) = ?
|
||
)`
|
||
args = []any{ptr.ValOrZero(payload.TeamID), payload.Source, payload.BundleIdentifier, wantFMA}
|
||
case payload.Platform == "windows":
|
||
stmt = `
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM software_installers si
|
||
JOIN software_titles st ON st.id = si.title_id
|
||
WHERE si.global_or_team_id = ? AND st.source = ? AND (st.name = ? OR (st.upgrade_code != '' AND st.upgrade_code = ?))
|
||
AND (si.fleet_maintained_app_id IS NOT NULL) = ?
|
||
)`
|
||
args = []any{ptr.ValOrZero(payload.TeamID), payload.Source, payload.Title, payload.UpgradeCode, wantFMA}
|
||
default:
|
||
return false, nil
|
||
}
|
||
|
||
var exists bool
|
||
if err := sqlx.GetContext(ctx, ds.reader(ctx), &exists, stmt, args...); err != nil {
|
||
return false, ctxerr.Wrap(ctx, err, "check fleet-maintained app exists")
|
||
}
|
||
return exists, nil
|
||
}
|
||
|
||
func (ds *Datastore) GetSoftwareTitlesForInstallAll(ctx context.Context, host *fleet.Host, categoryID *uint) ([]*fleet.HostSoftwareWithInstaller, *string, error) {
|
||
// get software category and check that it exists
|
||
var categoryName *string
|
||
if categoryID != nil {
|
||
cat, err := ds.SoftwareCategory(ctx, *categoryID)
|
||
if err != nil {
|
||
if fleet.IsNotFound(err) {
|
||
return nil, nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: "software category not found", InternalErr: err})
|
||
}
|
||
return nil, nil, ctxerr.Wrap(ctx, err, "get software category")
|
||
}
|
||
|
||
if cat.TeamID != ptr.ValOrZero(host.TeamID) {
|
||
return nil, nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: "software category team does not match host team"})
|
||
}
|
||
categoryName = &cat.Name
|
||
}
|
||
|
||
mdmEnrolled, err := ds.IsHostConnectedToFleetMDM(ctx, host)
|
||
if err != nil {
|
||
return nil, nil, ctxerr.Wrap(ctx, err, "checking host mdm enrollment for install all")
|
||
}
|
||
|
||
// call ListHostSoftware with only_available_for_install
|
||
opts := fleet.HostSoftwareTitleListOptions{
|
||
SelfServiceOnly: true,
|
||
OnlyAvailableForInstall: true,
|
||
IsMDMEnrolled: mdmEnrolled,
|
||
}
|
||
opts.ListOptions.OrderKey = "name"
|
||
|
||
software, _, err := ds.ListHostSoftware(ctx, host, opts)
|
||
if err != nil {
|
||
return nil, nil, ctxerr.Wrap(ctx, err, "list host software for install all")
|
||
}
|
||
|
||
// create map of category names, could be improved by using category id's instead
|
||
var categoriesByTitle map[uint][]string
|
||
if categoryID != nil {
|
||
titleIDs := make([]uint, 0, len(software))
|
||
for _, s := range software {
|
||
titleIDs = append(titleIDs, s.ID)
|
||
}
|
||
|
||
categoriesByTitle, err = ds.GetCategoriesForSoftwareTitles(ctx, titleIDs, host.TeamID)
|
||
if err != nil {
|
||
return nil, nil, ctxerr.Wrap(ctx, err, "get categories for software titles")
|
||
}
|
||
}
|
||
|
||
// filter out pending or already installed software, and software not in the category if one is provided.
|
||
// Failed install/uninstall states are included so they get re-queued (matches the per-row Retry behavior).
|
||
var toInstall []*fleet.HostSoftwareWithInstaller
|
||
for _, s := range software {
|
||
if s.Status != nil {
|
||
switch *s.Status {
|
||
case fleet.SoftwareInstallPending, fleet.SoftwareUninstallPending, fleet.SoftwareInstalled:
|
||
continue
|
||
}
|
||
}
|
||
|
||
// already installed
|
||
if len(s.InstalledVersions) > 0 {
|
||
continue
|
||
}
|
||
|
||
//nolint:nilaway // categoryName is set or we return an error whenever categoryID is not nil
|
||
if categoryID != nil && !slices.Contains(categoriesByTitle[s.ID], *categoryName) {
|
||
continue
|
||
}
|
||
|
||
toInstall = append(toInstall, s)
|
||
}
|
||
|
||
return toInstall, categoryName, nil
|
||
}
|
||
|
||
func (ds *Datastore) GetPinnedVersion(ctx context.Context, teamID *uint, titleID uint) (*string, error) {
|
||
var version string
|
||
err := sqlx.GetContext(ctx, ds.reader(ctx), &version, `
|
||
SELECT pinned_version FROM software_title_team_pins WHERE team_id = ? AND title_id = ?
|
||
`, ptr.ValOrZero(teamID), titleID)
|
||
if err != nil {
|
||
return nil, ctxerr.Wrap(ctx, err, "get pinned version")
|
||
}
|
||
return &version, nil
|
||
}
|
||
|
||
func (ds *Datastore) SetPinnedVersion(ctx context.Context, teamID *uint, titleID uint, version string) error {
|
||
if err := setPinnedVersionDB(ctx, ds.writer(ctx), ptr.ValOrZero(teamID), titleID, version); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "set pinned version")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (ds *Datastore) DeletePinnedVersion(ctx context.Context, teamID *uint, titleID uint) error {
|
||
if err := deletePinnedVersionDB(ctx, ds.writer(ctx), ptr.ValOrZero(teamID), titleID); err != nil {
|
||
return ctxerr.Wrap(ctx, err, "delete pinned version")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func setPinnedVersionDB(ctx context.Context, ex sqlx.ExtContext, globalOrTeamID uint, titleID uint, version string) error {
|
||
_, err := ex.ExecContext(ctx, `
|
||
INSERT INTO software_title_team_pins (team_id, title_id, pinned_version)
|
||
VALUES (?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE pinned_version = VALUES(pinned_version)
|
||
`, globalOrTeamID, titleID, version)
|
||
return err
|
||
}
|
||
|
||
func deletePinnedVersionDB(ctx context.Context, ex sqlx.ExtContext, globalOrTeamID uint, titleID uint) error {
|
||
_, err := ex.ExecContext(ctx, `
|
||
DELETE FROM software_title_team_pins WHERE team_id = ? AND title_id = ?
|
||
`, globalOrTeamID, titleID)
|
||
return err
|
||
}
|