mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 00:49:03 +00:00
Fixes #32740 Fixes iDevice VPP apps showing as "installed" even when the device has an outdated version after clicking "Update".
1560 lines
49 KiB
Go
1560 lines
49 KiB
Go
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"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/mdm/nanomdm/mdm"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/go-kit/log/level"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
func (ds *Datastore) insertInHouseApp(ctx context.Context, payload *fleet.InHouseAppPayload) (uint, uint, error) {
|
|
selectStmt := `SELECT COUNT(id) FROM in_house_apps WHERE global_or_team_id = ? AND (bundle_identifier = ? OR filename = ?)`
|
|
|
|
var tid *uint
|
|
var globalOrTeamID uint
|
|
if payload.TeamID != nil {
|
|
globalOrTeamID = *payload.TeamID
|
|
|
|
if *payload.TeamID > 0 {
|
|
tid = payload.TeamID
|
|
}
|
|
}
|
|
|
|
titleIDipad, err := ds.getOrGenerateInHouseAppTitleID(ctx, payload.Title, payload.BundleID, "ipados_apps")
|
|
if err != nil {
|
|
return 0, 0, ctxerr.Wrap(ctx, err, "generating software title")
|
|
}
|
|
titleIDios, err := ds.getOrGenerateInHouseAppTitleID(ctx, payload.Title, payload.BundleID, "ios_apps")
|
|
if err != nil {
|
|
return 0, 0, ctxerr.Wrap(ctx, err, "generating software title")
|
|
}
|
|
|
|
var installerID uint
|
|
var count uint
|
|
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
|
row := tx.QueryRowxContext(ctx, selectStmt, globalOrTeamID, payload.BundleID, payload.Filename)
|
|
if err := row.Scan(&count); err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
// ios or ipados version of this installer exists
|
|
teamName, err := ds.getTeamName(ctx, payload.TeamID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
return alreadyExists("In-house app", payload.Filename).WithTeamName(teamName)
|
|
}
|
|
|
|
argsIos := []any{tid, globalOrTeamID, payload.Filename, payload.StorageID, payload.Version, payload.BundleID, titleIDios, "ios", payload.SelfService}
|
|
argsIpad := []any{tid, globalOrTeamID, payload.Filename, payload.StorageID, payload.Version, payload.BundleID, titleIDipad, "ipados", payload.SelfService}
|
|
|
|
_, err := ds.insertInHouseAppDB(ctx, tx, payload, argsIpad)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
installerID, err = ds.insertInHouseAppDB(ctx, tx, payload, argsIos)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
return installerID, titleIDios, ctxerr.Wrap(ctx, err, "insertInHouseApp")
|
|
}
|
|
|
|
func (ds *Datastore) getOrGenerateInHouseAppTitleID(ctx context.Context, name string, bundleID string, source string) (uint, error) {
|
|
selectStmt := `SELECT id FROM software_titles WHERE bundle_identifier = ? AND source = ?`
|
|
selectArgs := []any{bundleID, source}
|
|
insertStmt := `INSERT INTO software_titles (name, source, bundle_identifier, extension_for) VALUES (?, ?, ?, '')`
|
|
insertArgs := []any{name, source, bundleID}
|
|
|
|
titleID, err := ds.optimisticGetOrInsert(ctx,
|
|
¶meterizedStmt{
|
|
Statement: selectStmt,
|
|
Args: selectArgs,
|
|
},
|
|
¶meterizedStmt{
|
|
Statement: insertStmt,
|
|
Args: insertArgs,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return titleID, nil
|
|
}
|
|
|
|
func (ds *Datastore) insertInHouseAppDB(ctx context.Context, tx sqlx.ExtContext, payload *fleet.InHouseAppPayload, args []any) (uint, error) {
|
|
stmt := `
|
|
INSERT INTO in_house_apps (
|
|
team_id,
|
|
global_or_team_id,
|
|
filename,
|
|
storage_id,
|
|
version,
|
|
bundle_identifier,
|
|
title_id,
|
|
platform,
|
|
self_service
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
|
|
res, err := tx.ExecContext(ctx, stmt, args...)
|
|
if err != nil {
|
|
if IsDuplicate(err) {
|
|
teamName, err := ds.getTeamName(ctx, payload.TeamID)
|
|
if err != nil {
|
|
return 0, ctxerr.Wrap(ctx, err)
|
|
}
|
|
err = alreadyExists("In-house app", payload.Filename).WithTeamName(teamName)
|
|
return 0, ctxerr.Wrap(ctx, err, "insertInHouseAppDB")
|
|
}
|
|
return 0, ctxerr.Wrap(ctx, err, "insertInHouseAppDB")
|
|
}
|
|
id64, err := res.LastInsertId()
|
|
installerID := uint(id64) //nolint:gosec // dismiss G115
|
|
if err != nil {
|
|
return 0, ctxerr.Wrap(ctx, err, "insertInHouseAppDB")
|
|
}
|
|
|
|
if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, installerID, *payload.ValidatedLabels, softwareTypeInHouseApp); err != nil {
|
|
return 0, ctxerr.Wrap(ctx, err, "insertInHouseAppDB")
|
|
}
|
|
|
|
if payload.CategoryIDs != nil {
|
|
if err := setOrUpdateSoftwareInstallerCategoriesDB(ctx, tx, installerID, payload.CategoryIDs, softwareTypeInHouseApp); err != nil {
|
|
return 0, ctxerr.Wrap(ctx, err, "upsert in house apps categories")
|
|
}
|
|
}
|
|
|
|
return installerID, nil
|
|
}
|
|
|
|
// hihsiAlias is the table alias to use as prefix for the
|
|
// host_in_house_software_installs column names, no prefix used if empty.
|
|
// ncrAlias is the table alias to use as prefix for the nano_command_results
|
|
// column names, no prefix used if empty.
|
|
// colAlias is the name to be assigned to the computed status column, pass
|
|
// empty to have the value only, no column alias set.
|
|
func inHouseAppHostStatusNamedQuery(hihsiAlias, ncrAlias, colAlias string) string {
|
|
if hihsiAlias != "" {
|
|
hihsiAlias += "."
|
|
}
|
|
if ncrAlias != "" {
|
|
ncrAlias += "."
|
|
}
|
|
if colAlias != "" {
|
|
colAlias = " AS " + colAlias
|
|
}
|
|
|
|
return fmt.Sprintf(`
|
|
CASE
|
|
WHEN %sverification_at IS NOT NULL THEN
|
|
:software_status_installed
|
|
WHEN %sverification_failed_at IS NOT NULL THEN
|
|
:software_status_failed
|
|
WHEN %sstatus = :mdm_status_error OR %sstatus = :mdm_status_format_error THEN
|
|
:software_status_failed
|
|
ELSE
|
|
:software_status_pending
|
|
END %s
|
|
`, hihsiAlias, hihsiAlias, ncrAlias, ncrAlias, colAlias)
|
|
}
|
|
|
|
func (ds *Datastore) GetInHouseAppMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) {
|
|
query := `
|
|
SELECT
|
|
iha.id,
|
|
iha.team_id,
|
|
iha.title_id,
|
|
iha.filename,
|
|
iha.platform,
|
|
iha.storage_id,
|
|
iha.version,
|
|
iha.created_at AS uploaded_at,
|
|
st.bundle_identifier AS bundle_identifier,
|
|
COALESCE(st.name, '') AS software_title,
|
|
iha.self_service,
|
|
iha.url
|
|
FROM
|
|
in_house_apps iha
|
|
JOIN software_titles st ON st.id = iha.title_id
|
|
WHERE
|
|
iha.title_id = ? AND iha.global_or_team_id = ?`
|
|
|
|
var tmID uint
|
|
if teamID != nil {
|
|
tmID = *teamID
|
|
}
|
|
|
|
var dest fleet.SoftwareInstaller
|
|
err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, titleID, tmID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, ctxerr.Wrap(ctx, notFound("InHouseApp"), "get in house app metadata")
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, err, "get in house app metadata")
|
|
}
|
|
dest.Extension = "ipa"
|
|
|
|
labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID, softwareTypeInHouseApp)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get in house app labels")
|
|
}
|
|
var exclAny, inclAny []fleet.SoftwareScopeLabel
|
|
for _, l := range labels {
|
|
if l.Exclude {
|
|
exclAny = append(exclAny, l)
|
|
} else {
|
|
inclAny = append(inclAny, l)
|
|
}
|
|
}
|
|
|
|
if len(inclAny) > 0 && len(exclAny) > 0 {
|
|
level.Warn(ds.logger).Log("msg", "in house app has both include and exclude labels", "installer_id", dest.InstallerID, "include", fmt.Sprintf("%v", inclAny), "exclude", fmt.Sprintf("%v", exclAny))
|
|
}
|
|
dest.LabelsExcludeAny = exclAny
|
|
dest.LabelsIncludeAny = inclAny
|
|
|
|
categoryMap, err := ds.GetCategoriesForSoftwareTitles(ctx, []uint{titleID}, teamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting categories for in house app 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 in house app display name")
|
|
}
|
|
|
|
dest.DisplayName = displayName
|
|
|
|
if teamID != nil {
|
|
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) SaveInHouseAppUpdates(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) error {
|
|
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
|
stmt := `UPDATE in_house_apps SET
|
|
storage_id = ?,
|
|
filename = ?,
|
|
version = ?,
|
|
-- keep current value if provided arg is nil
|
|
self_service = COALESCE(?, self_service)
|
|
WHERE id = ?`
|
|
|
|
args := []any{
|
|
payload.StorageID,
|
|
payload.Filename,
|
|
payload.Version,
|
|
payload.SelfService,
|
|
payload.InstallerID,
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
|
if IsDuplicate(err) {
|
|
teamName, err := ds.getTeamName(ctx, payload.TeamID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
return alreadyExists("In-house app", payload.Filename).WithTeamName(teamName)
|
|
}
|
|
return ctxerr.Wrap(ctx, err, "update in house app")
|
|
}
|
|
|
|
if payload.ValidatedLabels != nil {
|
|
if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, payload.InstallerID, *payload.ValidatedLabels, softwareTypeInHouseApp); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "upsert in house app labels")
|
|
}
|
|
}
|
|
|
|
if payload.CategoryIDs != nil {
|
|
if err := setOrUpdateSoftwareInstallerCategoriesDB(ctx, tx, payload.InstallerID, payload.CategoryIDs, softwareTypeInHouseApp); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "upsert in house app categories")
|
|
}
|
|
}
|
|
|
|
if payload.DisplayName != nil {
|
|
if err := updateSoftwareTitleDisplayName(ctx, tx, payload.TeamID, payload.TitleID, *payload.DisplayName); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "update in house app display name")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "update in house app")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ds *Datastore) DeleteInHouseApp(ctx context.Context, id uint) error {
|
|
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
|
err := ds.RemovePendingInHouseAppInstalls(ctx, id)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return ctxerr.Wrap(ctx, err, "delete in house app: remove pending in house app installs")
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx, `DELETE FROM software_title_display_names WHERE (software_title_id, team_id) IN
|
|
(SELECT title_id, global_or_team_id FROM in_house_apps WHERE id = ?)`, id)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete software title display name")
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx, `DELETE FROM in_house_apps WHERE id = ?`, id)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete in house app")
|
|
}
|
|
return nil
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (ds *Datastore) RemovePendingInHouseAppInstalls(ctx context.Context, inHouseAppID uint) error {
|
|
type ipaInstall struct {
|
|
HostID uint `db:"host_id"`
|
|
ExecutionID string `db:"command_uuid"`
|
|
}
|
|
var installs []ipaInstall
|
|
err := sqlx.SelectContext(ctx, ds.reader(ctx), &installs, `
|
|
SELECT
|
|
host_id,
|
|
command_uuid
|
|
FROM
|
|
host_in_house_software_installs
|
|
WHERE
|
|
in_house_app_id = ? AND
|
|
canceled = 0 AND
|
|
verification_at IS NULL AND
|
|
verification_failed_at IS NULL
|
|
`, inHouseAppID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, in := range installs {
|
|
_, err := ds.CancelHostUpcomingActivity(ctx, in.HostID, in.ExecutionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ds *Datastore) GetSummaryHostInHouseAppInstalls(ctx context.Context, teamID *uint, inHouseAppID uint) (*fleet.VPPAppStatusSummary, error) {
|
|
var dest fleet.VPPAppStatusSummary // Using the vpp struct since it is more appropriate for ipa
|
|
stmt := `
|
|
WITH
|
|
-- select most recent upcoming activities for each host
|
|
upcoming AS (
|
|
SELECT
|
|
ua.host_id,
|
|
:software_status_pending AS status
|
|
FROM
|
|
upcoming_activities ua
|
|
JOIN in_house_app_upcoming_activities ihaua ON ua.id = ihaua.upcoming_activity_id
|
|
JOIN hosts h ON host_id = h.id
|
|
LEFT JOIN (
|
|
upcoming_activities ua2
|
|
INNER JOIN in_house_app_upcoming_activities ihaua2
|
|
ON ua2.id = ihaua2.upcoming_activity_id
|
|
) ON ua.host_id = ua2.host_id AND
|
|
ihaua.in_house_app_id = ihaua2.in_house_app_id AND
|
|
ua.activity_type = ua2.activity_type AND
|
|
(ua2.priority < ua.priority OR ua2.created_at > ua.created_at)
|
|
WHERE
|
|
ua.activity_type = 'in_house_app_install'
|
|
AND ua2.id IS NULL
|
|
AND ihaua.in_house_app_id = :in_house_app_id
|
|
AND (h.team_id = :team_id OR (h.team_id IS NULL AND :team_id = 0))
|
|
),
|
|
|
|
-- select most recent past activities for each host
|
|
-- NOTE if you change this logic make sure to change inHouseAppHostStatusNamedQuery accordingly
|
|
past AS (
|
|
SELECT
|
|
hihsi.host_id,
|
|
CASE
|
|
WHEN hihsi.verification_at IS NOT NULL THEN
|
|
:software_status_installed
|
|
WHEN hihsi.verification_failed_at IS NOT NULL THEN
|
|
:software_status_failed
|
|
WHEN ncr.status = :mdm_status_error OR ncr.status = :mdm_status_format_error THEN
|
|
:software_status_failed
|
|
WHEN ncr.status = :mdm_status_acknowledged THEN
|
|
:software_status_pending
|
|
ELSE
|
|
NULL -- either pending or not installed via in-house App
|
|
END AS status
|
|
FROM
|
|
host_in_house_software_installs hihsi
|
|
JOIN hosts h ON host_id = h.id
|
|
JOIN nano_command_results ncr ON ncr.id = h.uuid AND 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.removed = 0 AND
|
|
hihsi2.canceled = 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 (h.team_id = :team_id OR (h.team_id IS NULL AND :team_id = 0))
|
|
AND hihsi.host_id NOT IN (SELECT host_id FROM upcoming) -- antijoin to exclude hosts with upcoming activities
|
|
AND hihsi.removed = 0
|
|
AND hihsi.canceled = 0
|
|
)
|
|
|
|
-- count each status
|
|
SELECT
|
|
COALESCE(SUM( IF(status = :software_status_pending, 1, 0)), 0) AS pending,
|
|
COALESCE(SUM( IF(status = :software_status_failed, 1, 0)), 0) AS failed,
|
|
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`
|
|
|
|
var tmID uint
|
|
if teamID != nil {
|
|
tmID = *teamID
|
|
}
|
|
|
|
query, args, err := sqlx.Named(stmt, map[string]any{
|
|
"in_house_app_id": inHouseAppID,
|
|
"team_id": tmID,
|
|
"mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged,
|
|
"mdm_status_error": fleet.MDMAppleStatusError,
|
|
"mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError,
|
|
"software_status_pending": fleet.SoftwarePending,
|
|
"software_status_failed": fleet.SoftwareFailed,
|
|
"software_status_installed": fleet.SoftwareInstalled,
|
|
})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get summary host in house app 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 in house install status")
|
|
}
|
|
return &dest, nil
|
|
}
|
|
|
|
func (ds *Datastore) IsInHouseAppLabelScoped(ctx context.Context, inHouseAppID, hostID uint) (bool, error) {
|
|
return ds.isSoftwareLabelScoped(ctx, inHouseAppID, hostID, softwareTypeInHouseApp)
|
|
}
|
|
|
|
func (ds *Datastore) InsertHostInHouseAppInstall(ctx context.Context, hostID uint, inHouseAppID, softwareTitleID uint, commandUUID string, opts fleet.HostSoftwareInstallOptions) error {
|
|
const (
|
|
insertUAStmt = `
|
|
INSERT INTO upcoming_activities
|
|
(host_id, priority, user_id, fleet_initiated, activity_type, execution_id, payload)
|
|
VALUES
|
|
(?, ?, ?, ?, 'in_house_app_install', ?,
|
|
JSON_OBJECT(
|
|
'self_service', ?,
|
|
'user', (SELECT JSON_OBJECT('name', name, 'email', email, 'gravatar_url', gravatar_url) FROM users WHERE id = ?)
|
|
)
|
|
)`
|
|
|
|
insertIHAUAStmt = `
|
|
INSERT INTO in_house_app_upcoming_activities
|
|
(upcoming_activity_id, in_house_app_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 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,
|
|
opts.Priority(),
|
|
userID,
|
|
opts.IsFleetInitiated(),
|
|
commandUUID,
|
|
opts.SelfService,
|
|
userID,
|
|
)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "insert in house app install request")
|
|
}
|
|
|
|
activityID, _ := res.LastInsertId()
|
|
_, err = tx.ExecContext(ctx, insertIHAUAStmt,
|
|
activityID,
|
|
inHouseAppID,
|
|
softwareTitleID,
|
|
)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "insert in house app install request join table")
|
|
}
|
|
|
|
if _, err := ds.activateNextUpcomingActivity(ctx, tx, hostID, ""); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "activate next activity")
|
|
}
|
|
return nil
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (ds *Datastore) SetInHouseAppInstallAsVerified(ctx context.Context, hostID uint, installUUID, verificationUUID string) error {
|
|
stmt := `
|
|
UPDATE host_in_house_software_installs
|
|
SET verification_at = CURRENT_TIMESTAMP(6),
|
|
verification_command_uuid = ?
|
|
WHERE command_uuid = ?
|
|
`
|
|
|
|
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
|
if _, err := tx.ExecContext(ctx, stmt, verificationUUID, installUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "set in house app install as verified")
|
|
}
|
|
|
|
if _, err := ds.activateNextUpcomingActivity(ctx, tx, hostID, installUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "activate next activity from in house app install verify")
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (ds *Datastore) SetInHouseAppInstallAsFailed(ctx context.Context, hostID uint, installUUID, verificationUUID string) error {
|
|
stmt := `
|
|
UPDATE host_in_house_software_installs
|
|
SET verification_failed_at = CURRENT_TIMESTAMP(6),
|
|
verification_command_uuid = ?
|
|
WHERE command_uuid = ?
|
|
`
|
|
|
|
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
|
|
if _, err := tx.ExecContext(ctx, stmt, verificationUUID, installUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "set in house app install as failed")
|
|
}
|
|
|
|
if _, err := ds.activateNextUpcomingActivity(ctx, tx, hostID, installUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "activate next activity from in house app install failed")
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (ds *Datastore) ReplaceInHouseAppInstallVerificationUUID(ctx context.Context, oldVerifyUUID, verifyCommandUUID string) error {
|
|
stmt := `
|
|
UPDATE host_in_house_software_installs
|
|
SET verification_command_uuid = ?
|
|
WHERE verification_command_uuid = ?
|
|
`
|
|
|
|
if _, err := ds.writer(ctx).ExecContext(ctx, stmt, verifyCommandUUID, oldVerifyUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "update in-house app install verification command")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ds *Datastore) GetUnverifiedInHouseAppInstallsForHost(ctx context.Context, hostUUID string) ([]*fleet.HostVPPSoftwareInstall, error) {
|
|
stmt := `
|
|
SELECT
|
|
hihsi.host_id AS host_id,
|
|
hihsi.command_uuid AS command_uuid,
|
|
ncr.updated_at AS ack_at,
|
|
ncr.status AS install_command_status,
|
|
iha.bundle_identifier AS bundle_identifier,
|
|
iha.version AS expected_version
|
|
FROM nano_command_results ncr
|
|
JOIN host_in_house_software_installs hihsi ON hihsi.command_uuid = ncr.command_uuid
|
|
JOIN in_house_apps iha ON iha.id = hihsi.in_house_app_id AND iha.platform = hihsi.platform
|
|
WHERE ncr.id = ?
|
|
AND ncr.status = 'Acknowledged'
|
|
AND hihsi.verification_at IS NULL
|
|
AND hihsi.verification_failed_at IS NULL
|
|
`
|
|
|
|
var result []*fleet.HostVPPSoftwareInstall
|
|
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &result, stmt, hostUUID); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get unverified in-house app installs for host")
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (ds *Datastore) GetPastActivityDataForInHouseAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*fleet.User, *fleet.ActivityTypeInstalledSoftware, error) {
|
|
if commandResults == nil {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
stmt := `
|
|
SELECT
|
|
u.name AS user_name,
|
|
u.id AS user_id,
|
|
u.email as user_email,
|
|
hihsi.host_id AS host_id,
|
|
hdn.display_name AS host_display_name,
|
|
st.name AS software_title,
|
|
hihsi.command_uuid AS command_uuid,
|
|
hihsi.self_service AS self_service
|
|
FROM
|
|
host_in_house_software_installs hihsi
|
|
LEFT OUTER JOIN users u ON hihsi.user_id = u.id
|
|
LEFT OUTER JOIN host_display_names hdn ON hdn.host_id = hihsi.host_id
|
|
LEFT OUTER JOIN in_house_apps iha ON hihsi.in_house_app_id = iha.id
|
|
LEFT OUTER JOIN software_titles st ON st.id = iha.title_id
|
|
WHERE
|
|
hihsi.command_uuid = :command_uuid AND
|
|
hihsi.canceled = 0
|
|
`
|
|
|
|
type result struct {
|
|
HostID uint `db:"host_id"`
|
|
HostDisplayName string `db:"host_display_name"`
|
|
SoftwareTitle string `db:"software_title"`
|
|
CommandUUID string `db:"command_uuid"`
|
|
UserName *string `db:"user_name"`
|
|
UserID *uint `db:"user_id"`
|
|
UserEmail *string `db:"user_email"`
|
|
SelfService bool `db:"self_service"`
|
|
}
|
|
|
|
listStmt, args, err := sqlx.Named(stmt, map[string]any{
|
|
"command_uuid": commandResults.CommandUUID,
|
|
"software_status_failed": string(fleet.SoftwareInstallFailed),
|
|
"software_status_installed": string(fleet.SoftwareInstalled),
|
|
})
|
|
if err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "build list query from named args")
|
|
}
|
|
|
|
var res result
|
|
if err := sqlx.GetContext(ctx, ds.reader(ctx), &res, listStmt, args...); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil, notFound("install_command")
|
|
}
|
|
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "select past activity data for in-house app install")
|
|
}
|
|
|
|
var user *fleet.User
|
|
if res.UserID != nil {
|
|
user = &fleet.User{
|
|
ID: *res.UserID,
|
|
Name: *res.UserName,
|
|
Email: *res.UserEmail,
|
|
}
|
|
}
|
|
|
|
var status string
|
|
switch commandResults.Status {
|
|
case fleet.MDMAppleStatusAcknowledged:
|
|
status = string(fleet.SoftwareInstalled)
|
|
case fleet.MDMAppleStatusCommandFormatError, fleet.MDMAppleStatusError:
|
|
status = string(fleet.SoftwareInstallFailed)
|
|
default:
|
|
// This case shouldn't happen (we should only be doing this check if the command is in a
|
|
// "terminal" state, but adding it so we have a default
|
|
status = string(fleet.SoftwareInstallPending)
|
|
}
|
|
|
|
act := &fleet.ActivityTypeInstalledSoftware{
|
|
HostID: res.HostID,
|
|
HostDisplayName: res.HostDisplayName,
|
|
SoftwareTitle: res.SoftwareTitle,
|
|
CommandUUID: res.CommandUUID,
|
|
Status: status,
|
|
SelfService: res.SelfService,
|
|
}
|
|
|
|
return user, act, nil
|
|
}
|
|
|
|
func (ds *Datastore) BatchSetInHouseAppsInstallers(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error {
|
|
const upsertSoftwareTitles = `
|
|
INSERT INTO software_titles
|
|
(name, source, extension_for, bundle_identifier)
|
|
VALUES
|
|
%s
|
|
ON DUPLICATE KEY UPDATE
|
|
name = VALUES(name),
|
|
source = VALUES(source),
|
|
extension_for = VALUES(extension_for),
|
|
bundle_identifier = VALUES(bundle_identifier)
|
|
`
|
|
|
|
const loadSoftwareTitles = `
|
|
SELECT
|
|
id
|
|
FROM
|
|
software_titles
|
|
WHERE (unique_identifier, source, extension_for) IN (%s)
|
|
`
|
|
|
|
const getSoftwareTitle = `
|
|
SELECT
|
|
id
|
|
FROM
|
|
software_titles
|
|
WHERE
|
|
unique_identifier = ? AND source = ? AND extension_for = ''
|
|
`
|
|
|
|
const cancelAllPendingInHouseInstalls = `
|
|
UPDATE
|
|
host_in_house_software_installs
|
|
SET
|
|
canceled = 1
|
|
WHERE
|
|
verification_at IS NULL AND
|
|
verification_failed_at IS NULL AND
|
|
in_house_app_id IN (
|
|
SELECT id FROM in_house_apps WHERE global_or_team_id = ?
|
|
)
|
|
`
|
|
|
|
const cancelAllPendingInHouseNanoCmds = `
|
|
UPDATE
|
|
nano_enrollment_queue
|
|
SET
|
|
active = 0
|
|
WHERE
|
|
command_uuid IN (
|
|
SELECT command_uuid
|
|
FROM host_in_house_software_installs hihsi
|
|
INNER JOIN in_house_apps iha ON hihsi.in_house_app_id = iha.id
|
|
WHERE
|
|
hihsi.verification_at IS NULL AND
|
|
hihsi.verification_failed_at IS NULL AND
|
|
iha.global_or_team_id = ?
|
|
)
|
|
`
|
|
const loadAffectedHostsPendingInHouseInstallsUA = `
|
|
SELECT
|
|
DISTINCT host_id
|
|
FROM
|
|
upcoming_activities ua
|
|
INNER JOIN in_house_app_upcoming_activities ihua
|
|
ON ua.id = ihua.upcoming_activity_id
|
|
WHERE
|
|
ua.activity_type = 'in_house_app_install' AND
|
|
ua.activated_at IS NOT NULL AND
|
|
ihua.in_house_app_id IN (
|
|
SELECT id FROM in_house_apps WHERE global_or_team_id = ?
|
|
)
|
|
`
|
|
|
|
const deleteAllPendingInHouseInstallsUA = `
|
|
DELETE FROM upcoming_activities
|
|
USING upcoming_activities
|
|
INNER JOIN in_house_app_upcoming_activities ihua
|
|
ON upcoming_activities.id = ihua.upcoming_activity_id
|
|
WHERE
|
|
activity_type = 'in_house_app_install' AND
|
|
ihua.in_house_app_id IN (
|
|
SELECT id FROM in_house_apps WHERE global_or_team_id = ?
|
|
)
|
|
`
|
|
const markAllInHouseInstallsAsRemoved = `
|
|
UPDATE host_in_house_software_installs SET removed = TRUE
|
|
WHERE in_house_app_id IN (
|
|
SELECT id FROM in_house_apps WHERE global_or_team_id = ?
|
|
)
|
|
`
|
|
|
|
const deleteAllInHouseInstallersInTeam = `
|
|
DELETE FROM
|
|
in_house_apps
|
|
WHERE
|
|
global_or_team_id = ?
|
|
`
|
|
|
|
const cancelPendingInHouseInstallsNotInList = `
|
|
UPDATE
|
|
host_in_house_software_installs
|
|
SET
|
|
canceled = 1
|
|
WHERE
|
|
verification_at IS NULL AND
|
|
verification_failed_at IS NULL AND
|
|
in_house_app_id IN (
|
|
SELECT id FROM in_house_apps WHERE global_or_team_id = ? AND title_id NOT IN (?)
|
|
)
|
|
`
|
|
|
|
const cancelPendingInHouseNanoCmdsNotInList = `
|
|
UPDATE
|
|
nano_enrollment_queue
|
|
SET
|
|
active = 0
|
|
WHERE
|
|
command_uuid IN (
|
|
SELECT command_uuid
|
|
FROM host_in_house_software_installs hihsi
|
|
INNER JOIN in_house_apps iha ON hihsi.in_house_app_id = iha.id
|
|
WHERE
|
|
hihsi.verification_at IS NULL AND
|
|
hihsi.verification_failed_at IS NULL AND
|
|
iha.global_or_team_id = ? AND
|
|
iha.title_id NOT IN (?)
|
|
)
|
|
`
|
|
|
|
const loadAffectedHostsPendingInHouseInstallsNotInListUA = `
|
|
SELECT
|
|
DISTINCT host_id
|
|
FROM
|
|
upcoming_activities ua
|
|
INNER JOIN in_house_app_upcoming_activities ihua
|
|
ON ua.id = ihua.upcoming_activity_id
|
|
WHERE
|
|
ua.activity_type = 'in_house_app_install' AND
|
|
ua.activated_at IS NOT NULL AND
|
|
ihua.in_house_app_id IN (
|
|
SELECT id FROM in_house_apps WHERE global_or_team_id = ? AND title_id NOT IN (?)
|
|
)
|
|
`
|
|
|
|
const deletePendingInHouseInstallsNotInListUA = `
|
|
DELETE FROM upcoming_activities
|
|
USING upcoming_activities
|
|
INNER JOIN in_house_app_upcoming_activities ihua
|
|
ON upcoming_activities.id = ihua.upcoming_activity_id
|
|
WHERE
|
|
activity_type = 'in_house_app_install' AND
|
|
ihua.in_house_app_id IN (
|
|
SELECT id FROM in_house_apps WHERE global_or_team_id = ? AND title_id NOT IN (?)
|
|
)
|
|
`
|
|
|
|
const markInHouseInstallsNotInListAsRemoved = `
|
|
UPDATE host_in_house_software_installs SET removed = TRUE
|
|
WHERE in_house_app_id IN (
|
|
SELECT id FROM in_house_apps WHERE global_or_team_id = ? AND title_id NOT IN (?)
|
|
)
|
|
`
|
|
|
|
const deleteInHouseInstallersNotInList = `
|
|
DELETE FROM
|
|
in_house_apps
|
|
WHERE
|
|
global_or_team_id = ? AND
|
|
title_id NOT IN (?)
|
|
`
|
|
|
|
const checkExistingInstaller = `
|
|
SELECT
|
|
id,
|
|
storage_id != ? is_package_modified
|
|
FROM
|
|
in_house_apps
|
|
WHERE
|
|
global_or_team_id = ? AND
|
|
title_id = ?
|
|
`
|
|
|
|
const insertNewOrEditedInstaller = `
|
|
INSERT INTO in_house_apps (
|
|
title_id,
|
|
team_id,
|
|
global_or_team_id,
|
|
filename,
|
|
version,
|
|
storage_id,
|
|
platform,
|
|
bundle_identifier,
|
|
self_service,
|
|
url
|
|
) VALUES (
|
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
)
|
|
ON DUPLICATE KEY UPDATE
|
|
filename = VALUES(filename),
|
|
version = VALUES(version),
|
|
storage_id = VALUES(storage_id),
|
|
platform = VALUES(platform),
|
|
bundle_identifier = VALUES(bundle_identifier),
|
|
self_service = VALUES(self_service),
|
|
url = VALUES(url)
|
|
`
|
|
|
|
const loadInHouseInstallerID = `
|
|
SELECT
|
|
id
|
|
FROM
|
|
in_house_apps
|
|
WHERE
|
|
-- this is guaranteed to select a single in-house installer, due to unique index
|
|
global_or_team_id = ? AND
|
|
filename = ? AND
|
|
platform = ?
|
|
`
|
|
|
|
const deleteInHouseLabelsNotInList = `
|
|
DELETE FROM
|
|
in_house_app_labels
|
|
WHERE
|
|
in_house_app_id = ? AND
|
|
label_id NOT IN (?)
|
|
`
|
|
|
|
const deleteAllInHouseLabels = `
|
|
DELETE FROM
|
|
in_house_app_labels
|
|
WHERE
|
|
in_house_app_id = ?
|
|
`
|
|
|
|
const upsertInHouseLabels = `
|
|
INSERT INTO
|
|
in_house_app_labels (
|
|
in_house_app_id,
|
|
label_id,
|
|
exclude
|
|
)
|
|
VALUES
|
|
%s
|
|
ON DUPLICATE KEY UPDATE
|
|
exclude = VALUES(exclude)
|
|
`
|
|
|
|
const loadExistingInHouseLabels = `
|
|
SELECT
|
|
label_id,
|
|
exclude
|
|
FROM
|
|
in_house_app_labels
|
|
WHERE
|
|
in_house_app_id = ?
|
|
`
|
|
|
|
const deleteAllInHouseCategories = `
|
|
DELETE FROM
|
|
in_house_app_software_categories
|
|
WHERE
|
|
in_house_app_id = ?
|
|
`
|
|
|
|
const deleteInHouseCategoriesNotInList = `
|
|
DELETE FROM
|
|
in_house_app_software_categories
|
|
WHERE
|
|
in_house_app_id = ? AND
|
|
software_category_id NOT IN (?)
|
|
`
|
|
|
|
const upsertInHouseCategories = `
|
|
INSERT IGNORE INTO
|
|
in_house_app_software_categories (
|
|
in_house_app_id,
|
|
software_category_id
|
|
)
|
|
VALUES
|
|
%s
|
|
`
|
|
|
|
const getDisplayNamesForTeam = `
|
|
SELECT
|
|
stdn.software_title_id, stdn.display_name
|
|
FROM
|
|
software_title_display_names stdn
|
|
INNER JOIN
|
|
in_house_apps iha ON stdn.software_title_id = iha.title_id AND stdn.team_id = iha.global_or_team_id
|
|
WHERE
|
|
stdn.team_id = ?
|
|
`
|
|
|
|
const deleteDisplayNamesNotInList = `
|
|
DELETE
|
|
stdn
|
|
FROM
|
|
software_title_display_names stdn
|
|
INNER JOIN
|
|
in_house_apps iha ON stdn.software_title_id = iha.title_id AND stdn.team_id = iha.global_or_team_id
|
|
WHERE
|
|
stdn.team_id = ? AND stdn.software_title_id NOT IN (?)
|
|
`
|
|
|
|
// use a team id of 0 if no-team
|
|
var globalOrTeamID uint
|
|
if tmID != nil {
|
|
globalOrTeamID = *tmID
|
|
}
|
|
|
|
// NOTE: at the time of implementation, in-house apps do not support install
|
|
// during setup, automatic install (via policies), and uninstalls,
|
|
// so the related validations and updates that are done in
|
|
// BatchSetSoftwareInstallers are removed here.
|
|
|
|
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, cancelAllPendingInHouseInstalls, globalOrTeamID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "cancel all pending host in-house install records")
|
|
}
|
|
if _, err := tx.ExecContext(ctx, cancelAllPendingInHouseNanoCmds, globalOrTeamID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "cancel all pending in-house nano commands")
|
|
}
|
|
|
|
var affectedHostIDs []uint
|
|
if err := sqlx.SelectContext(ctx, tx, &affectedHostIDs,
|
|
loadAffectedHostsPendingInHouseInstallsUA, globalOrTeamID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "load affected hosts for upcoming in-house installs")
|
|
}
|
|
activateAffectedHostIDs = affectedHostIDs
|
|
|
|
if _, err := tx.ExecContext(ctx, deleteAllPendingInHouseInstallsUA, globalOrTeamID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete all upcoming pending in-house install records")
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, markAllInHouseInstallsAsRemoved, globalOrTeamID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "mark all host in-house installs as removed")
|
|
}
|
|
|
|
// reuse query to delete all display names associated with in-house apps, 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 in-house apps")
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, deleteAllInHouseInstallersInTeam, globalOrTeamID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete obsolete in-house installers")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Get team name for error messages
|
|
teamName, err := ds.getTeamName(ctx, tmID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get team name for conflict check")
|
|
}
|
|
|
|
var args []any
|
|
for _, installer := range installers {
|
|
// Check for installers that target iOS/iPadOS if they conflict with an existing VPP app
|
|
if installer.BundleIdentifier != "" {
|
|
// Check for iOS VPP app conflict
|
|
exists, err := ds.checkVPPAppExistsForTitleIdentifier(ctx, tx, tmID, string(fleet.IOSPlatform), installer.BundleIdentifier, "ios_apps", "")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "check if VPP app (ios) exists for in-house app")
|
|
}
|
|
if exists {
|
|
return ctxerr.Wrap(ctx, fleet.ConflictError{
|
|
Message: fmt.Sprintf(fleet.CantAddSoftwareConflictMessage, installer.Title, teamName),
|
|
}, "in-house app conflicts with existing VPP app (ios)")
|
|
}
|
|
|
|
// Check for iPadOS VPP app conflict
|
|
exists, err = ds.checkVPPAppExistsForTitleIdentifier(ctx, tx, tmID, string(fleet.IPadOSPlatform), installer.BundleIdentifier, "ipados_apps", "")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "check if VPP app (ipados) exists for in-house app")
|
|
}
|
|
if exists {
|
|
return ctxerr.Wrap(ctx, fleet.ConflictError{
|
|
Message: fmt.Sprintf(fleet.CantAddSoftwareConflictMessage, installer.Title, teamName),
|
|
}, "in-house app conflicts with existing VPP app (ipados)")
|
|
}
|
|
}
|
|
|
|
var providedTitle *string
|
|
if installer.Title != "" {
|
|
providedTitle = &installer.Title // for IPAs downloaded via URL; IPAs referenced by hash won't have this
|
|
}
|
|
|
|
args = append(
|
|
args,
|
|
providedTitle, // if IPA is downloaded as part of the GitOps run, we'll have this via metadata extraction
|
|
installer.StorageID, // if the IPA already exists in the DB, pull the name from an existing title if possible
|
|
strings.TrimSuffix(installer.Filename, ".ipa"), // if neither of the above turns up anything, fall back to filename
|
|
installer.Source,
|
|
"",
|
|
func() *string {
|
|
if strings.TrimSpace(installer.BundleIdentifier) != "" {
|
|
return &installer.BundleIdentifier
|
|
}
|
|
return nil
|
|
}(),
|
|
)
|
|
}
|
|
|
|
values := strings.TrimSuffix(strings.Repeat(
|
|
"(COALESCE(?, (SELECT name FROM software_titles st JOIN in_house_apps iha ON iha.title_id = st.id AND iha.storage_id = ? ORDER BY st.id ASC LIMIT 1), ?),?,?,?),", len(installers)), ",")
|
|
if _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertSoftwareTitles, values), args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "insert new/edited software titles")
|
|
}
|
|
|
|
var titleIDs []uint
|
|
args = []any{}
|
|
for _, installer := range installers {
|
|
args = append(
|
|
args,
|
|
BundleIdentifierOrName(installer.BundleIdentifier, strings.TrimSuffix(installer.Filename, ".ipa")),
|
|
installer.Source,
|
|
"",
|
|
)
|
|
}
|
|
values = strings.TrimSuffix(strings.Repeat("(?,?,?),", len(installers)), ",")
|
|
|
|
if err := sqlx.SelectContext(ctx, tx, &titleIDs, fmt.Sprintf(loadSoftwareTitles, values), args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "load existing titles")
|
|
}
|
|
|
|
stmt, args, err := sqlx.In(cancelPendingInHouseInstallsNotInList, globalOrTeamID, titleIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build statement to cancel pending in-house installs")
|
|
}
|
|
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "cancel obsolete pending host in-house install records")
|
|
}
|
|
stmt, args, err = sqlx.In(cancelPendingInHouseNanoCmdsNotInList, globalOrTeamID, titleIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build statement to cancel pending in-house nano commands")
|
|
}
|
|
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "cancel obsolete pending host in-house install nano commands")
|
|
}
|
|
|
|
stmt, args, err = sqlx.In(loadAffectedHostsPendingInHouseInstallsNotInListUA, globalOrTeamID, titleIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build statement to load affected hosts for upcoming in-house 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 in-house installs")
|
|
}
|
|
activateAffectedHostIDs = affectedHostIDs
|
|
|
|
stmt, args, err = sqlx.In(deletePendingInHouseInstallsNotInListUA, globalOrTeamID, titleIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build statement to delete upcoming pending in-house installs")
|
|
}
|
|
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete obsolete upcoming pending host in-house install records")
|
|
}
|
|
|
|
stmt, args, err = sqlx.In(markInHouseInstallsNotInListAsRemoved, globalOrTeamID, titleIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build statement to mark obsolete host in-house installs as removed")
|
|
}
|
|
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "mark obsolete host in-house 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(deleteInHouseInstallersNotInList, globalOrTeamID, titleIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build statement to delete obsolete in-house installers")
|
|
}
|
|
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete obsolete in-house installers")
|
|
}
|
|
|
|
// Fill a map of title IDs for this team that have a display name
|
|
var titlesWithDisplayNames []struct {
|
|
TitleID uint `db:"software_title_id"`
|
|
Name string `db:"display_name"`
|
|
}
|
|
if err := sqlx.SelectContext(ctx, tx, &titlesWithDisplayNames, getDisplayNamesForTeam, globalOrTeamID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "load display names for updating")
|
|
}
|
|
displayNameIDMap := make(map[uint]string, len(titlesWithDisplayNames))
|
|
for _, d := range titlesWithDisplayNames {
|
|
displayNameIDMap[d.TitleID] = d.Name
|
|
}
|
|
|
|
for _, installer := range installers {
|
|
if installer.ValidatedLabels == nil {
|
|
return ctxerr.Errorf(ctx, "labels have not been validated for in-house app with name %s", installer.Filename)
|
|
}
|
|
|
|
titleArgs := []any{
|
|
BundleIdentifierOrName(installer.BundleIdentifier, strings.TrimSuffix(installer.Filename, ".ipa")),
|
|
installer.Source,
|
|
}
|
|
var titleID uint
|
|
err = sqlx.GetContext(ctx, tx, &titleID, getSoftwareTitle, titleArgs...)
|
|
if err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "getting software title id for existing installer with name %q", installer.Filename)
|
|
}
|
|
|
|
wasUpdatedArgs := []any{
|
|
// package update
|
|
installer.StorageID,
|
|
// WHERE clause
|
|
globalOrTeamID,
|
|
titleID,
|
|
}
|
|
|
|
// pull existing installer state if it exists so we can diff for side effects post-update
|
|
type existingInstallerUpdateCheckResult struct {
|
|
InstallerID uint `db:"id"`
|
|
IsPackageModified bool `db:"is_package_modified"`
|
|
IsMetadataModified bool
|
|
}
|
|
var existing []existingInstallerUpdateCheckResult
|
|
err = sqlx.SelectContext(ctx, tx, &existing, checkExistingInstaller, wasUpdatedArgs...)
|
|
if err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "checking for existing installer with name %q", installer.Filename)
|
|
}
|
|
|
|
args := []any{
|
|
titleID,
|
|
tmID,
|
|
globalOrTeamID,
|
|
installer.Filename,
|
|
installer.Version,
|
|
installer.StorageID,
|
|
installer.Platform,
|
|
installer.BundleIdentifier,
|
|
installer.SelfService,
|
|
installer.URL,
|
|
}
|
|
upsertQuery := insertNewOrEditedInstaller
|
|
if len(existing) > 0 && existing[0].IsPackageModified { // update uploaded_at for updated installer package
|
|
upsertQuery = fmt.Sprintf("%s, updated_at = NOW()", upsertQuery)
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx, upsertQuery, args...); err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "insert new/edited in-house app 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, loadInHouseInstallerID, globalOrTeamID, installer.Filename, installer.Platform); err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "load id of new/edited in-house app with name %q", installer.Filename)
|
|
}
|
|
|
|
// process the labels associated with that in-house installer
|
|
if len(installer.ValidatedLabels.ByName) == 0 {
|
|
// no label to apply, so just delete all existing labels if any
|
|
res, err := tx.ExecContext(ctx, deleteAllInHouseLabels, installerID)
|
|
if err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "delete in-house 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(deleteInHouseLabelsNotInList, installerID, labelIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build statement to delete in-house labels not in list")
|
|
}
|
|
|
|
res, err := tx.ExecContext(ctx, stmt, args...)
|
|
if err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "delete in-house 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
|
|
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 bool changed, the target did change).
|
|
var existingLabels []struct {
|
|
LabelID uint `db:"label_id"`
|
|
Exclude bool `db:"exclude"`
|
|
}
|
|
if err := sqlx.SelectContext(ctx, tx, &existingLabels, loadExistingInHouseLabels, installerID); err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "load existing labels for in-house with name %q", installer.Filename)
|
|
}
|
|
|
|
if len(existingLabels) != len(labelIDs) {
|
|
existing[0].IsMetadataModified = true
|
|
}
|
|
if len(existingLabels) > 0 && existingLabels[0].Exclude != excludeLabels {
|
|
// same labels are provided, but the include <-> exclude 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)
|
|
}
|
|
upsertLabelValues := strings.TrimSuffix(strings.Repeat("(?,?,?),", len(installer.ValidatedLabels.ByName)), ",")
|
|
|
|
_, err = tx.ExecContext(ctx, fmt.Sprintf(upsertInHouseLabels, upsertLabelValues), upsertLabelArgs...)
|
|
if err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "insert new/edited labels for in-house with name %q", installer.Filename)
|
|
}
|
|
}
|
|
|
|
if len(installer.CategoryIDs) == 0 {
|
|
// delete all categories if there are any
|
|
_, err := tx.ExecContext(ctx, deleteAllInHouseCategories, installerID)
|
|
if err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "delete in-house categories for %s", installer.Filename)
|
|
}
|
|
} else {
|
|
// there are new categories to apply, delete only the obsolete ones
|
|
stmt, args, err := sqlx.In(deleteInHouseCategoriesNotInList, installerID, installer.CategoryIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "build statement to delete in-house categories not in list")
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx, stmt, args...)
|
|
if err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "delete in-house 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(upsertInHouseCategories, upsertCategoriesValues), upsertCategoriesArgs...)
|
|
if err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "insert new/edited categories for in-house 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 in-house app with name %q", installer.Filename)
|
|
}
|
|
}
|
|
|
|
// perform side effects if this was an update (related to pending install requests)
|
|
if len(existing) > 0 {
|
|
affectedHostIDs, err := ds.runInHouseUpdateSideEffectsInTransaction(
|
|
ctx,
|
|
tx,
|
|
existing[0].InstallerID,
|
|
existing[0].IsMetadataModified,
|
|
existing[0].IsPackageModified,
|
|
)
|
|
if err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "processing side-effects for in-house with name %q", installer.Filename)
|
|
}
|
|
activateAffectedHostIDs = append(activateAffectedHostIDs, affectedHostIDs...)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ds.activateNextUpcomingActivityForBatchOfHosts(ctx, activateAffectedHostIDs)
|
|
}
|
|
|
|
func (ds *Datastore) runInHouseUpdateSideEffectsInTransaction(ctx context.Context, tx sqlx.ExtContext, installerID uint, wasMetadataUpdated bool, wasPackageUpdated bool) (affectedHostIDs []uint, err error) {
|
|
if wasMetadataUpdated || wasPackageUpdated { // cancel pending installs
|
|
const cancelInHouseInstalls = `
|
|
UPDATE
|
|
host_in_house_software_installs
|
|
SET
|
|
canceled = 1
|
|
WHERE
|
|
verification_at IS NULL AND
|
|
verification_failed_at IS NULL AND
|
|
in_house_app_id = ?
|
|
`
|
|
_, err = tx.ExecContext(ctx, cancelInHouseInstalls, installerID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "cancel pending host in-house installs")
|
|
}
|
|
|
|
const cancelInHouseCmds = `
|
|
UPDATE
|
|
nano_enrollment_queue
|
|
SET
|
|
active = 0
|
|
WHERE
|
|
command_uuid IN (
|
|
SELECT command_uuid
|
|
FROM host_in_house_software_installs
|
|
WHERE
|
|
verification_at IS NULL AND
|
|
verification_failed_at IS NULL AND
|
|
in_house_app_id = ?
|
|
)
|
|
`
|
|
_, err = tx.ExecContext(ctx, cancelInHouseCmds, installerID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "cancel pending host in-house commands")
|
|
}
|
|
|
|
const loadAffectedHosts = `
|
|
SELECT
|
|
DISTINCT host_id
|
|
FROM
|
|
upcoming_activities ua
|
|
INNER JOIN in_house_app_upcoming_activities ihua
|
|
ON ua.id = ihua.upcoming_activity_id
|
|
WHERE
|
|
ua.activity_type = 'in_house_app_install' AND
|
|
ua.activated_at IS NOT NULL AND
|
|
ihua.in_house_app_id = ?
|
|
`
|
|
if err := sqlx.SelectContext(ctx, tx, &affectedHostIDs, loadAffectedHosts, installerID); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "select affected host IDs for in-house installs")
|
|
}
|
|
|
|
const deleteUpcomingInHouse = `
|
|
DELETE FROM upcoming_activities
|
|
USING upcoming_activities
|
|
INNER JOIN in_house_app_upcoming_activities ihua
|
|
ON upcoming_activities.id = ihua.upcoming_activity_id
|
|
WHERE
|
|
activity_type = 'in_house_app_install' AND
|
|
ihua.in_house_app_id = ?
|
|
`
|
|
|
|
_, err = tx.ExecContext(ctx, deleteUpcomingInHouse, installerID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "delete upcoming host in-house installs")
|
|
}
|
|
}
|
|
|
|
if wasPackageUpdated { // hide existing install counts
|
|
const markInHouseRemoved = `
|
|
UPDATE host_in_house_software_installs SET removed = TRUE
|
|
WHERE in_house_app_id = ?
|
|
`
|
|
_, err := tx.ExecContext(ctx, markInHouseRemoved, installerID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "hide existing install counts")
|
|
}
|
|
}
|
|
|
|
return affectedHostIDs, nil
|
|
}
|
|
|
|
func (ds *Datastore) CheckConflictingInstallerExists(ctx context.Context, teamID *uint, bundleIdentifier, platform string) (bool, error) {
|
|
return ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), teamID, bundleIdentifier, platform, softwareTypeInstaller)
|
|
}
|
|
|
|
func (ds *Datastore) CheckConflictingInHouseAppExists(ctx context.Context, teamID *uint, bundleIdentifier, platform string) (bool, error) {
|
|
return ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), teamID, bundleIdentifier, platform, softwareTypeInHouseApp)
|
|
}
|
|
|
|
func (ds *Datastore) checkInstallerOrInHouseAppExists(ctx context.Context, q sqlx.QueryerContext, teamID *uint, bundleIdentifier, platform string, swType softwareType) (bool, error) {
|
|
stmt := fmt.Sprintf(`
|
|
SELECT 1
|
|
FROM
|
|
software_titles st
|
|
INNER JOIN %[1]ss ON st.id = %[1]ss.title_id AND %[1]ss.global_or_team_id = ?
|
|
WHERE
|
|
st.unique_identifier = ?
|
|
AND %[1]ss.platform = ?
|
|
`, swType)
|
|
|
|
var globalOrTeamID uint
|
|
if teamID != nil {
|
|
globalOrTeamID = *teamID
|
|
}
|
|
var exists int
|
|
err := sqlx.GetContext(ctx, q, &exists, stmt, globalOrTeamID, bundleIdentifier, platform)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return false, ctxerr.Wrap(ctx, err, fmt.Sprintf("check %s exists", swType))
|
|
}
|
|
return exists == 1, nil
|
|
}
|
|
|
|
func (ds *Datastore) checkInHouseAppExistsForAdamID(ctx context.Context, q sqlx.QueryerContext, teamID *uint, appID fleet.VPPAppID) (exists bool, title string, err error) {
|
|
const stmt = `
|
|
SELECT st.name
|
|
FROM software_titles st
|
|
INNER JOIN in_house_apps iha ON iha.title_id = st.id AND
|
|
iha.global_or_team_id = ?
|
|
INNER JOIN vpp_apps va ON va.bundle_identifier = st.bundle_identifier
|
|
INNER JOIN vpp_apps_teams vat ON vat.adam_id = va.adam_id AND vat.platform = va.platform AND
|
|
vat.global_or_team_id = ?
|
|
WHERE
|
|
va.adam_id = ?
|
|
AND va.platform = ?
|
|
AND iha.platform = va.platform
|
|
LIMIT 1
|
|
`
|
|
|
|
var globalOrTeamID uint
|
|
if teamID != nil {
|
|
globalOrTeamID = *teamID
|
|
}
|
|
|
|
err = sqlx.GetContext(ctx, q, &title, stmt, globalOrTeamID, globalOrTeamID, appID.AdamID, appID.Platform)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return false, "", nil
|
|
}
|
|
return false, "", err
|
|
}
|
|
return true, title, nil
|
|
}
|