Initial support for in-house apps on iOS/iPadOS (#34802)

This commit is contained in:
Martin Angers
2025-10-28 08:33:58 -04:00
committed by GitHub
parent 437cb9ca96
commit cab7cc15be
51 changed files with 5217 additions and 478 deletions
+468 -150
View File
@@ -73,42 +73,29 @@ func (ds *Datastore) NewActivity(
cols = append(cols, "user_email")
}
vppPtrAct, okPtr := activity.(*fleet.ActivityInstalledAppStoreApp)
vppAct, ok := activity.(fleet.ActivityInstalledAppStoreApp)
if okPtr || ok {
hostID := vppAct.HostID
cmdUUID := vppAct.CommandUUID
if okPtr {
cmdUUID = vppPtrAct.CommandUUID
hostID = vppPtrAct.HostID
}
activateNext := vppAct.Status != string(fleet.SoftwareInstalled)
if vppPtrAct != nil {
activateNext = vppPtrAct.Status != string(fleet.SoftwareInstalled)
}
if activateNext {
// NOTE: ideally this would be called in the same transaction as storing
// the nanomdm command results, but the current design doesn't allow for
// that with the nano store being a distinct entity to our datastore (we
// should get rid of that distinction eventually, we've broken it already
// in some places and it doesn't bring much benefit anymore).
//
// Instead, this gets called from CommandAndReportResults, which is
// executed after the results have been saved in nano, but we already
// accept this non-transactional fact for many other states we manage in
// Fleet (wipe, lock results, setup experience results, etc. - see all
// critical data that gets updated in CommandAndReportResults) so there's
// no reason to treat the unified queue differently.
//
// This place here is a bit hacky but perfect for VPP apps as the activity
// gets created only when the MDM command status is in a final state
// (success or failure), which is exactly when we want to activate the next
// activity.
if _, err := ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), hostID, cmdUUID); err != nil {
return ctxerr.Wrap(ctx, err, "activate next activity from VPP app install")
}
if aa, ok := activity.(fleet.ActivityActivator); ok && aa.MustActivateNextUpcomingActivity() {
hostID, cmdUUID := aa.ActivateNextUpcomingActivityArgs()
// NOTE: ideally this would be called in the same transaction as storing
// the nanomdm command results, but the current design doesn't allow for
// that with the nano store being a distinct entity to our datastore (we
// should get rid of that distinction eventually, we've broken it already
// in some places and it doesn't bring much benefit anymore).
//
// Instead, this gets called from CommandAndReportResults, which is
// executed after the results have been saved in nano, but we already
// accept this non-transactional fact for many other states we manage in
// Fleet (wipe, lock results, setup experience results, etc. - see all
// critical data that gets updated in CommandAndReportResults) so there's
// no reason to treat the unified queue differently.
//
// This place here is a bit hacky but perfect for VPP/InHouse apps as the activity
// gets created only when the MDM command status is in a final state
// (success or failure), which is exactly when we want to activate the next
// activity. Though note that on success of the MDM command, we wait until the
// app gets verified (or it times out waiting for verification) to activate the
// next activity, to ensure the app is actually installed.
if _, err := ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), hostID, cmdUUID); err != nil {
return ctxerr.Wrap(ctx, err, "activate next activity from VPP app install")
}
}
@@ -446,6 +433,7 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
ua.host_id = :host_id AND
activity_type = 'software_uninstall'
`,
// list pending VPP apps
`SELECT
ua.execution_id AS uuid,
IF(ua.fleet_initiated, 'Fleet', COALESCE(u.name, ua.payload->>'$.user.name')) AS name,
@@ -457,8 +445,8 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
ua.created_at AS created_at,
JSON_OBJECT(
'host_id', ua.host_id,
'host_display_name', hdn.display_name,
'software_title', st.name,
'host_display_name', COALESCE(hdn.display_name, ''),
'software_title', COALESCE(st.name, ''),
'app_store_id', vaua.adam_id,
'command_uuid', ua.execution_id,
'self_service', ua.payload->'$.self_service' IS TRUE,
@@ -483,6 +471,41 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
ua.host_id = :host_id AND
ua.activity_type = 'vpp_app_install'
`,
// list pending in-house apps
`SELECT
ua.execution_id AS uuid,
IF(ua.fleet_initiated, 'Fleet', COALESCE(u.name, ua.payload->>'$.user.name')) AS name,
u.id AS user_id,
u.api_only as api_only,
COALESCE(u.gravatar_url, ua.payload->>'$.user.gravatar_url') as gravatar_url,
COALESCE(u.email, ua.payload->>'$.user.email') as user_email,
:installed_software_type as activity_type,
ua.created_at AS created_at,
JSON_OBJECT(
'host_id', ua.host_id,
'host_display_name', COALESCE(hdn.display_name, ''),
'software_title', COALESCE(st.name, ''),
'command_uuid', ua.execution_id,
'self_service', false,
'status', 'pending_install'
) AS details,
IF(ua.activated_at IS NULL, 0, 1) as topmost,
ua.priority as priority,
ua.fleet_initiated as fleet_initiated
FROM
upcoming_activities ua
INNER JOIN
in_house_app_upcoming_activities ihua ON ihua.upcoming_activity_id = ua.id
LEFT OUTER JOIN
users u ON ua.user_id = u.id
LEFT OUTER JOIN
host_display_names hdn ON hdn.host_id = ua.host_id
LEFT OUTER JOIN
software_titles st ON st.id = ihua.software_title_id
WHERE
ua.host_id = :host_id AND
ua.activity_type = 'in_house_app_install'
`,
}
listStmt := `
@@ -702,6 +725,15 @@ func (ds *Datastore) CancelHostUpcomingActivity(ctx context.Context, hostID uint
return details, nil
}
type activityToCancel struct {
ActivityType string `db:"activity_type"`
HostID uint `db:"host_id"`
HostDisplayName string `db:"host_display_name"`
CanceledName string `db:"canceled_name"`
CanceledID *uint `db:"canceled_id"`
Activated bool `db:"activated"`
}
func (ds *Datastore) cancelHostUpcomingActivity(ctx context.Context, tx sqlx.ExtContext, hostID uint, executionID string) (fleet.ActivityDetails, error) {
const (
loadScriptActivityStmt = `
@@ -799,25 +831,40 @@ func (ds *Datastore) cancelHostUpcomingActivity(ctx context.Context, tx sqlx.Ext
ua.execution_id = :execution_id AND
ua.activity_type = 'vpp_app_install'
`
loadInHouseAppInstallActivityStmt = `
SELECT
ua.activity_type,
ua.host_id,
COALESCE(hdn.display_name, '') as host_display_name,
COALESCE(st.name, '') as canceled_name, -- software title name in this case
st.id as canceled_id,
IF(ua.activated_at IS NULL, 0, 1) as activated
FROM
upcoming_activities ua
INNER JOIN
in_house_app_upcoming_activities ihua ON ihua.upcoming_activity_id = ua.id
LEFT OUTER JOIN
host_display_names hdn ON hdn.host_id = ua.host_id
LEFT OUTER JOIN
in_house_apps iha ON ihua.in_house_app_id = iha.id
LEFT OUTER JOIN
software_titles st ON st.id = iha.title_id
WHERE
ua.host_id = :host_id AND
ua.execution_id = :execution_id AND
ua.activity_type = 'in_house_app_install'
`
)
type activityToCancel struct {
ActivityType string `db:"activity_type"`
HostID uint `db:"host_id"`
HostDisplayName string `db:"host_display_name"`
CanceledName string `db:"canceled_name"`
CanceledID *uint `db:"canceled_id"`
Activated bool `db:"activated"`
}
var act activityToCancel
var pastAct fleet.ActivityDetails
// read the activity along with the required information to create the
// "canceled" past activity, and check if the activity was activated or
// not.
stmt := strings.Join([]string{
loadScriptActivityStmt, loadSoftwareInstallActivityStmt,
loadSoftwareUninstallActivityStmt, loadVPPAppInstallActivityStmt,
loadInHouseAppInstallActivityStmt,
}, " UNION ALL ")
stmt, args, err := sqlx.Named(stmt, map[string]any{"host_id": hostID, "execution_id": executionID})
if err != nil {
@@ -858,120 +905,36 @@ func (ds *Datastore) cancelHostUpcomingActivity(ctx context.Context, tx sqlx.Ext
}
}
var pastAct fleet.ActivityDetails
switch act.ActivityType {
case "script":
// if the script was part of the setup experience, then it must be marked
// as "failed" for that setup experience flow (regardless of whether or
// not it was activated).
const failSetupExpStmt = `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND script_execution_id = ?`
if _, err := tx.ExecContext(ctx, failSetupExpStmt, fleet.SetupExperienceStatusFailure, hostUUID, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update setup_experience_status_results as failed")
}
if act.Activated {
const updStmt = `UPDATE host_script_results SET canceled = 1 WHERE execution_id = ?`
if _, err := tx.ExecContext(ctx, updStmt, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update host_script_results as canceled")
}
}
pastAct = fleet.ActivityTypeCanceledRunScript{
HostID: act.HostID,
HostDisplayName: act.HostDisplayName,
ScriptName: act.CanceledName,
pastAct, err = cancelHostScriptUpcomingActivity(ctx, tx, act, hostUUID, executionID)
if err != nil {
return nil, err
}
case "software_install":
// if the install was part of the setup experience, then it must be
// marked as "failed" for that setup experience flow (regardless of
// whether or not it was activated).
const failSetupExpStmt = `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND host_software_installs_execution_id = ?`
if _, err := tx.ExecContext(ctx, failSetupExpStmt, fleet.SetupExperienceStatusFailure, hostUUID, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update setup_experience_status_results as failed")
}
if act.Activated {
const updStmt = `UPDATE host_software_installs SET canceled = 1 WHERE execution_id = ?`
if _, err := tx.ExecContext(ctx, updStmt, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update host_software_installs as canceled")
}
}
var titleID uint
if act.CanceledID != nil {
titleID = *act.CanceledID
}
pastAct = fleet.ActivityTypeCanceledInstallSoftware{
HostID: act.HostID,
HostDisplayName: act.HostDisplayName,
SoftwareTitle: act.CanceledName,
SoftwareTitleID: titleID,
pastAct, err = cancelHostSoftwareInstallUpcomingActivity(ctx, tx, act, hostUUID, executionID)
if err != nil {
return nil, err
}
case "software_uninstall":
// uninstall cannot be part of setup experience, so there's no update for
// that in this case.
if act.Activated {
// uninstall is a combination of software install and script result,
// with the same execution id.
const updSoftwareStmt = `UPDATE host_software_installs SET canceled = 1 WHERE execution_id = ?`
if _, err := tx.ExecContext(ctx, updSoftwareStmt, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update host_software_installs as canceled")
}
const updScriptStmt = `UPDATE host_script_results SET canceled = 1 WHERE execution_id = ?`
if _, err := tx.ExecContext(ctx, updScriptStmt, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update host_script_results as canceled")
}
}
var titleID uint
if act.CanceledID != nil {
titleID = *act.CanceledID
}
pastAct = fleet.ActivityTypeCanceledUninstallSoftware{
HostID: act.HostID,
HostDisplayName: act.HostDisplayName,
SoftwareTitle: act.CanceledName,
SoftwareTitleID: titleID,
pastAct, err = cancelHostSoftwareUninstallUpcomingActivity(ctx, tx, act, executionID)
if err != nil {
return nil, err
}
case "vpp_app_install":
// if the VPP install was part of the setup experience, then it must be
// marked as "failed" for that setup experience flow (regardless of
// whether or not it was activated).
const failSetupExpStmt = `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND nano_command_uuid = ?`
if _, err := tx.ExecContext(ctx, failSetupExpStmt, fleet.SetupExperienceStatusFailure, hostUUID, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update setup_experience_status_results as failed")
pastAct, err = cancelHostVPPAppInstallUpcomingActivity(ctx, tx, act, hostID, hostUUID, executionID)
if err != nil {
return nil, err
}
if act.Activated {
const updVPPStmt = `UPDATE host_vpp_software_installs SET canceled = 1 WHERE command_uuid = ?`
if _, err := tx.ExecContext(ctx, updVPPStmt, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update host_vpp_software_installs as canceled")
}
const updNanoStmt = `UPDATE nano_enrollment_queue SET active = 0 WHERE id = ? AND command_uuid = ?`
if _, err := tx.ExecContext(ctx, updNanoStmt, hostUUID, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update nano_enrollment_queue as canceled")
}
const delHostMDMCommandStmt = `DELETE FROM host_mdm_commands WHERE host_id = ? AND command_type = ?`
if _, err := tx.ExecContext(ctx, delHostMDMCommandStmt, hostID, fleet.VerifySoftwareInstallVPPPrefix); err != nil {
return nil, ctxerr.Wrap(ctx, err, "delete vpp verify from host_mdm_commands")
}
}
var titleID uint
if act.CanceledID != nil {
titleID = *act.CanceledID
}
pastAct = fleet.ActivityTypeCanceledInstallAppStoreApp{
HostID: act.HostID,
HostDisplayName: act.HostDisplayName,
SoftwareTitle: act.CanceledName,
SoftwareTitleID: titleID,
case "in_house_app_install":
pastAct, err = cancelHostInHouseAppInstallUpcomingActivity(ctx, tx, act, hostID, hostUUID, executionID)
if err != nil {
return nil, err
}
default:
@@ -994,6 +957,158 @@ func (ds *Datastore) cancelHostUpcomingActivity(ctx context.Context, tx sqlx.Ext
return pastAct, nil
}
func cancelHostInHouseAppInstallUpcomingActivity(ctx context.Context, tx sqlx.ExtContext, act activityToCancel, hostID uint, hostUUID, executionID string) (fleet.ActivityDetails, error) {
// in-house apps currently cannot be part of setup experience, so there's no
// update for that in this case.
if act.Activated {
const updInHouseStmt = `UPDATE host_in_house_software_installs SET canceled = 1 WHERE command_uuid = ?`
if _, err := tx.ExecContext(ctx, updInHouseStmt, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update host_in_house_software_installs as canceled")
}
const updNanoStmt = `UPDATE nano_enrollment_queue SET active = 0 WHERE id = ? AND command_uuid = ?`
if _, err := tx.ExecContext(ctx, updNanoStmt, hostUUID, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update nano_enrollment_queue as canceled")
}
const delHostMDMCommandStmt = `DELETE FROM host_mdm_commands WHERE host_id = ? AND command_type = ?`
if _, err := tx.ExecContext(ctx, delHostMDMCommandStmt, hostID, fleet.VerifySoftwareInstallVPPPrefix); err != nil {
return nil, ctxerr.Wrap(ctx, err, "delete verify from host_mdm_commands")
}
}
var titleID uint
if act.CanceledID != nil {
titleID = *act.CanceledID
}
return fleet.ActivityTypeCanceledInstallSoftware{
HostID: act.HostID,
HostDisplayName: act.HostDisplayName,
SoftwareTitle: act.CanceledName,
SoftwareTitleID: titleID,
}, nil
}
func cancelHostVPPAppInstallUpcomingActivity(ctx context.Context, tx sqlx.ExtContext, act activityToCancel, hostID uint, hostUUID, executionID string) (fleet.ActivityDetails, error) {
// if the VPP install was part of the setup experience, then it must be
// marked as "failed" for that setup experience flow (regardless of
// whether or not it was activated).
const failSetupExpStmt = `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND nano_command_uuid = ?`
if _, err := tx.ExecContext(ctx, failSetupExpStmt, fleet.SetupExperienceStatusFailure, hostUUID, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update setup_experience_status_results as failed")
}
if act.Activated {
const updVPPStmt = `UPDATE host_vpp_software_installs SET canceled = 1 WHERE command_uuid = ?`
if _, err := tx.ExecContext(ctx, updVPPStmt, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update host_vpp_software_installs as canceled")
}
const updNanoStmt = `UPDATE nano_enrollment_queue SET active = 0 WHERE id = ? AND command_uuid = ?`
if _, err := tx.ExecContext(ctx, updNanoStmt, hostUUID, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update nano_enrollment_queue as canceled")
}
const delHostMDMCommandStmt = `DELETE FROM host_mdm_commands WHERE host_id = ? AND command_type = ?`
if _, err := tx.ExecContext(ctx, delHostMDMCommandStmt, hostID, fleet.VerifySoftwareInstallVPPPrefix); err != nil {
return nil, ctxerr.Wrap(ctx, err, "delete verify vpp from host_mdm_commands")
}
}
var titleID uint
if act.CanceledID != nil {
titleID = *act.CanceledID
}
return fleet.ActivityTypeCanceledInstallAppStoreApp{
HostID: act.HostID,
HostDisplayName: act.HostDisplayName,
SoftwareTitle: act.CanceledName,
SoftwareTitleID: titleID,
}, nil
}
func cancelHostSoftwareUninstallUpcomingActivity(ctx context.Context, tx sqlx.ExtContext, act activityToCancel, executionID string) (fleet.ActivityDetails, error) {
// uninstall cannot be part of setup experience, so there's no update for
// that in this case.
if act.Activated {
// uninstall is a combination of software install and script result,
// with the same execution id.
const updSoftwareStmt = `UPDATE host_software_installs SET canceled = 1 WHERE execution_id = ?`
if _, err := tx.ExecContext(ctx, updSoftwareStmt, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update host_software_installs as canceled")
}
const updScriptStmt = `UPDATE host_script_results SET canceled = 1 WHERE execution_id = ?`
if _, err := tx.ExecContext(ctx, updScriptStmt, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update host_script_results as canceled")
}
}
var titleID uint
if act.CanceledID != nil {
titleID = *act.CanceledID
}
return fleet.ActivityTypeCanceledUninstallSoftware{
HostID: act.HostID,
HostDisplayName: act.HostDisplayName,
SoftwareTitle: act.CanceledName,
SoftwareTitleID: titleID,
}, nil
}
func cancelHostSoftwareInstallUpcomingActivity(ctx context.Context, tx sqlx.ExtContext, act activityToCancel, hostUUID, executionID string) (fleet.ActivityDetails, error) {
// if the install was part of the setup experience, then it must be
// marked as "failed" for that setup experience flow (regardless of
// whether or not it was activated).
const failSetupExpStmt = `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND host_software_installs_execution_id = ?`
if _, err := tx.ExecContext(ctx, failSetupExpStmt, fleet.SetupExperienceStatusFailure, hostUUID, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update setup_experience_status_results as failed")
}
if act.Activated {
const updStmt = `UPDATE host_software_installs SET canceled = 1 WHERE execution_id = ?`
if _, err := tx.ExecContext(ctx, updStmt, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update host_software_installs as canceled")
}
}
var titleID uint
if act.CanceledID != nil {
titleID = *act.CanceledID
}
return fleet.ActivityTypeCanceledInstallSoftware{
HostID: act.HostID,
HostDisplayName: act.HostDisplayName,
SoftwareTitle: act.CanceledName,
SoftwareTitleID: titleID,
}, nil
}
func cancelHostScriptUpcomingActivity(ctx context.Context, tx sqlx.ExtContext, act activityToCancel, hostUUID, executionID string) (fleet.ActivityDetails, error) {
// if the script was part of the setup experience, then it must be marked
// as "failed" for that setup experience flow (regardless of whether or
// not it was activated).
const failSetupExpStmt = `UPDATE setup_experience_status_results SET status = ? WHERE host_uuid = ? AND script_execution_id = ?`
if _, err := tx.ExecContext(ctx, failSetupExpStmt, fleet.SetupExperienceStatusFailure, hostUUID, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update setup_experience_status_results as failed")
}
if act.Activated {
const updStmt = `UPDATE host_script_results SET canceled = 1 WHERE execution_id = ?`
if _, err := tx.ExecContext(ctx, updStmt, executionID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "update host_script_results as canceled")
}
}
return fleet.ActivityTypeCanceledRunScript{
HostID: act.HostID,
HostDisplayName: act.HostDisplayName,
ScriptName: act.CanceledName,
}, nil
}
func clearLockWipeForCanceledActivity(ctx context.Context, tx sqlx.ExtContext, hostID uint, executionID string) error {
const clearLockStmt = `DELETE FROM host_mdm_actions WHERE host_id = ? AND lock_ref = ?`
resLock, err := tx.ExecContext(ctx, clearLockStmt, hostID, executionID)
@@ -1155,7 +1270,8 @@ func (ds *Datastore) activateNextUpcomingActivityForBatchOfHosts(ctx context.Con
// order. Activation consists of inserting the activity in its respective
// table, e.g. `host_script_results` for scripts, `host_software_installs` for
// software installs, `host_vpp_software_installs` and nano command queue for
// VPP installs; and setting the activated_at timestamp in the
// VPP installs, `host_in_house_software_installs` and nano command queue for
// in-house installs; and setting the activated_at timestamp in the
// `upcoming_activities` table.
// - As an optimization for MDM, if the activity type is `vpp_app_install`
// and the next few upcoming activities are all of this type, they are
@@ -1270,6 +1386,8 @@ WHERE
fn = ds.activateNextSoftwareUninstallActivity
case "vpp_app_install":
fn = ds.activateNextVPPAppInstallActivity
case "in_house_app_install":
fn = ds.activateNextInHouseAppInstallActivity
default:
return nil, ctxerr.Errorf(ctx, "unsupported activity type %s", actType)
}
@@ -1607,3 +1725,203 @@ ORDER BY
}
return nil
}
func (ds *Datastore) activateNextInHouseAppInstallActivity(ctx context.Context, tx sqlx.ExtContext, hostID uint, execIDs []string) error {
const insStmt = `
INSERT INTO
host_in_house_software_installs
(host_id, in_house_app_id, command_uuid, user_id, platform)
SELECT
ua.host_id,
ihua.in_house_app_id,
ua.execution_id,
ua.user_id,
iha.platform
FROM
upcoming_activities ua
INNER JOIN in_house_app_upcoming_activities ihua
ON ihua.upcoming_activity_id = ua.id
INNER JOIN in_house_apps iha
ON iha.id = ihua.in_house_app_id
WHERE
ua.host_id = ? AND
ua.execution_id IN (?)
ORDER BY
ua.priority DESC, ua.created_at ASC
`
const getHostUUIDStmt = `
SELECT
uuid, team_id
FROM
hosts
WHERE
id = ?
`
const insCmdStmt = `
INSERT INTO
nano_commands
(command_uuid, request_type, command, subtype)
SELECT
ua.execution_id,
'InstallApplication',
CONCAT(:raw_cmd_part1, :manifest_url, :raw_cmd_part2, ua.execution_id, :raw_cmd_part3),
:subtype
FROM
upcoming_activities ua
INNER JOIN in_house_app_upcoming_activities ihua
ON ihua.upcoming_activity_id = ua.id
WHERE
ua.host_id = :host_id AND
ua.execution_id IN (:execution_ids)
`
const rawCmdPart1 = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Command</key>
<dict>
<key>InstallAsManaged</key>
<true/>
<key>ManagementFlags</key>
<integer>0</integer>
<key>ChangeManagementState</key>
<string>Managed</string>
<key>InstallAsManaged</key>
<true />
<key>Options</key>
<dict>
<key>PurchaseMethod</key>
<integer>1</integer>
</dict>
<key>RequestType</key>
<string>InstallApplication</string>
<key>ManifestURL</key>
<string>`
const rawCmdPart2 = `</string>
</dict>
<key>CommandUUID</key>
<string>`
const rawCmdPart3 = `</string>
</dict>
</plist>`
const insNanoQueueStmt = `
INSERT INTO
nano_enrollment_queue
(id, command_uuid, created_at)
SELECT
?,
execution_id,
created_at -- force same timestamp to keep ordering
FROM
upcoming_activities
WHERE
host_id = ? AND
execution_id IN (?)
ORDER BY
priority DESC, created_at ASC
`
// sanity-check that there's something to activate
if len(execIDs) == 0 {
return nil
}
// get the host uuid, required for the nano tables
var hostData struct {
UUID string `db:"uuid"`
TeamID *uint `db:"team_id"`
}
if err := sqlx.GetContext(ctx, tx, &hostData, getHostUUIDStmt, hostID); err != nil {
return ctxerr.Wrap(ctx, err, "get host uuid")
}
// insert the host in-house app row
stmt, args, err := sqlx.In(insStmt, hostID, execIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare insert to activate in-house apps")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "insert to activate in-house apps")
}
appConfig, err := appConfigDB(ctx, tx)
if err != nil {
return ctxerr.Wrap(ctx, err, "activate in house app install: get app config")
}
var tid uint
if hostData.TeamID != nil {
tid = *hostData.TeamID
}
// Get the title ID for the in-house app being installed
var titleID uint
getTitleIDStmt := `
SELECT
ihua.software_title_id
FROM
upcoming_activities ua
INNER JOIN in_house_app_upcoming_activities ihua
ON ihua.upcoming_activity_id = ua.id
WHERE
ua.host_id = ? AND
ua.execution_id IN (?)
`
stmt, args, err = sqlx.In(getTitleIDStmt, hostID, execIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare get in-house app title id")
}
if err := sqlx.GetContext(ctx, tx, &titleID, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "get in-house app title id")
}
manifestURL := fmt.Sprintf("%s/api/latest/fleet/software/titles/%d/in_house_app/manifest?team_id=%d", appConfig.ServerSettings.ServerURL, titleID, tid)
// insert the nano command
namedArgs := map[string]any{
"manifest_url": manifestURL,
"raw_cmd_part1": rawCmdPart1,
"raw_cmd_part2": rawCmdPart2,
"raw_cmd_part3": rawCmdPart3,
"subtype": mdm.CommandSubtypeNone,
"host_id": hostID,
"execution_ids": execIDs,
}
stmt, args, err = sqlx.Named(insCmdStmt, namedArgs)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare insert nano commands")
}
stmt, args, err = sqlx.In(stmt, args...)
if err != nil {
return ctxerr.Wrap(ctx, err, "expand IN arguments to insert nano commands")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "insert nano commands")
}
// enqueue the nano command in the nano queue
stmt, args, err = sqlx.In(insNanoQueueStmt, hostData.UUID, hostID, execIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare insert nano queue")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "insert nano queue")
}
// best-effort APNs push notification to the host, not critical because we
// have a cron job that will retry for hosts with pending MDM commands.
if ds.pusher != nil {
if _, err := ds.pusher.Push(ctx, []string{hostData.UUID}); err != nil {
level.Error(ds.logger).Log("msg", "failed to send push notification", "err", err, "hostID", hostID, "hostUUID", hostData.UUID) //nolint:errcheck
}
}
return nil
}
+235 -9
View File
@@ -1140,6 +1140,8 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) {
nanoEnrollAndSetHostMDMData(t, ds, h1, false)
h2 := test.NewHost(t, ds, "h2.local", "10.10.10.2", "2", "2", time.Now())
nanoEnrollAndSetHostMDMData(t, ds, h2, false)
hIOS := test.NewHost(t, ds, "h3.local", "10.10.10.3", "3", "3", time.Now().Add(-1*time.Second), test.WithPlatform("ios"))
nanoEnrollAndSetHostMDMData(t, ds, hIOS, false)
u := test.NewUser(t, ds, "user1", "user1@example.com", false)
@@ -1160,6 +1162,12 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) {
}
_, err = ds.InsertVPPAppWithTeam(ctx, vppApp2, nil)
require.NoError(t, err)
vppApp1IOS := &fleet.VPPApp{
Name: "vpp_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "vpp1", Platform: fleet.IOSPlatform}},
BundleIdentifier: "vpp1",
}
_, err = ds.InsertVPPAppWithTeam(ctx, vppApp1IOS, nil)
require.NoError(t, err)
// create a software installer that can be installed later
installer1, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
@@ -1178,6 +1186,19 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
// create an in-house app that can be installed later
ihaID, ihaTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
StorageID: uuid.NewString(),
Filename: "inhouse.ipa",
Title: "inhouse",
Source: "ios_apps",
Extension: "ipa",
BundleIdentifier: "inhouse",
UserID: u.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// activating an empty queue is fine, nothing activated
execIDs, err := ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), h1.ID, "")
require.NoError(t, err)
@@ -1204,6 +1225,11 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) {
require.NoError(t, err)
script1_2 := hsr.ExecutionID
// host 2 is unaffected, activating results in nothing activated
execIDs, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), h2.ID, "")
require.NoError(t, err)
require.Empty(t, execIDs)
// add a couple install requests for vpp1 and vpp2
vpp1_1 := uuid.NewString()
err = ds.InsertHostVPPSoftwareInstall(ctx, h1.ID, vppApp1.VPPAppID, vpp1_1, "event-id-1", fleet.HostSoftwareInstallOptions{})
@@ -1435,6 +1461,149 @@ func testActivateNextActivity(t *testing.T, ds *Datastore) {
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, h1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 0)
// enqueue a VPP app request for iOS host
vpp1_1_ios := uuid.NewString()
err = ds.InsertHostVPPSoftwareInstall(ctx, hIOS.ID, vppApp1IOS.VPPAppID, vpp1_1_ios, "event-id-1-ios", fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// enqueue an in-house app request for the iOS host
ihaCmd := uuid.NewString()
err = ds.InsertHostInHouseAppInstall(ctx, hIOS.ID, ihaID, ihaTitleID, ihaCmd, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 2)
require.Equal(t, vpp1_1_ios, pendingActs[0].UUID)
require.Equal(t, ihaCmd, pendingActs[1].UUID)
// record a result for the VPP app install, which will activate the in-house app
cmdRes = &mdm.CommandResults{
CommandUUID: vpp1_1_ios,
Status: "Acknowledged",
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?>`),
}
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = ds.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
HostID: hIOS.ID,
AppStoreID: vppApp1IOS.VPPAppTeam.AdamID,
CommandUUID: vpp1_1_ios,
Status: "Error", // using a failure because otherwise it requires verification to activate next
}, []byte(`{}`), time.Now())
require.NoError(t, err)
// the in-house app is now activated
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 1)
require.Equal(t, ihaCmd, pendingActs[0].UUID)
// enqueue a VPP app request for iOS host once more
vpp1_1_ios = uuid.NewString()
err = ds.InsertHostVPPSoftwareInstall(ctx, hIOS.ID, vppApp1IOS.VPPAppID, vpp1_1_ios, "event-id-2-ios", fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 2)
require.Equal(t, ihaCmd, pendingActs[0].UUID)
require.Equal(t, vpp1_1_ios, pendingActs[1].UUID)
// record a result for in-house app and it should activate the next VPP app.
cmdRes = &mdm.CommandResults{
CommandUUID: ihaCmd,
Status: "Acknowledged",
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?>`),
}
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = ds.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{
HostID: hIOS.ID,
CommandUUID: ihaCmd,
Status: "Error", // using a failure because otherwise it requires verification to activate next
}, []byte(`{}`), time.Now())
require.NoError(t, err)
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 1)
require.Equal(t, vpp1_1_ios, pendingActs[0].UUID)
// enqueue the in-house app again
ihaCmd = uuid.NewString()
err = ds.InsertHostInHouseAppInstall(ctx, hIOS.ID, ihaID, ihaTitleID, ihaCmd, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 2)
require.Equal(t, vpp1_1_ios, pendingActs[0].UUID)
require.Equal(t, ihaCmd, pendingActs[1].UUID)
// record a successful result for the VPP app, will not activate the next until verification
cmdRes = &mdm.CommandResults{
CommandUUID: vpp1_1_ios,
Status: "Acknowledged",
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?>`),
}
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = ds.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{
HostID: hIOS.ID,
CommandUUID: vpp1_1_ios,
Status: string(fleet.SoftwareInstalled),
}, []byte(`{}`), time.Now())
require.NoError(t, err)
// both are still upcoming...
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 2)
require.Equal(t, vpp1_1_ios, pendingActs[0].UUID)
require.Equal(t, ihaCmd, pendingActs[1].UUID)
// mark the VPP app as verified, will activate the next activity
err = ds.SetVPPInstallAsVerified(ctx, hIOS.ID, vpp1_1_ios, uuid.NewString())
require.NoError(t, err)
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 1)
require.Equal(t, ihaCmd, pendingActs[0].UUID)
// record a successful result for the in-house app, will not become "past" until verification
cmdRes = &mdm.CommandResults{
CommandUUID: ihaCmd,
Status: "Acknowledged",
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?>`),
}
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = ds.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{
HostID: hIOS.ID,
CommandUUID: ihaCmd,
Status: string(fleet.SoftwareInstalled),
}, []byte(`{}`), time.Now())
require.NoError(t, err)
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 1)
require.Equal(t, ihaCmd, pendingActs[0].UUID)
// mark the in-house app as failed, will become "past"
err = ds.SetVPPInstallAsFailed(ctx, hIOS.ID, ihaCmd, uuid.NewString())
require.NoError(t, err)
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, hIOS.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 0)
}
func testActivateItselfOnEmptyQueue(t *testing.T, ds *Datastore) {
@@ -1549,6 +1718,8 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
nanoEnrollAndSetHostMDMData(t, ds, host, false)
hostLeftUntouched := test.NewHost(t, ds, "h2.local", "10.10.10.2", "2", "2", time.Now())
nanoEnrollAndSetHostMDMData(t, ds, hostLeftUntouched, false)
hostIOS := test.NewHost(t, ds, "h3.local", "10.10.10.3", "3", "3", time.Now(), test.WithPlatform("ios"))
nanoEnrollAndSetHostMDMData(t, ds, hostIOS, false)
nanoDB, err := nanomdm_mysql.New(nanomdm_mysql.WithDB(ds.primary.DB))
require.NoError(t, err)
@@ -1575,11 +1746,13 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
cases := []struct {
desc string
host *fleet.Host
setup func(t *testing.T) []string
cancelIndex int
}{
{
desc: "cancel software install",
host: host,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostScriptUpcomingActivity(t, ds, host)
exec2 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u)
@@ -1592,6 +1765,7 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
},
{
desc: "cancel script exec",
host: host,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u)
exec2 := test.CreateHostScriptUpcomingActivity(t, ds, host)
@@ -1604,6 +1778,7 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
},
{
desc: "cancel software uninstall",
host: host,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u)
exec2 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u)
@@ -1616,6 +1791,7 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
},
{
desc: "cancel vpp install",
host: host,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u)
exec2, _ := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host)
@@ -1628,6 +1804,7 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
},
{
desc: "cancel script with another activity after",
host: host,
setup: func(t *testing.T) []string {
exec1, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host)
exec2 := test.CreateHostScriptUpcomingActivity(t, ds, host)
@@ -1642,6 +1819,7 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
},
{
desc: "cancel software uninstall with a couple activities before",
host: host,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u)
exec2 := test.CreateHostScriptUpcomingActivity(t, ds, host)
@@ -1654,22 +1832,35 @@ func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
},
cancelIndex: 2,
},
{
desc: "cancel in-house install",
host: hostIOS,
setup: func(t *testing.T) []string {
exec1, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, hostIOS)
exec2 := test.CreateHostInHouseAppInstallUpcomingActivity(t, ds, hostIOS, u)
t.Cleanup(func() {
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec1, adamID, "Acknowledged")
})
return []string{exec1, exec2}
},
cancelIndex: 1,
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
execIDs := c.setup(t)
got, _, err := ds.ListHostUpcomingActivities(ctx, host.ID, fleet.ListOptions{})
got, _, err := ds.ListHostUpcomingActivities(ctx, c.host.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, got, len(execIDs))
require.Equal(t, execIDs, pluckExecIDs(got))
cancelExecID := execIDs[c.cancelIndex]
expectedExecIDs := append(execIDs[:c.cancelIndex], execIDs[c.cancelIndex+1:]...) // nolint: gocritic
_, err = ds.CancelHostUpcomingActivity(ctx, host.ID, cancelExecID)
_, err = ds.CancelHostUpcomingActivity(ctx, c.host.ID, cancelExecID)
require.NoError(t, err)
got, _, err = ds.ListHostUpcomingActivities(ctx, host.ID, fleet.ListOptions{})
got, _, err = ds.ListHostUpcomingActivities(ctx, c.host.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, got, len(expectedExecIDs))
require.Equal(t, expectedExecIDs, pluckExecIDs(got))
@@ -1693,6 +1884,8 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
nanoEnrollAndSetHostMDMData(t, ds, host, false)
hostLeftUntouched := test.NewHost(t, ds, "h2.local", "10.10.10.2", "2", "2", time.Now())
nanoEnrollAndSetHostMDMData(t, ds, hostLeftUntouched, false)
hostIOS := test.NewHost(t, ds, "h3.local", "10.10.10.3", "3", "3", time.Now(), test.WithPlatform("ios"))
nanoEnrollAndSetHostMDMData(t, ds, hostIOS, false)
nanoDB, err := nanomdm_mysql.New(nanomdm_mysql.WithDB(ds.primary.DB))
require.NoError(t, err)
@@ -1710,10 +1903,12 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
cases := []struct {
desc string
host *fleet.Host
setup func(t *testing.T) []string
}{
{
desc: "cancel script",
host: host,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostScriptUpcomingActivity(t, ds, host)
exec2 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u)
@@ -1725,6 +1920,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
},
{
desc: "cancel sofware install",
host: host,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u)
exec2 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u)
@@ -1736,6 +1932,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
},
{
desc: "cancel sofware uninstall",
host: host,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u)
exec2, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host)
@@ -1747,6 +1944,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
},
{
desc: "cancel vpp install",
host: host,
setup: func(t *testing.T) []string {
exec1, _ := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host)
exec2 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u)
@@ -1758,6 +1956,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
},
{
desc: "cancel script none after",
host: host,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostScriptUpcomingActivity(t, ds, host)
return []string{exec1}
@@ -1765,6 +1964,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
},
{
desc: "cancel sofware install with a couple after",
host: host,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u)
exec2 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u)
@@ -1778,6 +1978,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
},
{
desc: "cancel sofware uninstall none after",
host: host,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u)
return []string{exec1}
@@ -1785,6 +1986,7 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
},
{
desc: "cancel vpp install same after",
host: host,
setup: func(t *testing.T) []string {
exec1, _ := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host)
exec2, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host)
@@ -1794,22 +1996,46 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
return []string{exec1, exec2}
},
},
{
desc: "cancel in-house install",
host: hostIOS,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostInHouseAppInstallUpcomingActivity(t, ds, hostIOS, u)
exec2, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, hostIOS)
t.Cleanup(func() {
test.SetHostVPPAppInstallResult(t, ds, nanoDB, hostIOS, exec2, adamID, "Acknowledged")
})
return []string{exec1, exec2}
},
},
{
desc: "cancel in-house install same after",
host: hostIOS,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostInHouseAppInstallUpcomingActivity(t, ds, hostIOS, u)
exec2 := test.CreateHostInHouseAppInstallUpcomingActivity(t, ds, hostIOS, u)
t.Cleanup(func() {
test.SetHostInHouseAppInstallResult(t, ds, nanoDB, hostIOS, exec2, "Acknowledged")
})
return []string{exec1, exec2}
},
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
execIDs := c.setup(t)
got, _, err := ds.ListHostUpcomingActivities(ctx, host.ID, fleet.ListOptions{})
got, _, err := ds.ListHostUpcomingActivities(ctx, c.host.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, got, len(execIDs))
require.Equal(t, execIDs, pluckExecIDs(got))
cancelExecID := execIDs[0]
expectedExecIDs := execIDs[1:]
_, err = ds.CancelHostUpcomingActivity(ctx, host.ID, cancelExecID)
_, err = ds.CancelHostUpcomingActivity(ctx, c.host.ID, cancelExecID)
require.NoError(t, err)
got, _, err = ds.ListHostUpcomingActivities(ctx, host.ID, fleet.ListOptions{})
got, _, err = ds.ListHostUpcomingActivities(ctx, c.host.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, got, len(expectedExecIDs))
require.Equal(t, expectedExecIDs, pluckExecIDs(got))
@@ -1817,21 +2043,21 @@ func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
// the next upcoming activity (and only this one) should show up in those
// lists of ready-to-process activities.
var gotExecIDs []string
scripts, err := ds.ListReadyToExecuteScriptsForHost(ctx, host.ID, false)
scripts, err := ds.ListReadyToExecuteScriptsForHost(ctx, c.host.ID, false)
require.NoError(t, err)
require.True(t, len(scripts) <= 1)
if len(scripts) == 1 {
gotExecIDs = append(gotExecIDs, scripts[0].ExecutionID)
}
sws, err := ds.ListReadyToExecuteSoftwareInstalls(ctx, host.ID)
sws, err := ds.ListReadyToExecuteSoftwareInstalls(ctx, c.host.ID)
require.NoError(t, err)
require.True(t, len(sws) <= 1)
gotExecIDs = append(gotExecIDs, sws...)
var nanoExecIDs []string
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
err := sqlx.SelectContext(ctx, q, &nanoExecIDs, `SELECT command_uuid FROM nano_view_queue WHERE id = ? AND active = 1 AND status IS NULL`, host.UUID)
err := sqlx.SelectContext(ctx, q, &nanoExecIDs, `SELECT command_uuid FROM nano_view_queue WHERE id = ? AND active = 1 AND status IS NULL`, c.host.UUID)
return err
})
require.True(t, len(nanoExecIDs) <= 1)
+31 -21
View File
@@ -566,6 +566,11 @@ var hostRefs = []string{
"host_mdm_commands",
"microsoft_compliance_partner_host_statuses",
"host_identity_scep_certificates",
// unlike for host_software_installs, where we use soft-delete so that
// existing activities can still access the installation details, this is not
// needed for in-house apps as the activity contains the MDM command UUID and
// can access the request/response without this table's entry.
"host_in_house_software_installs",
}
// NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not
@@ -1206,29 +1211,14 @@ func (ds *Datastore) applyHostFilters(
// software (version) ID filter is mutually exclusive with software title ID
// so we're reusing the same filter to avoid adding unnecessary conditions.
if opt.SoftwareStatusFilter != nil {
_, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter, false)
installerID, vppID, inHouseID, err := ds.installerAvailableForInstallForTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter)
switch {
case fleet.IsNotFound(err):
vppApp, err := ds.GetVPPAppByTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter)
if fleet.IsNotFound(err) {
// Neither installer nor VPP app exists → immediately return 0 hosts safelysts
softwareFilter = "FALSE"
break
} else if err != nil {
return "", nil, ctxerr.Wrap(ctx, err, "get vpp app by team and title id")
}
vppAppJoin, vppAppParams, err := ds.vppAppJoin(vppApp.VPPAppID, *opt.SoftwareStatusFilter)
if err != nil {
return "", nil, ctxerr.Wrap(ctx, err, "vpp app join")
}
softwareStatusJoin = vppAppJoin
joinParams = append(joinParams, vppAppParams...)
case err != nil:
return "", nil, ctxerr.Wrap(ctx, err, "get software installer metadata by team and title id")
default:
// TODO(sarah): prior code was joining on installer id but based on how list options are parsed [1] it seems like this should be the title id
// [1] https://github.com/fleetdm/fleet/blob/8aecae4d853829cb6e7f828099a4f0953643cf18/server/datastore/mysql/hosts.go#L1088-L1089
// it does not return an error for not found, only for actual db error
return "", nil, ctxerr.Wrap(ctx, err, "get available installer by team and title id")
case installerID > 0:
// found a software installer package
installerJoin, installerParams, err := ds.softwareInstallerJoin(*opt.SoftwareTitleIDFilter, *opt.SoftwareStatusFilter)
if err != nil {
return "", nil, ctxerr.Wrap(ctx, err, "software installer join")
@@ -1236,6 +1226,26 @@ func (ds *Datastore) applyHostFilters(
softwareStatusJoin = installerJoin
joinParams = append(joinParams, installerParams...)
case vppID != nil:
// found a VPP app
vppAppJoin, vppAppParams, err := ds.vppAppJoin(*vppID, *opt.SoftwareStatusFilter)
if err != nil {
return "", nil, ctxerr.Wrap(ctx, err, "vpp app join")
}
softwareStatusJoin = vppAppJoin
joinParams = append(joinParams, vppAppParams...)
case inHouseID > 0:
inHouseJoin, inHouseParams, err := ds.inHouseAppJoin(inHouseID, *opt.SoftwareStatusFilter)
if err != nil {
return "", nil, ctxerr.Wrap(ctx, err, "in-house app join")
}
softwareStatusJoin = inHouseJoin
joinParams = append(joinParams, inHouseParams...)
default:
// no installer found, return as was done before
softwareFilter = "FALSE"
}
} else {
softwareFilter = "EXISTS (SELECT 1 FROM host_software hs INNER JOIN software sw ON hs.software_id = sw.id WHERE hs.host_id = h.id AND sw.title_id = ?)"
+15 -1
View File
@@ -3585,7 +3585,7 @@ func testHostsListByPolicy(t *testing.T, ds *Datastore) {
}
func testHostsListBySoftware(t *testing.T, ds *Datastore) {
for i := 0; i < 10; i++ {
for i := range 10 {
_, err := ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
@@ -8449,6 +8449,20 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
`, certSerial, host.ID, "test-host", time.Now().Add(-1*time.Hour), time.Now().Add(24*time.Hour), "-----BEGIN CERTIFICATE-----", []byte{0x04})
require.NoError(t, err)
_, _, err = ds.insertInHouseApp(ctx, &fleet.InHouseAppPayload{
Name: "test",
StorageID: uuid.NewString(),
Platform: string(fleet.MacOSPlatform),
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
var inHouseID uint
err = ds.writer(ctx).Get(&inHouseID, "SELECT id FROM in_house_apps WHERE name = ?", "test")
require.NoError(t, err)
_, err = ds.writer(ctx).Exec("INSERT INTO host_in_house_software_installs (host_id, in_house_app_id, command_uuid, platform) VALUES (?, ?, ?, ?)",
host.ID, inHouseID, uuid.NewString(), fleet.MacOSPlatform)
require.NoError(t, err)
// Check there's an entry for the host in all the associated tables.
for _, hostRef := range hostRefs {
var ok bool
+637
View File
@@ -0,0 +1,637 @@
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"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/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 name = ?)`
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.Name, payload.BundleID, "ipados_apps")
if err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "insertInHouseApp")
}
titleIDios, err := ds.getOrGenerateInHouseAppTitleID(ctx, payload.Name, payload.BundleID, "ios_apps")
if err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "insertInHouseApp")
}
var installerID uint
var count uint
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
row := tx.QueryRowxContext(ctx, selectStmt, globalOrTeamID, payload.BundleID, payload.Name)
if err := row.Scan(&count); err != nil {
return ctxerr.Wrap(ctx, err, "insertInHouseApp")
}
if count > 0 {
// ios or ipados version of this installer exists
err = alreadyExists("insertInHouseApp", payload.Name)
}
argsIos := []any{
tid,
globalOrTeamID,
payload.Name,
payload.StorageID,
payload.Version,
payload.BundleID,
titleIDios,
"ios",
}
argsIpad := []any{
tid,
globalOrTeamID,
payload.Name,
payload.StorageID,
payload.Version,
payload.BundleID,
titleIDipad,
"ipados",
}
_, err := ds.insertInHouseAppDB(ctx, tx, payload, argsIpad)
if err != nil {
return ctxerr.Wrap(ctx, err, "insertInHouseApp")
}
installerID, err = ds.insertInHouseAppDB(ctx, tx, payload, argsIos)
if err != nil {
return ctxerr.Wrap(ctx, err, "insertInHouseApp")
}
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 = ? OR (name = ? AND source = ?)`
selectArgs := []any{bundleID, source, name, source}
insertStmt := `INSERT INTO software_titles (name, source, bundle_identifier, extension_for) VALUES (?, ?, ?, '')`
insertArgs := []any{name, source, bundleID}
titleID, err := ds.optimisticGetOrInsert(ctx,
&parameterizedStmt{
Statement: selectStmt,
Args: selectArgs,
},
&parameterizedStmt{
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,
name,
storage_id,
version,
bundle_identifier,
title_id,
platform
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
res, err := tx.ExecContext(ctx, stmt, args...)
if err != nil {
if IsDuplicate(err) {
err = alreadyExists("insertInHouseAppDB", payload.Name)
}
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")
}
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,
COALESCE(iha.name, '') AS software_title,
iha.platform,
iha.storage_id,
st.bundle_identifier AS bundle_identifier,
iha.version
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
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 = ?,
name = ?,
version = ?
WHERE id = ?`
args := []any{
payload.StorageID,
payload.Filename,
payload.Version,
payload.InstallerID,
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
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")
}
}
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 in_house_apps WHERE id = ?`, id)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete in house app")
}
return err
})
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 = ?`, 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
past AS (
SELECT
hihsi.host_id,
CASE
WHEN ncr.status = :mdm_status_acknowledged THEN
:software_status_installed
WHEN ncr.status = :mdm_status_error OR ncr.status = :mdm_status_format_error THEN
:software_status_failed
ELSE
NULL -- either pending or not installed
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(
'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,
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
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
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"`
}
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,
}
return user, act, nil
}
@@ -0,0 +1,321 @@
package mysql
import (
"context"
"fmt"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
nanomdm_mysql "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/mysql"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)
func TestInHouseApps(t *testing.T) {
ds := CreateMySQLDS(t)
cases := []struct {
name string
fn func(t *testing.T, ds *Datastore)
}{
{"TestInHouseAppsCrud", testInHouseAppsCrud},
{"MultipleTeams", testInHouseAppsMultipleTeams},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer TruncateTables(t, ds)
c.fn(t, ds)
})
}
}
func testInHouseAppsCrud(t *testing.T, ds *Datastore) {
ctx := context.Background()
host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now())
host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now())
host3 := test.NewHost(t, ds, "host3", "3", "host3key", "host3uuid", time.Now())
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host1.ID, host2.ID, host3.ID}))
require.NoError(t, err)
nanoEnroll(t, ds, host1, false)
nanoEnroll(t, ds, host2, false)
nanoEnroll(t, ds, host3, false)
payload := fleet.UploadSoftwareInstallerPayload{
TeamID: &team.ID,
UserID: user1.ID,
Title: "foo",
BundleIdentifier: "com.foo",
StorageID: "testingtesting123",
Platform: "ios",
Extension: "ipa",
Version: "1.2.3",
}
// -------------------------
// Upload software installer
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &payload)
require.Error(t, err, "ValidatedLabels must not be nil")
payload.ValidatedLabels = &fleet.LabelIdentsWithScope{}
installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &payload)
require.NoError(t, err)
require.NotZero(t, installerID)
require.NotZero(t, titleID)
// both ios and ipados apps are created, both installer and title
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
var countI uint
var countS uint
errI := sqlx.GetContext(ctx, q, &countI, `SELECT COUNT(*) FROM in_house_apps`)
errS := sqlx.GetContext(ctx, q, &countS, `SELECT COUNT(*) FROM software_titles`)
require.NoError(t, errI)
require.NoError(t, errS)
require.Equal(t, uint(2), countI)
require.Equal(t, uint(2), countS)
return nil
})
installer, err := ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, titleID)
require.NoError(t, err)
require.Equal(t, payload.Title, installer.SoftwareTitle)
require.Equal(t, payload.Version, installer.Version)
// Install on multiple users with pending, success, failure
createInHouseAppInstallRequest(t, ds, host1.ID, installerID, titleID, user1)
cmdUUID2 := createInHouseAppInstallRequest(t, ds, host2.ID, installerID, titleID, user1)
createInHouseAppInstallResult(t, ds, host2, cmdUUID2, "Acknowledged")
cmdUUID3 := createInHouseAppInstallRequest(t, ds, host3.ID, installerID, titleID, user1)
createInHouseAppInstallResult(t, ds, host3, cmdUUID3, "Error")
// Get summary and expect failed, installed, pending
summary, err := ds.GetSummaryHostInHouseAppInstalls(ctx, &team.ID, installerID)
require.NoError(t, err)
require.Equal(t, fleet.VPPAppStatusSummary{Installed: 1, Pending: 1, Failed: 1}, *summary)
// -------------------------
// Update software installer
label, err := ds.NewLabel(ctx, &fleet.Label{Name: "include-any-1", Query: "select 1"})
require.NoError(t, err)
validatedLabels := fleet.LabelIdentsWithScope{
LabelScope: "include_any",
ByName: map[string]fleet.LabelIdent{
"include-any-1": {
LabelID: label.ID,
LabelName: label.Name,
},
}}
updatePayload := fleet.UpdateSoftwareInstallerPayload{
TeamID: &team.ID,
TitleID: titleID,
InstallerID: installerID,
Filename: "ipa_test.ipa",
StorageID: "new_storage_id",
ValidatedLabels: &validatedLabels,
}
err = ds.SaveInHouseAppUpdates(ctx, &updatePayload)
require.NoError(t, err)
// Installer updates correctly
var expectedLabels []fleet.SoftwareScopeLabel
expectedLabels = append(expectedLabels, fleet.SoftwareScopeLabel{LabelID: label.ID, LabelName: label.Name, Exclude: false, TitleID: titleID})
newInstaller, err := ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, titleID)
require.NoError(t, err)
require.Equal(t, "new_storage_id", newInstaller.StorageID)
require.Equal(t, expectedLabels, newInstaller.LabelsIncludeAny)
// Summary is unchanged?
summary2, err := ds.GetSummaryHostInHouseAppInstalls(ctx, &team.ID, installerID)
require.NoError(t, err)
require.Equal(t, summary, summary2)
// -------------------------
// Delete software installer
err = ds.DeleteInHouseApp(ctx, installerID)
require.NoError(t, err)
// TODO: test RemovePendingInHouseAppInstalls independently
_, err = ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, titleID)
require.Error(t, err)
status, err := ds.GetSummaryHostInHouseAppInstalls(ctx, &team.ID, installerID)
require.NoError(t, err)
require.Zero(t, *status)
// Check that entire tables are empty for this test
checkEmpty := func(table string) {
var count int
err := sqlx.GetContext(ctx, ds.reader(ctx), &count, fmt.Sprintf(`SELECT COUNT(*) FROM %s`, table))
require.NoError(t, err)
require.Zero(t, count, "expected %s to be empty", table)
}
checkEmpty("in_house_app_labels")
checkEmpty("host_in_house_software_installs")
checkEmpty("in_house_app_upcoming_activities")
checkEmpty("upcoming_activities")
// ipadOS installer should remain
var ipadID uint
err = sqlx.GetContext(ctx, ds.reader(ctx), &ipadID, `SELECT id FROM in_house_apps LIMIT 1`)
require.NoError(t, err)
// Try to upload installer again, expect duplicate error
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &payload)
require.Error(t, err)
// Delete ipadOS installer
err = ds.DeleteInHouseApp(ctx, ipadID)
require.NoError(t, err)
var count int
err = sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM in_house_apps`)
require.NoError(t, err)
require.Zero(t, count, "expected in_house_apps to be empty")
}
func testInHouseAppsMultipleTeams(t *testing.T, ds *Datastore) {
ctx := context.Background()
host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now())
host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now())
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"})
require.NoError(t, err)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host1.ID}))
require.NoError(t, err)
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"})
require.NoError(t, err)
err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team2.ID, []uint{host2.ID}))
require.NoError(t, err)
nanoEnroll(t, ds, host1, false)
payload1 := fleet.UploadSoftwareInstallerPayload{
TeamID: &team1.ID,
UserID: user1.ID,
Title: "foo",
BundleIdentifier: "com.foo",
StorageID: "testingtesting123",
Platform: "ios",
Extension: "ipa",
Version: "1.2.3",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
}
payload2 := payload1
payload2.TeamID = &team2.ID
payloadNoTeam := payload1
payloadNoTeam.TeamID = nil
// Add installers for both teams
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &payload1)
require.NoError(t, err)
installerID2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &payload2)
require.NoError(t, err)
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &payloadNoTeam)
require.NoError(t, err)
var count int
err = sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(id) FROM software_titles`)
require.NoError(t, err)
require.Equal(t, 2, count)
err = sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(id) FROM in_house_apps`)
require.NoError(t, err)
require.Equal(t, 6, count)
// Team 2: Delete 1 installer from 1 team
err = ds.DeleteInHouseApp(ctx, installerID2)
require.NoError(t, err)
err = sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(id) FROM in_house_apps`)
require.NoError(t, err)
require.Equal(t, 5, count)
// Team 2: Try to add installer again
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &payload2)
require.Error(t, err)
err = sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(id) FROM in_house_apps`)
require.NoError(t, err)
require.Equal(t, 5, count)
// Test that software titles for IHA don't get cleaned up
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
require.NoError(t, ds.CleanupSoftwareTitles(ctx))
require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now()))
err = sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(id) FROM software_titles`)
require.NoError(t, err)
require.Equal(t, 2, count)
}
func createInHouseAppInstallRequest(t *testing.T, ds *Datastore, hostID uint, appID uint, titleID uint, user *fleet.User) string {
ctx := context.Background()
ctx = viewer.NewContext(ctx, viewer.Viewer{User: user})
cmdUUID := uuid.NewString()
err := ds.InsertHostInHouseAppInstall(ctx, hostID, appID, titleID, cmdUUID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
return cmdUUID
}
func createInHouseAppInstallResult(t *testing.T, ds *Datastore, host *fleet.Host, cmdUUID string, status string) {
ctx := context.Background()
ctx = context.WithValue(ctx, fleet.ActivityWebhookContextKey, true)
nanoDB, err := nanomdm_mysql.New(nanomdm_mysql.WithDB(ds.primary.DB))
require.NoError(t, err)
nanoCtx := &mdm.Request{EnrollID: &mdm.EnrollID{ID: host.UUID}, Context: ctx}
cmdRes := &mdm.CommandResults{
CommandUUID: cmdUUID,
Status: status,
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?>`),
}
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
// inserting the activity is what marks the upcoming activity as completed
// (and activates the next one).
err = ds.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
HostID: host.ID,
CommandUUID: cmdUUID,
}, []byte(`{}`), time.Now())
require.NoError(t, err)
}
func createInHouseAppInstallResultVerified(t *testing.T, ds *Datastore, host *fleet.Host, cmdUUID string, status string) {
createInHouseAppInstallResult(t, ds, host, cmdUUID, status)
ctx := t.Context()
timestampCol := "verification_at"
if status != "Acknowledged" {
timestampCol = "verification_failed_at"
}
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, fmt.Sprintf(`UPDATE host_in_house_software_installs SET
%s = NOW(6), verification_command_uuid = ? WHERE host_id = ? AND command_uuid = ?`, timestampCol),
uuid.NewString(), host.ID, cmdUUID)
return err
})
}
+26 -16
View File
@@ -816,31 +816,41 @@ func (ds *Datastore) applyHostLabelFilters(ctx context.Context, filter fleet.Tea
// // TODO: Do we currently support filtering by software version ID and label?
// }
if opt.SoftwareTitleIDFilter != nil && opt.SoftwareStatusFilter != nil {
// check for software installer metadata
_, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter, false)
installerID, vppID, inHouseID, err := ds.installerAvailableForInstallForTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter)
switch {
case fleet.IsNotFound(err):
vppApp, err := ds.GetVPPAppByTeamAndTitleID(ctx, opt.TeamFilter, *opt.SoftwareTitleIDFilter)
if err != nil {
return "", nil, ctxerr.Wrap(ctx, err, "get vpp app by team and title id")
}
vppAppJoin, vppAppParams, err := ds.vppAppJoin(vppApp.VPPAppID, *opt.SoftwareStatusFilter)
if err != nil {
return "", nil, ctxerr.Wrap(ctx, err, "vpp app join")
}
softwareStatusJoin = vppAppJoin
joinParams = append(joinParams, vppAppParams...)
case err != nil:
return "", nil, ctxerr.Wrap(ctx, err, "get software installer metadata by team and title id")
// it does not return an error for not found, only for actual db error
return "", nil, ctxerr.Wrap(ctx, err, "get available installer by team and title id")
default:
case installerID > 0:
// found a software installer package
installerJoin, installerParams, err := ds.softwareInstallerJoin(*opt.SoftwareTitleIDFilter, *opt.SoftwareStatusFilter)
if err != nil {
return "", nil, ctxerr.Wrap(ctx, err, "software installer join")
}
softwareStatusJoin = installerJoin
joinParams = append(joinParams, installerParams...)
case vppID != nil:
// found a VPP app
vppAppJoin, vppAppParams, err := ds.vppAppJoin(*vppID, *opt.SoftwareStatusFilter)
if err != nil {
return "", nil, ctxerr.Wrap(ctx, err, "vpp app join")
}
softwareStatusJoin = vppAppJoin
joinParams = append(joinParams, vppAppParams...)
case inHouseID > 0:
inHouseJoin, inHouseParams, err := ds.inHouseAppJoin(inHouseID, *opt.SoftwareStatusFilter)
if err != nil {
return "", nil, ctxerr.Wrap(ctx, err, "in-house app join")
}
softwareStatusJoin = inHouseJoin
joinParams = append(joinParams, inHouseParams...)
default:
// no installer found, return as was done before (which was a not-found error, here, unlike in applyHostsFilter)
return "", nil, ctxerr.Wrap(ctx, notFound("installerAvailableForInstall"), "get available software installer by team and title id")
}
}
if softwareStatusJoin != "" {
+1 -1
View File
@@ -2299,7 +2299,7 @@ GROUP BY
return counts, nil
}
func (ds *Datastore) IsHostPendingVPPInstallVerification(ctx context.Context, hostUUID string) (bool, error) {
func (ds *Datastore) IsHostPendingMDMInstallVerification(ctx context.Context, hostUUID string) (bool, error) {
stmt := `
SELECT EXISTS (
SELECT 1
@@ -0,0 +1,60 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20251027101151, Down_20251027101151)
}
func Up_20251027101151(tx *sql.Tx) error {
createTableStmt := `
CREATE TABLE in_house_apps (
id int unsigned NOT NULL AUTO_INCREMENT,
title_id int unsigned DEFAULT NULL,
team_id int unsigned DEFAULT NULL,
global_or_team_id int unsigned NOT NULL DEFAULT '0',
name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
version VARCHAR(255) NOT NULL DEFAULT '',
storage_id VARCHAR(64) COLLATE utf8mb4_unicode_ci NOT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
platform varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
bundle_identifier VARCHAR(255) NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE KEY (global_or_team_id,name,platform),
CONSTRAINT fk_in_house_apps_title FOREIGN KEY (title_id) REFERENCES software_titles (id) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`
if _, err := tx.Exec(createTableStmt); err != nil {
return fmt.Errorf("create in_house_apps table: %w", err)
}
createLabelMappingTableStmt := `
CREATE TABLE in_house_app_labels (
id int unsigned NOT NULL AUTO_INCREMENT,
in_house_app_id int unsigned NOT NULL,
label_id int unsigned NOT NULL,
exclude tinyint(1) NOT NULL DEFAULT '0',
created_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY id_in_house_app_labels_in_house_app_id_label_id (in_house_app_id,label_id),
KEY label_id (label_id),
CONSTRAINT in_house_app_labels_ibfk_1 FOREIGN KEY (in_house_app_id) REFERENCES in_house_apps (id) ON DELETE CASCADE,
CONSTRAINT in_house_app_labels_ibfk_2 FOREIGN KEY (label_id) REFERENCES labels (id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`
if _, err := tx.Exec(createLabelMappingTableStmt); err != nil {
return fmt.Errorf("create in_house_app_labels table: %w", err)
}
return nil
}
func Down_20251027101151(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,14 @@
package tables
import "testing"
func TestUp_20251027101151(t *testing.T) {
db := applyUpToPrev(t)
// These are brand new tables, so no logic to test here.
// Leaving it in because it's nice to validate that the migration applies successfully.
// Apply current migration.
applyNext(t, db)
}
@@ -0,0 +1,101 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20251027101155, Down_20251027101155)
}
func Up_20251027101155(tx *sql.Tx) error {
// Note that at the moment of this migration, in-house apps uninstall is not
// supported, so we don't add it to the enum.
_, err := tx.Exec(`
ALTER TABLE upcoming_activities
CHANGE COLUMN activity_type activity_type ENUM('script', 'software_install', 'software_uninstall', 'vpp_app_install', 'in_house_app_install')
COLLATE utf8mb4_unicode_ci NOT NULL
`)
if err != nil {
return fmt.Errorf("failed to alter upcoming_activities activity_type: %w", err)
}
// Note that at the moment of this migration, auto-install and self-service is not
// supported for in-house apps, so we don't need to add columns for e.g. policy_id.
// See https://www.figma.com/design/zcc45sBgdiDZT11iKjLolh/-30936-Deploy-custom--in-house--iOS-app?node-id=5363-11227&t=S1pEnokvQ83v8eJk-0
_, err = tx.Exec(`
CREATE TABLE in_house_app_upcoming_activities (
upcoming_activity_id BIGINT UNSIGNED NOT NULL,
-- those are all columns and not JSON fields because we need FKs on them to
-- do processing ON DELETE, otherwise we'd have to check for existence of
-- each one when executing the activity (we need the enqueue next activity
-- action to be efficient).
in_house_app_id INT UNSIGNED NOT NULL,
software_title_id INT UNSIGNED DEFAULT NULL,
-- Using DATETIME instead of TIMESTAMP to prevent future Y2K38 issues
created_at DATETIME(6) NOT NULL DEFAULT NOW(6),
updated_at DATETIME(6) NOT NULL DEFAULT NOW(6) ON UPDATE NOW(6),
PRIMARY KEY (upcoming_activity_id),
CONSTRAINT fk_in_house_app_upcoming_activities_upcoming_activity_id
FOREIGN KEY (upcoming_activity_id) REFERENCES upcoming_activities (id) ON DELETE CASCADE,
CONSTRAINT fk_in_house_app_upcoming_activities_in_house_app_id
FOREIGN KEY (in_house_app_id) REFERENCES in_house_apps (id) ON DELETE CASCADE,
CONSTRAINT fk_in_house_app_upcoming_activities_software_title_id
FOREIGN KEY (software_title_id) REFERENCES software_titles (id) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
`,
)
if err != nil {
return fmt.Errorf("failed to create in_house_app_upcoming_activities table: %w", err)
}
// Note that at the time of this migration, in-house apps do not support
// auto-install and self-service installs so those columns have not been added.
// See https://www.figma.com/design/zcc45sBgdiDZT11iKjLolh/-30936-Deploy-custom--in-house--iOS-app?node-id=5363-11227&t=S1pEnokvQ83v8eJk-0
_, err = tx.Exec(`
-- This table is the in-house app equivalent of the host_vpp_software_installs table.
-- It tracks the installation of in-house software on particular hosts.
CREATE TABLE host_in_house_software_installs (
id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
host_id INT(10) UNSIGNED NOT NULL,
in_house_app_id INT(10) UNSIGNED NOT NULL,
-- This is the UUID of the MDM command issued to install the app
command_uuid VARCHAR(127) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
user_id INT(10) UNSIGNED NULL,
platform VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
removed TINYINT NOT NULL DEFAULT '0',
canceled TINYINT NOT NULL DEFAULT '0',
verification_command_uuid VARCHAR(127) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
verification_at DATETIME(6) DEFAULT NULL,
verification_failed_at DATETIME(6) DEFAULT NULL,
-- Using DATETIME instead of TIMESTAMP to prevent future Y2K38 issues
created_at DATETIME(6) NOT NULL DEFAULT NOW(6),
updated_at DATETIME(6) NOT NULL DEFAULT NOW(6) ON UPDATE NOW(6),
PRIMARY KEY(id),
UNIQUE INDEX idx_host_in_house_software_installs_command_uuid (command_uuid),
CONSTRAINT fk_host_in_house_software_installs_user_id
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
CONSTRAINT fk_host_in_house_software_installs_in_house_app_id
FOREIGN KEY (in_house_app_id) REFERENCES in_house_apps (id) ON DELETE CASCADE,
INDEX idx_host_in_house_software_installs_verification ((verification_at IS NULL AND verification_failed_at IS NULL))
) DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci`)
if err != nil {
return fmt.Errorf("failed to create table host_in_house_software_installs: %w", err)
}
return nil
}
func Down_20251027101155(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,46 @@
package tables
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestUp_20251027101155(t *testing.T) {
db := applyUpToPrev(t)
hostID := insertHost(t, db, nil)
contentIDs := insertScriptContents(t, db, 1)
// create an upcoming activity for script run on that host
execID := uuid.NewString()
uaID := execNoErrLastID(t, db, `INSERT INTO upcoming_activities (
host_id, activity_type, execution_id, payload
) VALUES (?, ?, ?, ?)`, hostID, "script", execID, `{}`)
execNoErr(t, db, `INSERT INTO script_upcoming_activities (
upcoming_activity_id, script_content_id
) VALUES (?, ?)`, uaID, contentIDs[0])
// Apply current migration.
applyNext(t, db)
assertRowCount(t, db, "upcoming_activities", 1)
// activity type is still "script"
var activityType string
err := db.Get(&activityType, "SELECT activity_type FROM upcoming_activities WHERE id = ?", uaID)
require.NoError(t, err)
require.Equal(t, "script", activityType)
// activity can now be in_house_app_install
execID2 := uuid.NewString()
uaID2 := execNoErrLastID(t, db, `INSERT INTO upcoming_activities (
host_id, activity_type, execution_id, payload
) VALUES (?, ?, ?, ?)`, hostID, "in_house_app_install", execID2, `{}`)
err = db.Get(&activityType, "SELECT activity_type FROM upcoming_activities WHERE id = ?", uaID2)
require.NoError(t, err)
require.Equal(t, "in_house_app_install", activityType)
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+181 -11
View File
@@ -197,6 +197,24 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload
return 0, 0, errors.New("validated labels must not be nil")
}
// Insert in house app instead of software installer
if payload.Extension == "ipa" {
// Insert both iOS and ipadOS titles per https://github.com/fleetdm/fleet/issues/34283
installerID, titleID, err := ds.insertInHouseApp(ctx, &fleet.InHouseAppPayload{
TeamID: payload.TeamID,
Name: payload.Title,
BundleID: payload.BundleIdentifier,
StorageID: payload.StorageID,
Platform: payload.Platform,
ValidatedLabels: payload.ValidatedLabels,
Version: payload.Version,
})
if err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "MatchOrCreateSoftwareInstaller: ")
}
return installerID, titleID, err
}
titleID, err = ds.getOrGenerateSoftwareInstallerTitleID(ctx, payload)
if err != nil {
return 0, 0, ctxerr.Wrap(ctx, err, "get or generate software installer title ID")
@@ -476,7 +494,6 @@ func (ds *Datastore) getOrGenerateSoftwareInstallerTitleID(ctx context.Context,
if err != nil {
return 0, err
}
return titleID, nil
}
@@ -502,8 +519,9 @@ func (ds *Datastore) addSoftwareTitleToMatchingSoftware(ctx context.Context, tit
type softwareType string
const (
softwareTypeInstaller softwareType = "software_installer"
softwareTypeVPP softwareType = "vpp_app_team"
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
@@ -723,6 +741,74 @@ WHERE
return &dest, nil
}
func (ds *Datastore) installerAvailableForInstallForTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (installerID uint, vppAppID *fleet.VPPAppID, inHouseID uint, err error) {
const stmt = `
SELECT
si.id AS installer_id,
NULL as vpp_adam_id,
NULL as vpp_platform,
NULL as in_house_id
FROM
software_installers si
WHERE
si.title_id = ? AND si.global_or_team_id = ?
UNION ALL
SELECT
NULL AS installer_id,
vap.adam_id AS vpp_adam_id,
vap.platform AS vpp_platform,
NULL as in_house_id
FROM
vpp_apps vap
JOIN vpp_apps_teams vat ON vap.adam_id = vat.adam_id AND vap.platform = vat.platform
WHERE
vap.title_id = ? AND vat.global_or_team_id = ?
UNION ALL
SELECT
NULL AS installer_id,
NULL as vpp_adam_id,
NULL as vpp_platform,
iha.id as in_house_id
FROM
in_house_apps iha
WHERE
iha.title_id = ? AND iha.global_or_team_id = ?
`
var tmID uint
if teamID != nil {
tmID = *teamID
}
type resultRow struct {
InstallerID sql.Null[uint] `db:"installer_id"`
VPPAdamID sql.Null[string] `db:"vpp_adam_id"`
VPPPlatform sql.Null[string] `db:"vpp_platform"`
InHouseID sql.Null[uint] `db:"in_house_id"`
}
var row resultRow
err = sqlx.GetContext(ctx, ds.reader(ctx), &row, stmt,
titleID, tmID, titleID, tmID, titleID, tmID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil, 0, nil
}
return 0, nil, 0, ctxerr.Wrap(ctx, err, "check installer/vpp/in-house app availability")
}
if row.VPPAdamID.Valid {
vppAppID = &fleet.VPPAppID{
AdamID: row.VPPAdamID.V,
Platform: fleet.AppleDevicePlatform(row.VPPPlatform.V),
}
}
return row.InstallerID.V, vppAppID, row.InHouseID.V, nil
}
func (ds *Datastore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) {
var scriptContentsSelect, scriptContentsFrom string
if withScriptContents {
@@ -778,7 +864,7 @@ WHERE
// TODO: do we want to include labels on other queries that return software installer metadata
// (e.g., GetSoftwareInstallerMetadataByID)?
labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID)
labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID, softwareTypeInstaller)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get software installer labels")
}
@@ -826,23 +912,23 @@ WHERE
return &dest, nil
}
func (ds *Datastore) getSoftwareInstallerLabels(ctx context.Context, installerID uint) ([]fleet.SoftwareScopeLabel, error) {
query := `
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
FROM
software_installer_labels sil
JOIN software_installers si ON si.id = sil.software_installer_id
%[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
software_installer_id = ?`
%[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, "get software installer labels")
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("get %s labels", softwareType))
}
return labels, nil
@@ -1570,6 +1656,85 @@ WHERE
})
}
func (ds *Datastore) inHouseAppJoin(inHouseID uint, status fleet.SoftwareInstallerStatus) (string, []any, error) {
// for pending status, we'll join through upcoming_activities
if status == fleet.SoftwarePending || status == fleet.SoftwareInstallPending || status == fleet.SoftwareUninstallPending {
stmt := `JOIN (
SELECT DISTINCT
host_id
FROM
upcoming_activities ua
JOIN in_house_app_upcoming_activities ihua ON ua.id = ihua.upcoming_activity_id
WHERE
%s) hss ON hss.host_id = h.id`
filter := "ihua.in_house_app_id = ?"
switch status {
case fleet.SoftwareInstallPending:
filter += " AND ua.activity_type = 'in_house_app_install'"
case fleet.SoftwareUninstallPending:
// TODO: Update this when in-house supports uninstall, for now we map
// uninstall to install to preserve existing behavior of VPP filters
filter += " AND ua.activity_type = 'in_house_app_install'"
default:
// no change, we're just filtering by title id so it will pick up any
// activity type that is associated with the app (i.e. both install and
// uninstall)
}
return fmt.Sprintf(stmt, filter), []any{inHouseID}, nil
}
// TODO: Update this when in-house app supports uninstall for now we map the
// generic failed status to the install status
if status == fleet.SoftwareFailed {
status = fleet.SoftwareInstallFailed // TODO: When in-house supports uninstall this should become STATUS IN ('failed_install', 'failed_uninstall')
}
stmt := fmt.Sprintf(`JOIN (
SELECT
hihsi.host_id
FROM
host_in_house_software_installs hihsi
INNER JOIN
nano_command_results ncr ON ncr.command_uuid = hihsi.command_uuid
LEFT JOIN host_in_house_software_installs hihsi2
ON hihsi.host_id = hihsi2.host_id AND
hihsi.in_house_app_id = hihsi2.in_house_app_id AND
hihsi2.canceled = 0 AND
hihsi2.removed = 0 AND
(hihsi.created_at < hihsi2.created_at OR (hihsi.created_at = hihsi2.created_at AND hihsi.id < hihsi2.id))
WHERE
hihsi2.id IS NULL
AND hihsi.in_house_app_id = :in_house_app_id
AND hihsi.canceled = 0
AND hihsi.removed = 0
AND (%s) = :status
AND NOT EXISTS (
SELECT 1
FROM
upcoming_activities ua
JOIN in_house_app_upcoming_activities ihua ON ua.id = ihua.upcoming_activity_id
WHERE
ua.host_id = hihsi.host_id
AND ihua.in_house_app_id = hihsi.in_house_app_id
AND ua.activity_type = 'in_house_app_install'
)
) hss ON hss.host_id = h.id
`, inHouseAppHostStatusNamedQuery("hihsi", "ncr", ""))
return sqlx.Named(stmt, map[string]any{
"status": status,
"in_house_app_id": inHouseID,
"software_status_installed": fleet.SoftwareInstalled,
"software_status_failed": fleet.SoftwareInstallFailed,
"software_status_pending": fleet.SoftwareInstallPending,
"mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged,
"mdm_status_error": fleet.MDMAppleStatusError,
"mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError,
})
}
func (ds *Datastore) GetHostLastInstallData(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) {
hostLastInstall, err := ds.getLatestUpcomingInstall(ctx, hostID, installerID)
if err != nil && errors.Is(err, sql.ErrNoRows) {
@@ -1641,9 +1806,14 @@ func (ds *Datastore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwa
// get the list of software installers hashes that are in use
var storageIDs []string
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &storageIDs, `SELECT DISTINCT storage_id FROM software_installers`); err != nil {
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")
@@ -275,8 +275,8 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
// Insert a setup experience status result to simulate this install is part of setup experience
_, err = ds.writer(ctx).ExecContext(ctx, `
INSERT INTO setup_experience_status_results
(host_uuid, name, status, software_installer_id, host_software_installs_execution_id)
INSERT INTO setup_experience_status_results
(host_uuid, name, status, software_installer_id, host_software_installs_execution_id)
VALUES (?, ?, ?, ?, ?)`,
host1.UUID, "test_software", fleet.SetupExperienceStatusPending, installerID1, setupExperienceInstallID)
require.NoError(t, err)
@@ -298,6 +298,11 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
createBuiltinLabels(t, ds)
labelsByName, err := ds.LabelIDsByName(ctx, []string{fleet.BuiltinLabelNameAllHosts})
require.NoError(t, err)
require.Len(t, labelsByName, 1)
cases := map[string]*uint{
"no team": nil,
"team": &team.ID,
@@ -332,6 +337,20 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
require.NotNil(t, si)
require.Equal(t, "foo.pkg", si.Name)
inHouseID, inHouseTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouse",
Source: "ios_apps",
TeamID: teamID,
Filename: "inhouse.ipa",
Extension: "ipa",
Platform: "ios",
UserID: user1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, inHouseID)
require.NotZero(t, inHouseTitleID)
// non-existent host
_, err = ds.InsertSoftwareInstallRequest(ctx, 12, si.InstallerID, fleet.HostSoftwareInstallOptions{})
require.ErrorAs(t, err, &nfe)
@@ -350,6 +369,21 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
_, err = ds.InsertSoftwareInstallRequest(ctx, hostPendingInstall.ID, si.InstallerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// Host with in-house app install pending
tag = "-in-house-pending_install"
hostInHousePendingInstall, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "ios-test" + tag + tc,
OsqueryHostID: ptr.String("osquery-ios" + tag + tc),
NodeKey: ptr.String("node-key-ios" + tag + tc),
UUID: uuid.NewString(),
Platform: "ios",
TeamID: teamID,
})
require.NoError(t, err)
nanoEnroll(t, ds, hostInHousePendingInstall, false)
err = ds.InsertHostInHouseAppInstall(ctx, hostInHousePendingInstall.ID, inHouseID, inHouseTitleID, uuid.NewString(), fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// Host with software install failed
tag = "-failed_install"
hostFailedInstall, err := ds.NewHost(ctx, &fleet.Host{
@@ -370,6 +404,42 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
// Host with in-house app failed install
tag = "-in-house-failed_install"
hostInHouseFailedInstall, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "ios-test" + tag + tc,
OsqueryHostID: ptr.String("osquery-ios" + tag + tc),
NodeKey: ptr.String("node-key-ios" + tag + tc),
UUID: uuid.NewString(),
Platform: "ios",
TeamID: teamID,
})
require.NoError(t, err)
nanoEnroll(t, ds, hostInHouseFailedInstall, false)
cmdUUID := uuid.NewString()
err = ds.InsertHostInHouseAppInstall(ctx, hostInHouseFailedInstall.ID, inHouseID, inHouseTitleID, cmdUUID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// record a failed verification for that in-house app install
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
INSERT INTO nano_command_results (id, command_uuid, status, result)
VALUES (?, ?, 'Error', '<?xml version="1.0"?><plist></plist>')`,
hostInHouseFailedInstall.UUID, cmdUUID)
if err != nil {
return err
}
_, err = q.ExecContext(ctx, `
UPDATE host_in_house_software_installs
SET verification_command_uuid = ?, verification_failed_at = NOW(6)
WHERE command_uuid = ? AND host_id = ?`,
uuid.NewString(), cmdUUID, hostInHouseFailedInstall.ID,
)
return err
})
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), hostInHouseFailedInstall.ID, cmdUUID)
require.NoError(t, err)
// Host with software install successful
tag = "-installed"
hostInstalled, err := ds.NewHost(ctx, &fleet.Host{
@@ -390,6 +460,42 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
// host with in-house successful install
tag = "-in-house-installed"
hostInHouseInstalled, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "ios-test" + tag + tc,
OsqueryHostID: ptr.String("osquery-ios" + tag + tc),
NodeKey: ptr.String("node-key-ios" + tag + tc),
UUID: uuid.NewString(),
Platform: "ios",
TeamID: teamID,
})
require.NoError(t, err)
nanoEnroll(t, ds, hostInHouseInstalled, false)
cmdUUID = uuid.NewString()
err = ds.InsertHostInHouseAppInstall(ctx, hostInHouseInstalled.ID, inHouseID, inHouseTitleID, cmdUUID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// record a successful verification for that in-house app install
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
INSERT INTO nano_command_results (id, command_uuid, status, result)
VALUES (?, ?, 'Acknowledged', '<?xml version="1.0"?><plist></plist>')`,
hostInHouseInstalled.UUID, cmdUUID)
if err != nil {
return err
}
_, err = q.ExecContext(ctx, `
UPDATE host_in_house_software_installs
SET verification_command_uuid = ?, verification_at = NOW(6)
WHERE command_uuid = ? AND host_id = ?`,
uuid.NewString(), cmdUUID, hostInHouseInstalled.ID,
)
return err
})
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), hostInHouseInstalled.ID, cmdUUID)
require.NoError(t, err)
// Host with pending uninstall
tag = "-pending_uninstall"
hostPendingUninstall, err := ds.NewHost(ctx, &fleet.Host{
@@ -450,6 +556,22 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
err = ds.InsertSoftwareUninstallRequest(ctx, "uuid"+tag+tc, 99999, si.InstallerID, false)
assert.ErrorContains(t, err, "Host")
allHostIDs := []uint{
hostPendingInstall.ID,
hostFailedInstall.ID,
hostInstalled.ID,
hostPendingUninstall.ID,
hostFailedUninstall.ID,
hostUninstalled.ID,
hostInHousePendingInstall.ID,
hostInHouseFailedInstall.ID,
hostInHouseInstalled.ID,
}
for _, hid := range allHostIDs {
err = ds.AddLabelsToHost(ctx, hid, []uint{labelsByName[fleet.BuiltinLabelNameAllHosts]})
require.NoError(t, err)
}
userTeamFilter := fleet.TeamFilter{
User: &fleet.User{GlobalRole: ptr.String("admin")},
}
@@ -462,107 +584,175 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
teamFilter = ptr.Uint(0)
}
// list hosts with software install pending requests
expectStatus := fleet.SoftwareInstallPending
hosts, err := ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: &expectStatus,
TeamFilter: teamFilter,
})
require.NoError(t, err)
// get the names of hosts, useful for debugging
getHostNames := func(hosts []*fleet.Host) []string {
hostNames := make([]string, len(hosts))
hostNames := make([]string, 0, len(hosts))
for _, h := range hosts {
hostNames = append(hostNames, h.Hostname)
}
return hostNames
}
require.Len(t, hosts, 1, getHostNames(hosts))
require.Equal(t, hostPendingInstall.ID, hosts[0].ID)
pluckHostIDs := func(hosts []*fleet.Host) []uint {
hostIDs := make([]uint, 0, len(hosts))
for _, h := range hosts {
hostIDs = append(hostIDs, h.ID)
}
return hostIDs
}
// list hosts with all pending requests
expectStatus = fleet.SoftwarePending
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: &expectStatus,
TeamFilter: teamFilter,
})
require.NoError(t, err)
require.Len(t, hosts, 2, getHostNames(hosts))
assert.ElementsMatch(t, []uint{hostPendingInstall.ID, hostPendingUninstall.ID}, []uint{hosts[0].ID, hosts[1].ID})
cases := []struct {
desc string
opts fleet.HostListOptions
wantHostIDs []uint
}{
{
desc: "list hosts with software install pending requests",
opts: fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: ptr.T(fleet.SoftwareInstallPending),
TeamFilter: teamFilter,
},
wantHostIDs: []uint{hostPendingInstall.ID},
},
{
desc: "list hosts with in-house app pending install",
opts: fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: &inHouseTitleID,
SoftwareStatusFilter: ptr.T(fleet.SoftwareInstallPending),
TeamFilter: teamFilter,
},
wantHostIDs: []uint{hostInHousePendingInstall.ID},
},
{
desc: "list hosts with all pending requests",
opts: fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: ptr.T(fleet.SoftwarePending),
TeamFilter: teamFilter,
},
wantHostIDs: []uint{hostPendingInstall.ID, hostPendingUninstall.ID},
},
{
desc: "list hosts with in-house app all pending requests",
opts: fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: &inHouseTitleID,
SoftwareStatusFilter: ptr.T(fleet.SoftwarePending),
TeamFilter: teamFilter,
},
wantHostIDs: []uint{hostInHousePendingInstall.ID},
},
{
desc: "list hosts with software install failed requests",
opts: fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: ptr.T(fleet.SoftwareInstallFailed),
TeamFilter: teamFilter,
},
wantHostIDs: []uint{hostFailedInstall.ID},
},
{
desc: "list hosts with in-house install failed requests",
opts: fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: &inHouseTitleID,
SoftwareStatusFilter: ptr.T(fleet.SoftwareInstallFailed),
TeamFilter: teamFilter,
},
wantHostIDs: []uint{hostInHouseFailedInstall.ID},
},
{
desc: "list hosts with all failed requests",
opts: fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: ptr.T(fleet.SoftwareFailed),
TeamFilter: teamFilter,
},
wantHostIDs: []uint{hostFailedInstall.ID, hostFailedUninstall.ID},
},
{
desc: "list hosts with in-house all failed requests",
opts: fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: &inHouseTitleID,
SoftwareStatusFilter: ptr.T(fleet.SoftwareFailed),
TeamFilter: teamFilter,
},
wantHostIDs: []uint{hostInHouseFailedInstall.ID},
},
{
desc: "list hosts with software installed",
opts: fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: ptr.T(fleet.SoftwareInstalled),
TeamFilter: teamFilter,
},
wantHostIDs: []uint{hostInstalled.ID},
},
{
desc: "list hosts with in-house app installed",
opts: fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: &inHouseTitleID,
SoftwareStatusFilter: ptr.T(fleet.SoftwareInstalled),
TeamFilter: teamFilter,
},
wantHostIDs: []uint{hostInHouseInstalled.ID},
},
{
desc: "list hosts with pending software uninstall requests",
opts: fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: ptr.T(fleet.SoftwareUninstallPending),
TeamFilter: teamFilter,
},
wantHostIDs: []uint{hostPendingUninstall.ID},
},
{
desc: "list hosts with failed software uninstall requests",
opts: fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: ptr.T(fleet.SoftwareUninstallFailed),
TeamFilter: teamFilter,
},
wantHostIDs: []uint{hostFailedUninstall.ID},
},
{
desc: "list all hosts with the software title",
opts: fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
TeamFilter: teamFilter,
},
wantHostIDs: []uint{},
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
hosts, err := ds.ListHosts(ctx, userTeamFilter, c.opts)
require.NoError(t, err)
require.Len(t, hosts, len(c.wantHostIDs), getHostNames(hosts))
require.ElementsMatch(t, c.wantHostIDs, pluckHostIDs(hosts))
// list hosts with software install failed requests
expectStatus = fleet.SoftwareInstallFailed
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: &expectStatus,
TeamFilter: teamFilter,
})
require.NoError(t, err)
require.Len(t, hosts, 1, getHostNames(hosts))
assert.ElementsMatch(t, []uint{hostFailedInstall.ID}, []uint{hosts[0].ID})
// list hosts with all failed requests
expectStatus = fleet.SoftwareFailed
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: &expectStatus,
TeamFilter: teamFilter,
})
require.NoError(t, err)
require.Len(t, hosts, 2, getHostNames(hosts))
assert.ElementsMatch(t, []uint{hostFailedInstall.ID, hostFailedUninstall.ID}, []uint{hosts[0].ID, hosts[1].ID})
// list hosts with software installed
expectStatus = fleet.SoftwareInstalled
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: &expectStatus,
TeamFilter: teamFilter,
})
require.NoError(t, err)
require.Len(t, hosts, 1, getHostNames(hosts))
assert.ElementsMatch(t, []uint{hostInstalled.ID}, []uint{hosts[0].ID})
// list hosts with pending software uninstall requests
expectStatus = fleet.SoftwareUninstallPending
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: &expectStatus,
TeamFilter: teamFilter,
})
require.NoError(t, err)
require.Len(t, hosts, 1, getHostNames(hosts))
assert.ElementsMatch(t, []uint{hostPendingUninstall.ID}, []uint{hosts[0].ID})
// list hosts with failed software uninstall requests
expectStatus = fleet.SoftwareUninstallFailed
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: &expectStatus,
TeamFilter: teamFilter,
})
require.NoError(t, err)
require.Len(t, hosts, 1, getHostNames(hosts))
assert.ElementsMatch(t, []uint{hostFailedUninstall.ID}, []uint{hosts[0].ID})
// list all hosts with the software title that shows up in host_software (after fleetd software query is run)
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
TeamFilter: teamFilter,
})
require.NoError(t, err)
assert.Empty(t, hosts)
if c.opts.SoftwareStatusFilter == nil && c.opts.SoftwareTitleIDFilter != nil {
// for list hosts by label, if no status is provided, the title ID filter is ignored/no-op,
// so all host IDs are returned
c.wantHostIDs = allHostIDs
}
hosts, err = ds.ListHostsInLabel(ctx, userTeamFilter, labelsByName[fleet.BuiltinLabelNameAllHosts], c.opts)
require.NoError(t, err)
require.Len(t, hosts, len(c.wantHostIDs), getHostNames(hosts))
require.ElementsMatch(t, c.wantHostIDs, pluckHostIDs(hosts))
})
}
summary, err := ds.GetSummaryHostSoftwareInstalls(ctx, installerMeta.InstallerID)
require.NoError(t, err)
@@ -573,6 +763,14 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
PendingUninstall: 1,
FailedUninstall: 1,
}, *summary)
vppSummary, err := ds.GetSummaryHostInHouseAppInstalls(ctx, teamID, inHouseID)
require.NoError(t, err)
require.Equal(t, fleet.VPPAppStatusSummary{
Installed: 1,
Pending: 1,
Failed: 1,
}, *vppSummary)
})
}
}
+388
View File
@@ -100,6 +100,7 @@ func TestSoftware(t *testing.T) {
{"PreInsertSoftwareInventory", testPreInsertSoftwareInventory},
{"ListHostSoftwareWithExtensionFor", testListHostSoftwareWithExtensionFor},
{"LongestCommonPrefix", testLongestCommonPrefix},
{"ListHostSoftwareInHouseApps", testListHostSoftwareInHouseApps},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -9649,3 +9650,390 @@ func findSoftware(sw []*fleet.HostSoftwareWithInstaller, name, extensionFor stri
}
return nil
}
func testListHostSoftwareInHouseApps(t *testing.T, ds *Datastore) {
ctx := context.Background()
t.Cleanup(func() { ds.testActivateSpecificNextActivities = nil })
// use time -1s to ensure host label-updated-at is before the labels creation timestamp
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now().Add(-1*time.Second), test.WithPlatform("ios"))
nanoEnroll(t, ds, host, false)
otherHost := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now(), test.WithPlatform("ubuntu"))
require.NotNil(t, otherHost)
opts := fleet.HostSoftwareTitleListOptions{
IsMDMEnrolled: true, // required for vpp/in-house apps, and the host is MDM-enrolled
ListOptions: fleet.ListOptions{PerPage: 11, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"},
}
// create a distinct team
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
user, err := ds.NewUser(ctx, &fleet.User{
Password: []byte("p4ssw0rd.123"),
Name: "user1",
Email: "user1@example.com",
GlobalRole: ptr.String(fleet.RoleAdmin),
})
require.NoError(t, err)
// create some in-house apps for no-team (this creates both iOS and iPadOS,
// but returns the iOS ids)
inHouseID1, inHouseTitleID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouse1",
Source: "ios_apps",
Filename: "inhouse1.ipa",
Extension: "ipa",
BundleIdentifier: "inhouse1",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, inHouseID1)
require.NotZero(t, inHouseTitleID1)
inHouseID2, inHouseTitleID2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouse2",
Source: "ios_apps",
Filename: "inhouse2.ipa",
Extension: "ipa",
BundleIdentifier: "inhouse2",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, inHouseID2)
require.NotZero(t, inHouseTitleID2)
inHouseID3, inHouseTitleID3, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouse3",
Source: "ios_apps",
Filename: "inhouse3.ipa",
Extension: "ipa",
BundleIdentifier: "inhouse3",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, inHouseID3)
require.NotZero(t, inHouseTitleID3)
// add an in-house app on the team, should not affect the host's results
inHouseIDTm, inHouseTitleIDTm, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouse-tm",
Source: "ios_apps",
Filename: "inhouse-tm.ipa",
Extension: "ipa",
BundleIdentifier: "inhouse-tm",
TeamID: &team.ID,
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
require.NotZero(t, inHouseIDTm)
require.NotZero(t, inHouseTitleIDTm)
// add software to the host
software := []fleet.Software{
{Name: "a", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "b", Version: "0.0.3", Source: "apps"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now()))
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
// make software "b" vulnerable
var swBID uint
if host.Software[0].Name == "b" {
swBID = host.Software[0].ID
} else {
swBID = host.Software[1].ID
}
cpes := []fleet.SoftwareCPE{{SoftwareID: swBID, CPE: "somecpe"}}
_, err = ds.UpsertSoftwareCPEs(ctx, cpes)
require.NoError(t, err)
require.NoError(t, ds.LoadHostSoftware(context.Background(), host, false))
vulns := []fleet.SoftwareVulnerability{
{SoftwareID: swBID, CVE: "CVE-2022-0001"},
}
for _, v := range vulns {
_, err = ds.InsertSoftwareVulnerability(ctx, v, fleet.NVDSource)
require.NoError(t, err)
}
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
pluckSoftwareNames := func(sw []*fleet.HostSoftwareWithInstaller) []string {
names := make([]string, 0, len(sw))
for _, s := range sw {
names = append(names, s.Name)
}
return names
}
// there should be 2 titles installed
sw, _, err := ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 2)
require.Equal(t, []string{"a", "b"}, pluckSoftwareNames(sw))
// 5 titles including the in-house apps available for install
opts.IncludeAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 5)
require.Equal(t, []string{"a", "b", "inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw))
// vulnerable only returns "b"
opts.IncludeAvailableForInstall = false
opts.VulnerableOnly = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 1)
require.Equal(t, []string{"b"}, pluckSoftwareNames(sw))
// only available for install returns the in-house apps
opts.VulnerableOnly = false
opts.OnlyAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 3)
require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw))
// make inhouse-1 pending install
inhouse1InstallCmd := createInHouseAppInstallRequest(t, ds, host.ID, inHouseID1, inHouseTitleID1, user)
ds.testActivateSpecificNextActivities = []string{inhouse1InstallCmd}
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "")
require.NoError(t, err)
// software inventory, no available for install, does not include the pending
// as it's not installed yet
opts.OnlyAvailableForInstall = false
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 2)
require.Equal(t, []string{"a", "b"}, pluckSoftwareNames(sw))
// TODO(mna): thinking of leaving this on here for a bit as I've seen it fail
// with some flakiness before but couldn't repro locally nor on CI. Error was
// in createInHouseAppInstallResultVerified, the nano command for the result
// was not found.
ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
fmt.Println(">>> command uuid: ", inhouse1InstallCmd)
DumpTable(t, tx, "hosts", "id", "uuid", "platform", "hostname", "team_id")
DumpTable(t, tx, "nano_devices")
DumpTable(t, tx, "nano_commands")
DumpTable(t, tx, "nano_command_results")
return nil
})
// make inhouse-1 installed, inhouse-2 pending
createInHouseAppInstallResultVerified(t, ds, host, inhouse1InstallCmd, "Acknowledged")
inhouse2InstallCmd := createInHouseAppInstallRequest(t, ds, host.ID, inHouseID2, inHouseTitleID2, user)
ds.testActivateSpecificNextActivities = []string{inhouse2InstallCmd}
_, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), host.ID, "")
require.NoError(t, err)
// mark it as reported as installed on the host
software = []fleet.Software{
{Name: "a", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "b", Version: "0.0.3", Source: "apps"},
{Name: "inhouse1", Version: "0.0.3", Source: "ios_apps", ApplicationID: ptr.String("inhouse1")},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now()))
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
// software inventory, no available for install, includes the installed one
opts.OnlyAvailableForInstall = false
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 3)
require.Equal(t, []string{"a", "b", "inhouse1"}, pluckSoftwareNames(sw))
require.Equal(t, sw[2].Status, ptr.T(fleet.SoftwareInstalled))
require.NotNil(t, sw[2].SoftwarePackage)
require.Equal(t, sw[2].SoftwarePackage.Name, "inhouse1")
require.Equal(t, sw[2].SoftwarePackage.Platform, "ios")
require.Equal(t, sw[2].SoftwarePackage.SelfService, ptr.Bool(false))
require.NotNil(t, sw[2].SoftwarePackage.LastInstall)
require.Equal(t, sw[2].SoftwarePackage.LastInstall.CommandUUID, inhouse1InstallCmd)
// software with available for install, also includes the pending one
opts.IncludeAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 5)
require.Equal(t, []string{"a", "b", "inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw))
require.Equal(t, sw[2].Status, ptr.T(fleet.SoftwareInstalled))
require.Equal(t, sw[3].Status, ptr.T(fleet.SoftwareInstallPending))
require.NotNil(t, sw[3].SoftwarePackage)
require.Equal(t, sw[3].SoftwarePackage.Name, "inhouse2")
require.Equal(t, sw[3].SoftwarePackage.Platform, "ios")
require.Equal(t, sw[3].SoftwarePackage.SelfService, ptr.Bool(false))
require.NotNil(t, sw[3].SoftwarePackage.LastInstall)
require.Equal(t, sw[3].SoftwarePackage.LastInstall.CommandUUID, inhouse2InstallCmd)
require.Nil(t, sw[4].Status)
require.NotNil(t, sw[4].SoftwarePackage)
require.Equal(t, sw[4].SoftwarePackage.Name, "inhouse3")
require.Equal(t, sw[4].SoftwarePackage.Platform, "ios")
require.Equal(t, sw[4].SoftwarePackage.SelfService, ptr.Bool(false))
require.Nil(t, sw[4].SoftwarePackage.LastInstall)
// add inhouse3 as installed outside of Fleet
software = []fleet.Software{
{Name: "a", Version: "0.0.1", Source: "chrome_extensions"},
{Name: "b", Version: "0.0.3", Source: "apps"},
{Name: "inhouse1", Version: "0.0.3", Source: "ios_apps", ApplicationID: ptr.String("inhouse1")},
{Name: "inhouse3", Version: "0.0.4", Source: "ios_apps", ApplicationID: ptr.String("inhouse3")},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now()))
require.NoError(t, ds.LoadHostSoftware(ctx, host, false))
// software inventory includes it
opts.IncludeAvailableForInstall = false
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 4)
require.Equal(t, []string{"a", "b", "inhouse1", "inhouse3"}, pluckSoftwareNames(sw))
require.Nil(t, sw[3].Status)
// record a failed install for inhouse2
createInHouseAppInstallResultVerified(t, ds, host, inhouse2InstallCmd, "Error")
// software inventory still does not list it
opts.IncludeAvailableForInstall = false
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 4)
require.Equal(t, []string{"a", "b", "inhouse1", "inhouse3"}, pluckSoftwareNames(sw))
// software library shows it as failed
opts.OnlyAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 3)
require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw))
require.Equal(t, sw[1].Status, ptr.T(fleet.SoftwareInstallFailed))
require.NotNil(t, sw[1].SoftwarePackage)
require.Equal(t, sw[1].SoftwarePackage.Name, "inhouse2")
require.Equal(t, sw[1].SoftwarePackage.Platform, "ios")
require.Equal(t, sw[1].SoftwarePackage.SelfService, ptr.Bool(false))
require.NotNil(t, sw[1].SoftwarePackage.LastInstall)
require.Equal(t, sw[1].SoftwarePackage.LastInstall.CommandUUID, inhouse2InstallCmd)
// test with label conditions
lbl1, err := ds.NewLabel(ctx, &fleet.Label{Name: "label1", LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
lbl2, err := ds.NewLabel(ctx, &fleet.Label{Name: "label2", Query: "select 1", LabelMembershipType: fleet.LabelMembershipTypeDynamic})
require.NoError(t, err)
lbl3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3", LabelMembershipType: fleet.LabelMembershipTypeManual})
require.NoError(t, err)
// create an in-house app with include any labels
inHouseIDIncl, inHouseTitleIDIncl, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouseincl",
Source: "ios_apps",
Filename: "inhouseincl.ipa",
Extension: "ipa",
BundleIdentifier: "inhouseincl",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeIncludeAny,
ByName: map[string]fleet.LabelIdent{
lbl1.Name: {LabelID: lbl1.ID, LabelName: lbl1.Name},
lbl2.Name: {LabelID: lbl2.ID, LabelName: lbl2.Name},
},
},
})
require.NoError(t, err)
require.NotZero(t, inHouseIDIncl)
require.NotZero(t, inHouseTitleIDIncl)
// create an in-house app with exclude any labels
inHouseIDExcl, inHouseTitleIDExcl, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "inhouseexcl",
Source: "ios_apps",
Filename: "inhouseexcl.ipa",
Extension: "ipa",
BundleIdentifier: "inhouseexcl",
UserID: user.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{
LabelScope: fleet.LabelScopeExcludeAny,
ByName: map[string]fleet.LabelIdent{
lbl2.Name: {LabelID: lbl2.ID, LabelName: lbl2.Name},
lbl3.Name: {LabelID: lbl3.ID, LabelName: lbl3.Name},
},
},
})
require.NoError(t, err)
require.NotZero(t, inHouseIDExcl)
require.NotZero(t, inHouseTitleIDExcl)
// software inventory does not list those
opts.OnlyAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 3)
require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3"}, pluckSoftwareNames(sw))
// make host a member of lbl1
err = ds.AddLabelsToHost(ctx, host.ID, []uint{lbl1.ID})
require.NoError(t, err)
// software inventory now shows the include in-house app
opts.OnlyAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 4)
require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3", "inhouseincl"}, pluckSoftwareNames(sw))
// update the host's labels updated at timestamp so the exclude any condition kicks in
host.LabelUpdatedAt = time.Now()
host.PolicyUpdatedAt = time.Now()
err = ds.UpdateHost(ctx, host)
require.NoError(t, err)
// software inventory now shows the exclude in-house app
opts.OnlyAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 5)
require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3", "inhouseexcl", "inhouseincl"}, pluckSoftwareNames(sw))
// make host a member of lbl3
err = ds.AddLabelsToHost(ctx, host.ID, []uint{lbl3.ID})
require.NoError(t, err)
// exclude in-house app is now removed
opts.OnlyAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Len(t, sw, 4)
require.Equal(t, []string{"inhouse1", "inhouse2", "inhouse3", "inhouseincl"}, pluckSoftwareNames(sw))
// Useful for debugging:
// ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error {
// DumpTable(t, tx, "hosts", "id", "uuid", "platform", "hostname", "team_id")
// DumpTable(t, tx, "host_software")
// DumpTable(t, tx, "software", "id", "title_id")
// DumpTable(t, tx, "in_house_apps", "id", "title_id", "global_or_team_id", "name", "version", "platform")
// DumpTable(t, tx, "in_house_app_labels")
// DumpTable(t, tx, "software_titles", "id", "name", "source", "bundle_identifier", "additional_identifier", "application_id", "unique_identifier")
// return nil
// })
// the other host is unaffected, does not see inhouse-tm since it is not
// mdm-enrolled and wrong platform
opts.IsMDMEnrolled = false
opts.IncludeAvailableForInstall = true
sw, _, err = ds.ListHostSoftware(ctx, otherHost, opts)
require.NoError(t, err)
require.Len(t, sw, 0)
}
@@ -119,8 +119,9 @@ func (ds *Datastore) DeleteIconsAssociatedWithTitlesWithoutInstallers(ctx contex
SELECT title_id FROM vpp_apps va
JOIN vpp_apps_teams vat ON vat.adam_id = va.adam_id AND vat.platform = va.platform
WHERE global_or_team_id = ?
) AND software_title_id NOT IN (SELECT title_id FROM software_installers WHERE global_or_team_id = ?)`,
teamID, teamID, teamID)
) AND software_title_id NOT IN (SELECT title_id FROM software_installers WHERE global_or_team_id = ?)
AND software_title_id NOT IN (SELECT title_id FROM in_house_apps WHERE global_or_team_id = ?)`,
teamID, teamID, teamID, teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "cleaning up icons not associated with software installers")
}
+57 -8
View File
@@ -23,16 +23,19 @@ func (ds *Datastore) SoftwareTitleByID(ctx context.Context, id uint, teamID *uin
teamFilter string // used to filter software titles host counts by team
softwareInstallerGlobalOrTeamIDFilter string
vppAppsTeamsGlobalOrTeamIDFilter string
inHouseAppsTeamsGlobalOrTeamIDFilter string
)
if teamID != nil {
teamFilter = fmt.Sprintf("sthc.team_id = %d AND sthc.global_stats = 0", *teamID)
softwareInstallerGlobalOrTeamIDFilter = fmt.Sprintf("si.global_or_team_id = %d", *teamID)
vppAppsTeamsGlobalOrTeamIDFilter = fmt.Sprintf("vat.global_or_team_id = %d", *teamID)
inHouseAppsTeamsGlobalOrTeamIDFilter = fmt.Sprintf("iha.global_or_team_id = %d", *teamID)
} else {
teamFilter = ds.whereFilterGlobalOrTeamIDByTeams(tmFilter, "sthc")
softwareInstallerGlobalOrTeamIDFilter = "TRUE"
vppAppsTeamsGlobalOrTeamIDFilter = "TRUE"
inHouseAppsTeamsGlobalOrTeamIDFilter = "TRUE"
}
// Select software title but filter out if the software has zero host counts
@@ -49,14 +52,16 @@ SELECT
MAX(sthc.updated_at) AS counts_updated_at,
COUNT(si.id) as software_installers_count,
COUNT(vat.adam_id) AS vpp_apps_count,
COUNT(iha.id) AS in_house_apps_count,
vap.icon_url AS icon_url
FROM software_titles st
LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND sthc.hosts_count > 0 AND (%s)
LEFT JOIN software_installers si ON si.title_id = st.id AND %s
LEFT JOIN vpp_apps vap ON vap.title_id = st.id
LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND %s
LEFT JOIN in_house_apps iha ON iha.title_id = st.id AND %s
WHERE st.id = ? AND
(sthc.hosts_count > 0 OR vat.adam_id IS NOT NULL OR si.id IS NOT NULL)
(sthc.hosts_count > 0 OR vat.adam_id IS NOT NULL OR si.id IS NOT NULL OR iha.title_id IS NOT NULL)
GROUP BY
st.id,
st.name,
@@ -65,7 +70,7 @@ GROUP BY
st.bundle_identifier,
hosts_count,
vap.icon_url
`, teamFilter, softwareInstallerGlobalOrTeamIDFilter, vppAppsTeamsGlobalOrTeamIDFilter,
`, teamFilter, softwareInstallerGlobalOrTeamIDFilter, vppAppsTeamsGlobalOrTeamIDFilter, inHouseAppsTeamsGlobalOrTeamIDFilter,
)
var title fleet.SoftwareTitle
if err := sqlx.GetContext(ctx, ds.reader(ctx), &title, selectSoftwareTitleStmt, id); err != nil {
@@ -137,6 +142,7 @@ func (ds *Datastore) ListSoftwareTitles(
if err != nil {
return nil, 0, nil, ctxerr.Wrap(ctx, err, "building software titles select statement")
}
// build the count statement before adding the pagination constraints to `getTitlesStmt`
getTitlesCountStmt := fmt.Sprintf(`SELECT COUNT(DISTINCT s.id) FROM (%s) AS s`, getTitlesStmt)
@@ -156,6 +162,10 @@ func (ds *Datastore) ListSoftwareTitles(
VPPAppIconURL *string `db:"vpp_app_icon_url"`
VPPInstallDuringSetup *bool `db:"vpp_install_during_setup"`
FleetMaintainedAppID *uint `db:"fleet_maintained_app_id"`
InHouseAppName *string `db:"in_house_app_name"`
InHouseAppVersion *string `db:"in_house_app_version"`
InHouseAppPlatform *string `db:"in_house_app_platform"`
InHouseAppStorageID *string `db:"in_house_app_storage_id"`
}
var softwareList []*softwareTitle
getTitlesStmt, args = appendListOptionsWithCursorToSQL(getTitlesStmt, args, &opt.ListOptions)
@@ -205,6 +215,31 @@ func (ds *Datastore) ListSoftwareTitles(
}
}
// promote in-house app properties to their proper destination fields
if title.InHouseAppName != nil {
var version string
if title.InHouseAppVersion != nil {
version = *title.InHouseAppVersion
}
var platform string
if title.InHouseAppPlatform != nil {
platform = *title.InHouseAppPlatform
}
// as per the spec, in-house apps are returned as software packages
// https://github.com/fleetdm/fleet/pull/33950/files
title.SoftwarePackage = &fleet.SoftwarePackageOrApp{
Name: *title.InHouseAppName,
Version: version,
Platform: platform,
SelfService: ptr.Bool(false),
}
// this is set directly for software packages, but if this is an in-house
// app we need to set it here
title.HashSHA256 = title.InHouseAppStorageID
}
// promote the VPP app id and version to the proper destination fields
if title.VPPAppAdamID != nil {
var version string
@@ -318,7 +353,6 @@ func (ds *Datastore) ListSoftwareTitles(
titles := make([]fleet.SoftwareTitleListResult, 0, len(softwareList))
for _, st := range softwareList {
st := st
titles = append(titles, st.SoftwareTitleListResult)
}
@@ -383,11 +417,15 @@ SELECT
,vap.latest_version as vpp_app_version
,vap.platform as vpp_app_platform
,vap.icon_url as vpp_app_icon_url
,iha.name as in_house_app_name
,iha.version as in_house_app_version
,iha.platform as in_house_app_platform
,iha.storage_id as in_house_app_storage_id
{{end}}
FROM software_titles st
{{if hasTeamID .}}
{{$installerJoin := printf "%s JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = %d" (yesNo .PackagesOnly "INNER" "LEFT") (teamID .)}}
{{$installerJoin}}
LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = {{teamID .}}
LEFT JOIN in_house_apps iha ON iha.title_id = st.id AND iha.global_or_team_id = {{teamID .}}
LEFT JOIN vpp_apps vap ON vap.title_id = st.id AND {{yesNo .PackagesOnly "FALSE" "TRUE"}}
LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND
{{if .PackagesOnly}} FALSE {{else}} vat.global_or_team_id = {{teamID .}}{{end}}
@@ -419,17 +457,20 @@ FROM software_titles st
{{end}}
WHERE
{{with $additionalWhere := "TRUE"}}
{{if and (hasTeamID $) $.PackagesOnly}}
{{$additionalWhere = "(si.id IS NOT NULL OR iha.id IS NOT NULL)"}}
{{end}}
{{if $.ListOptions.MatchQuery}}
{{$additionalWhere = "(st.name LIKE ? OR scve.cve LIKE ?)"}}
{{end}}
{{if and (hasTeamID $) $.Platform}}
{{$postfix := printf " AND (si.platform IN (%s) OR vap.platform IN (%[1]s))" (placeholders $.Platform)}}
{{$postfix := printf " AND (si.platform IN (%s) OR vap.platform IN (%[1]s) OR iha.platform IN (%[1]s))" (placeholders $.Platform)}}
{{$additionalWhere = printf "%s %s" $additionalWhere $postfix}}
{{end}}
{{$additionalWhere}}
{{end}}
-- If teamID is set, defaults to "a software installer or VPP app exists", and see next condition.
{{with $defFilter := yesNo (hasTeamID .) "(si.id IS NOT NULL OR vat.adam_id IS NOT NULL)" "FALSE"}}
-- If teamID is set, defaults to "a software installer, in-house app or VPP app exists", and see next condition.
{{with $defFilter := yesNo (hasTeamID .) "(si.id IS NOT NULL OR vat.adam_id IS NOT NULL OR iha.id IS NOT NULL)" "FALSE"}}
-- add software installed for hosts if we're not filtering for "available for install" only
{{if not $.AvailableForInstall}}
{{$defFilter = $defFilter | printf " ( %s OR sthc.hosts_count > 0 ) "}}
@@ -456,6 +497,10 @@ GROUP BY
,vpp_app_platform
,vpp_app_icon_url
,vpp_install_during_setup
,in_house_app_name
,in_house_app_version
,in_house_app_platform
,in_house_app_storage_id
{{end}}
`
var args []any
@@ -486,6 +531,10 @@ GROUP BY
for _, platform := range platforms {
args = append(args, platform)
}
// for in-house apps; could micro-optimize later by dropping non-Apple platforms
for _, platform := range platforms {
args = append(args, platform)
}
}
t, err := template.New("stm").Funcs(map[string]any{
@@ -44,6 +44,7 @@ func TestSoftwareTitles(t *testing.T) {
{"ListSoftwareTitlesAllTeamsWithAutomaticInstallersInNoTeam", testListSoftwareTitlesAllTeamsWithAutomaticInstallersInNoTeam},
{"ListSoftwareTitlesPackagesOnly", testSoftwareTitlesPackagesOnly},
{"SoftwareTitleByIDHostCount", testSoftwareTitleHostCount},
{"ListSoftwareTitlesInHouseApps", testListSoftwareTitlesInHouseApps},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -2254,3 +2255,253 @@ func testSoftwareTitleHostCount(t *testing.T, ds *Datastore) {
require.Equal(t, uint(1), title.VersionsCount)
require.Equal(t, ptr.Uint(1), title.Versions[0].HostsCount)
}
func testListSoftwareTitlesInHouseApps(t *testing.T, ds *Datastore) {
ctx := t.Context()
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
require.NoError(t, ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team1.ID, []uint{host.ID})))
user := test.NewUser(t, ds, "Alice", "alice@example.com", true)
test.CreateInsertGlobalVPPToken(t, ds)
software := []fleet.Software{
{Name: "foo", Version: "1.0.0", Source: "deb_packages"},
{Name: "bar", Version: "2.0.0", Source: "apps"},
{Name: "baz", Version: "3.0.0", Source: "rpm_packages"},
}
_, err = ds.UpdateHostSoftware(ctx, host.ID, software)
require.NoError(t, err)
// create a software package that matches foo
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "foo",
Source: "deb_packages",
InstallScript: "echo foo",
Filename: "foo.pkg",
UserID: user.ID,
TeamID: &team1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
Platform: string(fleet.MacOSPlatform),
})
require.NoError(t, err)
// create a VPP app
_, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{
Name: "vpp1", BundleIdentifier: "com.app.vpp1",
VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_app_1", Platform: fleet.IPadOSPlatform}},
}, &team1.ID)
require.NoError(t, err)
// create a couple in-house apps (they always create both ios and ipados entries)
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "in-house1",
Filename: "in-house1.ipa",
BundleIdentifier: "in-house1",
Extension: "ipa",
UserID: user.ID,
TeamID: &team1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "in-house2",
Filename: "in-house2.ipa",
BundleIdentifier: "in-house2",
Extension: "ipa",
UserID: user.ID,
TeamID: &team1.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// Sync and reconcile
require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now()))
require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now()))
pluckNames := func(titles []fleet.SoftwareTitleListResult) []string {
var out []string
for _, t := range titles {
out = append(out, t.Name)
}
return out
}
assertInstallers := func(t *testing.T, got []fleet.SoftwareTitleListResult, want []*fleet.SoftwarePackageOrApp) {
require.Len(t, got, len(want))
for i, sw := range got {
switch {
case want[i] == nil:
require.Nil(t, sw.SoftwarePackage)
require.Nil(t, sw.AppStoreApp)
case want[i].AppStoreID != "":
require.Nil(t, sw.SoftwarePackage)
require.NotNil(t, sw.AppStoreApp)
require.Equal(t, want[i], sw.AppStoreApp)
default:
require.Nil(t, sw.AppStoreApp)
require.NotNil(t, sw.SoftwarePackage)
require.Equal(t, want[i], sw.SoftwarePackage)
}
}
}
adminFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}
cases := []struct {
desc string
opts fleet.SoftwareTitleListOptions
wantCount int
wantNames []string
wantInstallers []*fleet.SoftwarePackageOrApp
}{
{
desc: "all",
opts: fleet.SoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
OrderDirection: fleet.OrderAscending,
TestSecondaryOrderKey: "in_house_app_platform",
},
TeamID: &team1.ID,
},
wantCount: 8,
wantNames: []string{"bar", "baz", "foo", "in-house1", "in-house1", "in-house2", "in-house2", "vpp1"},
wantInstallers: []*fleet.SoftwarePackageOrApp{
nil,
nil,
{Name: "foo.pkg", SelfService: ptr.Bool(false), PackageURL: ptr.String(""), InstallDuringSetup: ptr.Bool(false), Platform: string(fleet.MacOSPlatform)},
{Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)},
{Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)},
{Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)},
{Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)},
{AppStoreID: "adam_vpp_app_1", Platform: string(fleet.IPadOSPlatform), SelfService: ptr.Bool(false), InstallDuringSetup: ptr.Bool(false)},
},
},
{
desc: "packages only",
opts: fleet.SoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
OrderDirection: fleet.OrderAscending,
TestSecondaryOrderKey: "in_house_app_platform",
},
TeamID: &team1.ID,
PackagesOnly: true, // should include in-house, not VPP
},
wantCount: 5,
wantNames: []string{"foo", "in-house1", "in-house1", "in-house2", "in-house2"},
wantInstallers: []*fleet.SoftwarePackageOrApp{
{Name: "foo.pkg", SelfService: ptr.Bool(false), PackageURL: ptr.String(""), InstallDuringSetup: ptr.Bool(false), Platform: string(fleet.MacOSPlatform)},
{Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)},
{Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)},
{Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)},
{Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)},
},
},
{
desc: "available for install",
opts: fleet.SoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
OrderDirection: fleet.OrderAscending,
TestSecondaryOrderKey: "in_house_app_platform",
},
TeamID: &team1.ID,
AvailableForInstall: true,
},
wantCount: 6,
wantNames: []string{"foo", "in-house1", "in-house1", "in-house2", "in-house2", "vpp1"},
wantInstallers: []*fleet.SoftwarePackageOrApp{
{Name: "foo.pkg", SelfService: ptr.Bool(false), PackageURL: ptr.String(""), InstallDuringSetup: ptr.Bool(false), Platform: string(fleet.MacOSPlatform)},
{Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)},
{Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)},
{Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)},
{Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)},
{AppStoreID: "adam_vpp_app_1", Platform: string(fleet.IPadOSPlatform), SelfService: ptr.Bool(false), InstallDuringSetup: ptr.Bool(false)},
},
},
{
desc: "self-service only",
opts: fleet.SoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
OrderDirection: fleet.OrderAscending,
TestSecondaryOrderKey: "in_house_app_platform",
},
TeamID: &team1.ID,
SelfServiceOnly: true,
},
wantCount: 0,
},
{
desc: "macos only",
opts: fleet.SoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
OrderDirection: fleet.OrderAscending,
TestSecondaryOrderKey: "in_house_app_platform",
},
TeamID: &team1.ID,
Platform: "macos",
},
wantCount: 1,
wantNames: []string{"foo"},
wantInstallers: []*fleet.SoftwarePackageOrApp{
{Name: "foo.pkg", SelfService: ptr.Bool(false), PackageURL: ptr.String(""), InstallDuringSetup: ptr.Bool(false), Platform: string(fleet.MacOSPlatform)},
},
},
{
desc: "iOS only",
opts: fleet.SoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
OrderDirection: fleet.OrderAscending,
TestSecondaryOrderKey: "in_house_app_platform",
},
TeamID: &team1.ID,
Platform: "ios",
},
wantCount: 2,
wantNames: []string{"in-house1", "in-house2"},
wantInstallers: []*fleet.SoftwarePackageOrApp{
{Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)},
{Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)},
},
},
{
desc: "iOS and IPadOS",
opts: fleet.SoftwareTitleListOptions{
ListOptions: fleet.ListOptions{
OrderKey: "name",
OrderDirection: fleet.OrderAscending,
TestSecondaryOrderKey: "in_house_app_platform",
},
TeamID: &team1.ID,
Platform: "ios,ipados",
},
wantCount: 5,
wantNames: []string{"in-house1", "in-house1", "in-house2", "in-house2", "vpp1"},
wantInstallers: []*fleet.SoftwarePackageOrApp{
{Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)},
{Name: "in-house1", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)},
{Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IOSPlatform)},
{Name: "in-house2", SelfService: ptr.Bool(false), Platform: string(fleet.IPadOSPlatform)},
{AppStoreID: "adam_vpp_app_1", Platform: string(fleet.IPadOSPlatform), SelfService: ptr.Bool(false), InstallDuringSetup: ptr.Bool(false)},
},
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
titles, counts, _, err := ds.ListSoftwareTitles(ctx, c.opts, adminFilter)
require.NoError(t, err)
require.Equal(t, c.wantCount, counts)
require.Equal(t, c.wantNames, pluckNames(titles))
assertInstallers(t, titles, c.wantInstallers)
})
}
}
+4 -4
View File
@@ -205,15 +205,15 @@ func setupDummyReplica(t testing.TB, testName string, ds *Datastore, opts *testi
// Build query to avoid inserting into GENERATED columns
var columns string
columnsStmt := fmt.Sprintf(`SELECT
GROUP_CONCAT(column_name ORDER BY ordinal_position)
FROM information_schema.columns
columnsStmt := fmt.Sprintf(`SELECT
GROUP_CONCAT(column_name ORDER BY ordinal_position)
FROM information_schema.columns
WHERE table_schema = '%s' AND table_name = '%s'
AND NOT (EXTRA LIKE '%%GENERATED%%' AND EXTRA NOT LIKE '%%DEFAULT_GENERATED%%');`, replicaDB, tbl)
err = replica.GetContext(ctx, &columns, columnsStmt)
require.NoError(t, err)
stmt = fmt.Sprintf(`INSERT INTO %s.%s (%s)
stmt = fmt.Sprintf(`INSERT INTO %s.%s (%s)
SELECT %s
FROM %s.%s;`, replicaDB, tbl, columns, columns, testName, tbl)
t.Log(stmt)
+65 -19
View File
@@ -148,10 +148,6 @@ func (ds *Datastore) GetSummaryHostVPPAppInstalls(ctx context.Context, teamID *u
) {
var dest fleet.VPPAppStatusSummary
// TODO(sarah): do we need to handle host_deleted_at similar to GetSummaryHostSoftwareInstalls?
// Currently there is no host_deleted_at in host_vpp_software_installs, so
// not handling it as part of the unified queue work.
stmt := `
WITH
@@ -1120,8 +1116,7 @@ WHERE
switch commandResults.Status {
case fleet.MDMAppleStatusAcknowledged:
status = string(fleet.SoftwareInstalled)
case fleet.MDMAppleStatusCommandFormatError:
case fleet.MDMAppleStatusError:
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
@@ -1820,9 +1815,20 @@ AND hvsi.verification_failed_at IS NULL
return result, nil
}
func (ds *Datastore) AssociateVPPInstallToVerificationUUID(ctx context.Context, installUUID, verifyCommandUUID string) error {
func (s softwareType) getInstallMappingTableName() string {
tableNames := map[softwareType]string{
softwareTypeInHouseApp: "host_in_house_software_installs",
softwareTypeVPP: "host_vpp_software_installs",
}
return tableNames[s]
}
func (ds *Datastore) AssociateMDMInstallToVerificationUUID(ctx context.Context, installUUID, verifyCommandUUID, hostUUID string) error {
stmt := `
UPDATE host_vpp_software_installs
UPDATE %s
SET verification_command_uuid = ?
WHERE command_uuid = ?
`
@@ -1830,15 +1836,33 @@ WHERE command_uuid = ?
hostCmdStmt := `
INSERT INTO host_mdm_commands
(host_id, command_type)
VALUES ((SELECT host_id FROM host_vpp_software_installs WHERE command_uuid = ?), ?)
VALUES ((SELECT id FROM hosts WHERE uuid = ?), ?)
`
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
if _, err := tx.ExecContext(ctx, stmt, verifyCommandUUID, installUUID); err != nil {
var rowsAffected int64
r, err := tx.ExecContext(ctx, fmt.Sprintf(stmt, softwareTypeVPP.getInstallMappingTableName()), verifyCommandUUID, installUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "update vpp install verification command")
}
if _, err := tx.ExecContext(ctx, hostCmdStmt, installUUID, fleet.VerifySoftwareInstallVPPPrefix); err != nil {
count, _ := r.RowsAffected()
rowsAffected += count
r, err = tx.ExecContext(ctx, fmt.Sprintf(stmt, softwareTypeInHouseApp.getInstallMappingTableName()), verifyCommandUUID, installUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "update in-house app install verification command")
}
count, _ = r.RowsAffected()
rowsAffected += count
if rowsAffected == 0 {
// There's a bug somewhere
return ctxerr.WrapWithData(ctx, err, "no MDM install attempts found with given uuid", map[string]any{"install_command_uuid": installUUID, "verify_command_uuid": verifyCommandUUID, "host_uuid": hostUUID})
}
if _, err := tx.ExecContext(ctx, hostCmdStmt, hostUUID, fleet.VerifySoftwareInstallVPPPrefix); err != nil {
return ctxerr.Wrap(ctx, err, "insert verify host mdm command")
}
@@ -1902,36 +1926,58 @@ WHERE command_uuid = ?
})
}
func (ds *Datastore) MarkAllPendingVPPInstallsAsFailed(ctx context.Context, jobName string) error {
clearUpcomingActivitiesStmt := `
func (ds *Datastore) MarkAllPendingVPPAndInHouseInstallsAsFailed(ctx context.Context, jobName string) error {
clearVPPUpcomingActivitiesStmt := `
DELETE ua FROM
upcoming_activities ua
JOIN
host_vpp_software_installs hvsi ON hvsi.command_uuid = ua.execution_id
WHERE ua.activity_type = ? AND hvsi.verification_failed_at IS NULL AND hvsi.verification_at IS NULL
`
`
installFailStmt := `
clearInHouseUpcomingActivitiesStmt := `
DELETE ua FROM
upcoming_activities ua
JOIN
host_in_house_software_installs hihs ON hihs.command_uuid = ua.execution_id
WHERE ua.activity_type = ? AND hihs.verification_failed_at IS NULL AND hihs.verification_at IS NULL
`
installVPPFailStmt := `
UPDATE host_vpp_software_installs
SET verification_failed_at = CURRENT_TIMESTAMP(6)
WHERE verification_failed_at IS NULL AND verification_at IS NULL
`
`
installInHouseFailStmt := `
UPDATE host_in_house_software_installs
SET verification_failed_at = CURRENT_TIMESTAMP(6)
WHERE verification_failed_at IS NULL AND verification_at IS NULL
`
deletePendingJobsStmt := `
DELETE FROM jobs
WHERE name = ?
AND state = ?
`
`
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
if _, err := tx.ExecContext(ctx, clearUpcomingActivitiesStmt, "vpp_app_install"); err != nil {
if _, err := tx.ExecContext(ctx, clearVPPUpcomingActivitiesStmt, "vpp_app_install"); err != nil {
return ctxerr.Wrap(ctx, err, "clear vpp install upcoming activities")
}
if _, err := tx.ExecContext(ctx, installFailStmt); err != nil {
if _, err := tx.ExecContext(ctx, installVPPFailStmt); err != nil {
return ctxerr.Wrap(ctx, err, "set all vpp install as failed")
}
if _, err := tx.ExecContext(ctx, clearInHouseUpcomingActivitiesStmt, "in_house_app_install"); err != nil {
return ctxerr.Wrap(ctx, err, "clear in-house install upcoming activities")
}
if _, err := tx.ExecContext(ctx, installInHouseFailStmt); err != nil {
return ctxerr.Wrap(ctx, err, "set all in-house install as failed")
}
if _, err := tx.ExecContext(ctx, deletePendingJobsStmt, jobName, fleet.JobStateQueued); err != nil {
return ctxerr.Wrap(ctx, err, "delete pending jobs")
}