Initial support for in-house apps on iOS/iPadOS (#34802)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ?)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
¶meterizedStmt{
|
||||
Statement: selectStmt,
|
||||
Args: selectArgs,
|
||||
},
|
||||
¶meterizedStmt{
|
||||
Statement: insertStmt,
|
||||
Args: insertArgs,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return titleID, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) insertInHouseAppDB(ctx context.Context, tx sqlx.ExtContext, payload *fleet.InHouseAppPayload, args []any) (uint, error) {
|
||||
stmt := `
|
||||
INSERT INTO in_house_apps (
|
||||
team_id,
|
||||
global_or_team_id,
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
+101
@@ -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
|
||||
}
|
||||
+46
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user