Files
Juan Fernandez fbccb8cc59 Emit created/deleted activities for setup experience scripts
Setup experience script add/replace/delete now record activities (API
and GitOps), skipping no-op re-submissions.
2026-07-23 06:41:51 -04:00

2986 lines
116 KiB
Go

package mysql
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/pkg/scripts"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
"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/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActivity(t *testing.T) {
ds := CreateMySQLDS(t)
cases := []struct {
name string
fn func(t *testing.T, ds *Datastore)
}{
{"UsernameChange", testActivityUsernameChange},
{"ListHostUpcomingActivities", testListHostUpcomingActivities},
{"CleanupExpiredLiveQueries", testCleanupExpiredLiveQueries},
{"CleanupExpiredLiveQueriesBatch", testCleanupExpiredLiveQueriesBatch},
{"ActivateNextActivity", testActivateNextActivity},
{"ActivateItselfOnEmptyQueue", testActivateItselfOnEmptyQueue},
{"CancelNonActivatedUpcomingActivity", testCancelNonActivatedUpcomingActivity},
{"CancelActivatedUpcomingActivity", testCancelActivatedUpcomingActivity},
{"BatchCancelAllHostUpcomingActivities", testBatchCancelAllHostUpcomingActivities},
{"SetResultAfterCancelUpcomingActivity", testSetResultAfterCancelUpcomingActivity},
{"GetHostUpcomingActivityMeta", testGetHostUpcomingActivityMeta},
{"UnblockHostsUpcomingActivityQueue", testUnblockHostsUpcomingActivityQueue},
{"ActivateScriptPackageInstallWithCorruptPayload", testActivateScriptPackageInstallWithCorruptPayload},
{"ActivateRegularPackageInstall", testActivateRegularPackageInstall},
{"ActivateDeletedInstallerShowsPlaceholder", testActivateDeletedInstallerShowsPlaceholder},
{"ActivateScriptPackageUninstallWithCorruptPayload", testActivateScriptPackageUninstallWithCorruptPayload},
{"ListPolicyAutomationActivities", testListPolicyAutomationActivities},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer TruncateTables(t, ds)
c.fn(t, ds)
})
}
}
type dummyActivity struct {
name string `json:"-"`
details map[string]interface{}
hostIDs []uint
}
func (d dummyActivity) MarshalJSON() ([]byte, error) {
b, err := json.Marshal(d.details)
if err != nil {
return nil, err
}
return b, nil
}
func (d dummyActivity) ActivityName() string {
return d.name
}
func (d dummyActivity) Documentation() (activity string, details string, detailsExample string) {
return "", "", ""
}
func (d dummyActivity) HostIDs() []uint {
return d.hostIDs
}
func testActivityUsernameChange(t *testing.T, ds *Datastore) {
activitySvc := NewTestActivityService(t, ds)
u := &fleet.User{
Password: []byte("asd"),
Name: "fullname",
Email: "email@asd.com",
GravatarURL: "http://asd.com",
APIOnly: true,
GlobalRole: ptr.String(fleet.RoleObserver),
}
_, err := ds.NewUser(context.Background(), u)
require.NoError(t, err)
apiUser := &activity_api.User{ID: u.ID, Name: u.Name, Email: u.Email}
ctx := context.Background()
require.NoError(
t, activitySvc.NewActivity(
ctx, apiUser, dummyActivity{
name: "test1",
details: map[string]interface{}{"detail": 1, "sometext": "aaa"},
},
),
)
require.NoError(
t, activitySvc.NewActivity(
ctx, apiUser, dummyActivity{
name: "test2",
details: map[string]interface{}{"detail": 2},
},
),
)
activities := ListActivitiesAPI(t, context.Background(), activitySvc, activity_api.ListOptions{})
assert.Len(t, activities, 2)
assert.Equal(t, "fullname", *activities[0].ActorFullName)
u.Name = "newname"
err = ds.SaveUser(context.Background(), u)
require.NoError(t, err)
activities = ListActivitiesAPI(t, context.Background(), activitySvc, activity_api.ListOptions{})
assert.Len(t, activities, 2)
assert.Equal(t, "newname", *activities[0].ActorFullName)
assert.Equal(t, "http://asd.com", *activities[0].ActorGravatar)
assert.Equal(t, "email@asd.com", *activities[0].ActorEmail)
assert.Equal(t, true, *activities[0].ActorAPIOnly)
err = ds.DeleteUser(context.Background(), u.ID)
require.NoError(t, err)
activities = ListActivitiesAPI(t, context.Background(), activitySvc, activity_api.ListOptions{})
assert.Len(t, activities, 2)
assert.Equal(t, "fullname", *activities[0].ActorFullName)
assert.Nil(t, activities[0].ActorGravatar)
}
func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
noUserCtx := context.Background()
u := test.NewUser(t, ds, "user1", "user1@example.com", false)
u2 := test.NewUser(t, ds, "user2", "user2@example.com", false)
ctx := viewer.NewContext(noUserCtx, viewer.Viewer{User: u2})
test.CreateInsertGlobalVPPToken(t, ds)
// create four hosts
h1 := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now())
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)
h3 := test.NewHost(t, ds, "h3.local", "10.10.10.3", "3", "3", time.Now())
nanoEnrollAndSetHostMDMData(t, ds, h3, false)
h4 := test.NewHost(t, ds, "h4.local", "10.10.10.4", "4", "4", time.Now())
nanoEnrollAndSetHostMDMData(t, ds, h4, false)
// create a couple of named scripts
scr1, err := ds.NewScript(ctx, &fleet.Script{
Name: "A",
ScriptContents: "A",
})
require.NoError(t, err)
scr2, err := ds.NewScript(ctx, &fleet.Script{
Name: "B",
ScriptContents: "B",
})
require.NoError(t, err)
// create a couple of software installers
installer1, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
require.NoError(t, err)
sw1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install foo",
InstallerFile: installer1,
StorageID: uuid.NewString(),
Filename: "foo.pkg",
Title: "foo",
Source: "apps",
Version: "0.0.1",
UserID: u.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
installer2, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
require.NoError(t, err)
sw2, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install bar",
InstallerFile: installer2,
StorageID: uuid.NewString(),
Filename: "bar.pkg",
Title: "bar",
Source: "apps",
Version: "0.0.2",
UserID: u.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
installer3, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
require.NoError(t, err)
sw3, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install to delete",
InstallerFile: installer3,
StorageID: uuid.NewString(),
Filename: "todelete.pkg",
Title: "todelete",
Source: "apps",
Version: "0.0.3",
UserID: u.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
sw1Meta, err := ds.GetSoftwareInstallerMetadataByID(ctx, sw1)
require.NoError(t, err)
sw2Meta, err := ds.GetSoftwareInstallerMetadataByID(ctx, sw2)
require.NoError(t, err)
sw3Meta, err := ds.GetSoftwareInstallerMetadataByID(ctx, sw3)
require.NoError(t, err)
// insert a VPP app
vppCommand1, vppCommand2 := "vpp-command-1", "vpp-command-2"
vppApp := &fleet.VPPApp{
Name: "vpp_no_team_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "3", Platform: fleet.MacOSPlatform}},
BundleIdentifier: "b3",
}
_, err = ds.InsertVPPAppWithTeam(ctx, vppApp, nil)
require.NoError(t, err)
// install the VPP app on h1
err = ds.InsertHostVPPSoftwareInstall(ctx, h1.ID, vppApp.VPPAppID, vppCommand1, "event-id-1", fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// vppCommand1 is now active for h1
// install the VPP app on h2, self-service
err = ds.InsertHostVPPSoftwareInstall(noUserCtx, h2.ID, vppApp.VPPAppID, vppCommand2, "event-id-2", fleet.HostSoftwareInstallOptions{SelfService: true})
require.NoError(t, err)
// vppCommand2 is now active for h2
// create a sync script request for h1 that has been pending for >
// MaxWaitTime, will still show up (sync scripts go through the upcoming
// queue as any script)
hsr, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptContents: "sync", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
hSyncExpired := hsr.ExecutionID
t.Log("hSyncExpired", hSyncExpired)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "UPDATE upcoming_activities SET created_at = ? WHERE execution_id = ?", time.Now().Add(-(scripts.MaxServerWaitTime + time.Minute)), hSyncExpired)
return err
})
// create some script requests for h1
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptID: &scr1.ID, ScriptContents: scr1.ScriptContents, UserID: &u.ID})
require.NoError(t, err)
h1A := hsr.ExecutionID
t.Log("h1A", h1A)
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptID: &scr2.ID, ScriptContents: scr2.ScriptContents, UserID: &u.ID})
require.NoError(t, err)
h1B := hsr.ExecutionID
t.Log("h1B", h1B)
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptContents: "C", UserID: &u.ID})
require.NoError(t, err)
h1C := hsr.ExecutionID
t.Log("h1C", h1C)
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptContents: "D"})
require.NoError(t, err)
h1D := hsr.ExecutionID
t.Log("h1D", h1D)
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptContents: "E"})
require.NoError(t, err)
h1E := hsr.ExecutionID
t.Log("h1E", h1E)
// create some software installs requests for h1
h1Bar, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw2Meta.InstallerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
t.Log("h1Bar", h1Bar)
// No user for this one and not Self-service, means it was installed by Fleet
policy, err := ds.NewTeamPolicy(ctx, 0, &u.ID, fleet.PolicyPayload{
Name: "Test Policy",
Query: "SELECT 1",
})
require.NoError(t, err)
h1Fleet, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID, fleet.HostSoftwareInstallOptions{PolicyID: &policy.ID})
require.NoError(t, err)
t.Log("h1Fleet", h1Fleet)
// create a single pending request for h2
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h2.ID, ScriptID: &scr1.ID, ScriptContents: scr1.ScriptContents, UserID: &u.ID})
require.NoError(t, err)
h2A := hsr.ExecutionID
t.Log("h2A", h2A)
// add a pending software install request for h2
h2Bar, err := ds.InsertSoftwareInstallRequest(ctx, h2.ID, sw2Meta.InstallerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
t.Log("h2Bar", h2Bar)
// No user for this one and Self-service, means it was installed by the end user, so the user_id should be null/nil.
h2SelfService, err := ds.InsertSoftwareInstallRequest(noUserCtx, h2.ID, sw1Meta.InstallerID, fleet.HostSoftwareInstallOptions{SelfService: true})
require.NoError(t, err)
t.Log("h2SelfService", h2SelfService)
setupExpScript := &fleet.Script{Name: "setup_experience_script", ScriptContents: "setup_experience"}
_, err = ds.SetSetupExperienceScript(ctx, setupExpScript)
require.NoError(t, err)
ses, err := ds.GetSetupExperienceScript(ctx, h2.TeamID)
require.NoError(t, err)
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h2.ID, ScriptContents: "setup_experience", SetupExperienceScriptID: &ses.ID})
require.NoError(t, err)
h2SetupExp := hsr.ExecutionID
t.Log("h2SetupExp", h2SetupExp)
// create pending install and uninstall requests for h3 that will be deleted
_, err = ds.InsertSoftwareInstallRequest(ctx, h3.ID, sw3Meta.InstallerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
err = ds.InsertSoftwareUninstallRequest(ctx, "uninstallRun", h3.ID, sw3Meta.InstallerID, false)
require.NoError(t, err)
// delete installer (should clear pending requests)
err = ds.DeleteSoftwareInstaller(ctx, sw3Meta.InstallerID)
require.NoError(t, err)
// Setup host 4. We will create upcoming activities, then
// delete and "restore" the host, similar to what would happen
// if you delete an ABM DEP host.
_, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h4.ID, ScriptID: &scr1.ID, ScriptContents: scr1.ScriptContents, UserID: &u.ID})
require.NoError(t, err)
// h4A := hsr.ExecutionID
// h4Bar, err := ds.InsertSoftwareInstallRequest(ctx, h4.ID, sw2Meta.InstallerID, false, nil)
_, err = ds.InsertSoftwareInstallRequest(ctx, h4.ID, sw2Meta.InstallerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// Set LastEnrolledAt before deleting the host (simulating a DEP enrolled host)
h4.LastEnrolledAt = time.Now()
// Delete the host
err = ds.DeleteHost(ctx, h4.ID)
require.NoError(t, err)
// DEP "restore" the host
err = ds.RestoreMDMApplePendingDEPHost(ctx, h4)
require.NoError(t, err)
// force-set the order of the created_at timestamps
// even if vppCommand1 and 2 are later, since they are already activated
// (because they were enqueued first) they will show up first.
SetOrderedCreatedAtTimestamps(t, ds, time.Now(), "upcoming_activities", "execution_id",
h1A, h1B, h1Bar, h1C, h1D, h1E, h1Fleet, h2SelfService, h2Bar, h2A, vppCommand1, vppCommand2, h2SetupExp)
execIDsWithUser := map[string]bool{
hSyncExpired: true,
h1A: true,
h1B: true,
h1C: true,
h1D: false,
h1E: false,
h2A: true,
h1Fleet: false,
h2SelfService: false,
h1Bar: true,
h2Bar: true,
vppCommand1: true,
vppCommand2: false,
h2SetupExp: false,
}
execIDsScriptName := map[string]string{
h1A: scr1.Name,
h1B: scr2.Name,
h2A: scr1.Name,
h2SetupExp: setupExpScript.Name,
}
execIDsSoftwareTitle := map[string]string{
h1Fleet: "foo",
h1Bar: "bar",
h2Bar: "bar",
h2SelfService: "foo",
}
execIDsFromPolicyAutomation := map[string]struct{}{
h1Fleet: {},
}
cases := []struct {
opts fleet.ListOptions
hostID uint
wantExecs []string
wantMeta *fleet.PaginationMetadata
}{
{
opts: fleet.ListOptions{PerPage: 2},
hostID: h1.ID,
wantExecs: []string{vppCommand1, hSyncExpired},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 9},
},
{
opts: fleet.ListOptions{Page: 1, PerPage: 2},
hostID: h1.ID,
wantExecs: []string{h1A, h1B},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 9},
},
{
opts: fleet.ListOptions{Page: 2, PerPage: 2},
hostID: h1.ID,
wantExecs: []string{h1Bar, h1C},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 9},
},
{
opts: fleet.ListOptions{Page: 3, PerPage: 2},
hostID: h1.ID,
wantExecs: []string{h1D, h1E},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 9},
},
{
opts: fleet.ListOptions{Page: 4, PerPage: 2},
hostID: h1.ID,
wantExecs: []string{h1Fleet},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 9},
},
{
opts: fleet.ListOptions{PerPage: 4},
hostID: h1.ID,
wantExecs: []string{vppCommand1, hSyncExpired, h1A, h1B},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 9},
},
{
opts: fleet.ListOptions{Page: 1, PerPage: 4},
hostID: h1.ID,
wantExecs: []string{h1Bar, h1C, h1D, h1E},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 9},
},
{
opts: fleet.ListOptions{Page: 2, PerPage: 4},
hostID: h1.ID,
wantExecs: []string{h1Fleet},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 9},
},
{
opts: fleet.ListOptions{Page: 3, PerPage: 4},
hostID: h1.ID,
wantExecs: []string{},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 9},
},
{
opts: fleet.ListOptions{PerPage: 5},
hostID: h2.ID,
wantExecs: []string{vppCommand2, h2SetupExp, h2SelfService, h2Bar, h2A}, // setup experience is top-priority, but vppCommand2 was already activated
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 5},
},
{
opts: fleet.ListOptions{},
hostID: h3.ID,
wantExecs: []string{},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 0},
},
{
opts: fleet.ListOptions{},
hostID: h4.ID,
wantExecs: []string{},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 0},
},
}
for _, c := range cases {
t.Run(fmt.Sprintf("%v: %#v", c.hostID, c.opts), func(t *testing.T) {
// always include metadata
c.opts.IncludeMetadata = true
c.opts.OrderKey = ""
c.opts.OrderDirection = fleet.OrderAscending
acts, meta, err := ds.ListHostUpcomingActivities(ctx, c.hostID, c.opts)
require.NoError(t, err)
require.Equal(t, len(c.wantExecs), len(acts))
require.Equal(t, c.wantMeta, meta)
for i, a := range acts {
wantExec := c.wantExecs[i]
var details map[string]any
require.NotNil(t, a.Details, "result %d", i)
require.NoError(t, json.Unmarshal([]byte(*a.Details), &details), "result %d", i)
require.Equal(t, c.hostID, uint(details["host_id"].(float64)), "result %d", i)
var wantUser *fleet.User
switch a.Type {
case fleet.ActivityTypeRanScript{}.ActivityName():
require.Equal(t, wantExec, details["script_execution_id"], "result %d", i)
require.Equal(t, execIDsScriptName[wantExec], details["script_name"], "result %d", i)
wantUser = u
case fleet.ActivityTypeInstalledSoftware{}.ActivityName():
require.Equal(t, wantExec, details["install_uuid"], "result %d", i)
require.Equal(t, execIDsSoftwareTitle[wantExec], details["software_title"], "result %d", i)
wantUser = u2
case fleet.ActivityInstalledAppStoreApp{}.ActivityName():
require.Equal(t, wantExec, details["command_uuid"], "result %d", i)
require.Equal(t, "vpp_no_team_app_1", details["software_title"], "result %d", i)
require.Equal(t, !execIDsWithUser[wantExec], details["self_service"], "result %d", i)
wantUser = u2
default:
t.Fatalf("unknown activity type %s", a.Type)
}
if _, ok := execIDsFromPolicyAutomation[wantExec]; ok {
require.Nil(t, a.ActorID, "result %d", i)
require.NotNil(t, a.ActorFullName, "result %d", i)
require.Equal(t, "Fleet", *a.ActorFullName, "result %d", i)
require.Nil(t, a.ActorEmail, "result %d", i)
require.NotNil(t, details["policy_id"])
require.Equal(t, float64(policy.ID), details["policy_id"], "result %d", i)
require.NotNil(t, details["policy_name"])
require.Equal(t, policy.Name, details["policy_name"], "result %d", i)
} else if execIDsWithUser[wantExec] {
require.NotNil(t, a.ActorID, "result %d", i)
require.Equal(t, wantUser.ID, *a.ActorID, "result %d", i)
require.NotNil(t, a.ActorFullName, "result %d", i)
require.Equal(t, wantUser.Name, *a.ActorFullName, "result %d", i)
require.NotNil(t, a.ActorEmail, "result %d", i)
require.Equal(t, wantUser.Email, *a.ActorEmail, "result %d", i)
} else {
require.Nil(t, a.ActorID, "result %d", i)
if a.FleetInitiated {
require.NotNil(t, a.ActorFullName, "result %d", i)
require.Equal(t, "Fleet", *a.ActorFullName, "result %d", i)
} else {
require.Nil(t, a.ActorFullName, "result %d", i)
}
require.Nil(t, a.ActorEmail, "result %d", i)
}
}
})
}
t.Run("rejects_unknown_order_key", func(t *testing.T) {
_, _, err := ds.ListHostUpcomingActivities(ctx, h1.ID, fleet.ListOptions{OrderKey: "h.node_key"})
require.Error(t, err)
})
}
func testCleanupExpiredLiveQueries(t *testing.T, ds *Datastore) {
ctx := context.Background()
user1 := &fleet.User{
Password: []byte("p4ssw0rd.123"),
Name: "user1",
Email: "user1@example.com",
GlobalRole: ptr.String(fleet.RoleAdmin),
}
user1, err := ds.NewUser(ctx, user1)
require.NoError(t, err)
// Nothing to delete.
err = ds.CleanupExpiredLiveQueries(ctx, 1)
require.NoError(t, err)
nonSavedQuery, err := ds.NewQuery(ctx, &fleet.Query{
Name: "nonSavedQuery",
Saved: false,
Query: "SELECT 1;",
Logging: fleet.LoggingSnapshot,
})
require.NoError(t, err)
savedQuery, err := ds.NewQuery(ctx, &fleet.Query{
Name: "savedQuery",
Saved: true,
Query: "SELECT 2;",
Logging: fleet.LoggingSnapshot,
})
require.NoError(t, err)
campaign, err := ds.NewDistributedQueryCampaign(ctx, &fleet.DistributedQueryCampaign{
QueryID: nonSavedQuery.ID,
Status: fleet.QueryComplete,
UserID: user1.ID,
})
require.NoError(t, err)
_, err = ds.NewDistributedQueryCampaignTarget(ctx, &fleet.DistributedQueryCampaignTarget{
DistributedQueryCampaignID: campaign.ID,
TargetID: 1,
Type: fleet.TargetHost,
})
require.NoError(t, err)
// Nothing is deleted because the data is recent.
err = ds.CleanupExpiredLiveQueries(ctx, 1)
require.NoError(t, err)
_, err = ds.Query(ctx, nonSavedQuery.ID)
require.NoError(t, err)
_, err = ds.DistributedQueryCampaign(ctx, campaign.ID)
require.NoError(t, err)
targets, err := ds.DistributedQueryCampaignTargetIDs(ctx, campaign.ID)
require.NoError(t, err)
require.Len(t, targets.HostIDs, 1)
// Make the queries older.
_, err = ds.writer(context.Background()).Exec(`
UPDATE queries SET created_at = ? WHERE id = ? OR id = ?`,
time.Now().Add(-48*time.Hour), nonSavedQuery.ID, savedQuery.ID,
)
require.NoError(t, err)
// Expired unsaved query, its campaign, and campaign targets should be cleaned up.
err = ds.CleanupExpiredLiveQueries(ctx, 1)
require.NoError(t, err)
_, err = ds.Query(ctx, nonSavedQuery.ID)
require.ErrorIs(t, err, sql.ErrNoRows)
_, err = ds.DistributedQueryCampaign(ctx, campaign.ID)
require.ErrorIs(t, err, sql.ErrNoRows)
targets, err = ds.DistributedQueryCampaignTargetIDs(ctx, campaign.ID)
require.NoError(t, err)
require.Empty(t, targets.HostIDs)
require.Empty(t, targets.LabelIDs)
require.Empty(t, targets.TeamIDs)
// Saved query should not be cleaned up.
savedQuery, err = ds.Query(ctx, savedQuery.ID)
require.NoError(t, err)
require.NotNil(t, savedQuery)
}
func testCleanupExpiredLiveQueriesBatch(t *testing.T, ds *Datastore) {
ctx := context.Background()
// Create 1500 non-saved queries.
insertQueriesStmt := `
INSERT INTO queries
(name, description, query)
VALUES `
var insertQueriesArgs []any
for i := range 1500 {
insertQueriesArgs = append(insertQueriesArgs,
fmt.Sprintf("foobar%d", i), "foobar", "SELECT 1;",
)
}
insertQueriesStmt += strings.TrimSuffix(strings.Repeat("(?, ?, ?),", 1500), ",")
_, err := ds.writer(ctx).ExecContext(ctx, insertQueriesStmt, insertQueriesArgs...)
require.NoError(t, err)
// Nothing deleted; all recent.
err = ds.CleanupExpiredLiveQueries(ctx, 1)
require.NoError(t, err)
var queriesLen int
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &queriesLen, `SELECT COUNT(*) FROM queries WHERE NOT saved;`)
})
require.Equal(t, 1500, queriesLen)
// Make 1250 queries expired.
_, err = ds.writer(context.Background()).Exec(`
UPDATE queries SET created_at = ? WHERE id <= 1250`,
time.Now().Add(-48*time.Hour),
)
require.NoError(t, err)
// All 1250 expired queries should be cleaned up in one call (batched internally).
err = ds.CleanupExpiredLiveQueries(ctx, 1)
require.NoError(t, err)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &queriesLen, `SELECT COUNT(*) FROM queries WHERE NOT saved;`)
})
require.Equal(t, 250, queriesLen)
// Running again should be a no-op (remaining 250 are not expired).
err = ds.CleanupExpiredLiveQueries(ctx, 1)
require.NoError(t, err)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &queriesLen, `SELECT COUNT(*) FROM queries WHERE NOT saved;`)
})
require.Equal(t, 250, queriesLen)
}
func testActivateNextActivity(t *testing.T, ds *Datastore) {
activitySvc := NewTestActivityService(t, ds)
ctx := context.Background()
test.CreateInsertGlobalVPPToken(t, ds)
h1 := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now())
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)
nanoDB, err := nanomdm_mysql.New(nanomdm_mysql.WithDB(ds.primary.DB))
require.NoError(t, err)
nanoCtx := &mdm.Request{EnrollID: &mdm.EnrollID{ID: h1.UUID}, Context: ctx}
// create a couple VPP apps that can be installed later
vppApp1 := &fleet.VPPApp{
Name: "vpp_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "vpp1", Platform: fleet.MacOSPlatform}},
BundleIdentifier: "vpp1",
}
_, err = ds.InsertVPPAppWithTeam(ctx, vppApp1, nil)
require.NoError(t, err)
vppApp2 := &fleet.VPPApp{
Name: "vpp_2", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "vpp2", Platform: fleet.MacOSPlatform}},
BundleIdentifier: "vpp2",
}
_, 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)
require.NoError(t, err)
sw1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install foo",
InstallerFile: installer1,
StorageID: uuid.NewString(),
Filename: "foo.pkg",
Title: "foo",
Source: "apps",
Version: "0.0.1",
UserID: u.ID,
UninstallScript: "uninstall foo",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
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)
require.Empty(t, execIDs)
// activating when empty with an unknown completed exec id is fine
execIDs, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), h1.ID, uuid.NewString())
require.NoError(t, err)
require.Empty(t, execIDs)
// create a script execution request
hsr, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{
HostID: h1.ID,
ScriptContents: "echo 'a'",
})
require.NoError(t, err)
script1_1 := hsr.ExecutionID
// create a second script execution request that will not be activated yet
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{
HostID: h1.ID,
ScriptContents: "echo 'b'",
})
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{})
require.NoError(t, err)
vpp1_2 := uuid.NewString()
err = ds.InsertHostVPPSoftwareInstall(ctx, h1.ID, vppApp2.VPPAppID, vpp1_2, "event-id-2", fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// activating does nothing because the first script is still activated
execIDs, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), h1.ID, "")
require.NoError(t, err)
require.Empty(t, execIDs)
// pending activities are script1_1, script1_2, vpp1_1, vpp1_2
pendingActs, _, err := ds.ListHostUpcomingActivities(ctx, h1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 4)
require.Equal(t, script1_1, pendingActs[0].UUID)
require.Equal(t, script1_2, pendingActs[1].UUID)
require.Equal(t, vpp1_1, pendingActs[2].UUID)
require.Equal(t, vpp1_2, pendingActs[3].UUID)
// listing scripts ready to execute returns script1_1
pendingScripts, err := ds.ListReadyToExecuteScriptsForHost(ctx, h1.ID, false)
require.NoError(t, err)
require.Len(t, pendingScripts, 1)
require.Equal(t, script1_1, pendingScripts[0].ExecutionID)
// get host script result while there are no results yet returns the current status
scriptRes, err := ds.GetHostScriptExecutionResult(ctx, script1_1)
require.NoError(t, err)
require.Nil(t, scriptRes.ExitCode)
scriptRes, err = ds.GetHostScriptExecutionResult(ctx, script1_2)
require.NoError(t, err)
require.Nil(t, scriptRes.ExitCode)
// delete the script1_2 upcoming activity as if it was cancelled
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
DELETE FROM upcoming_activities
WHERE execution_id = ?`,
script1_2)
return err
})
// set a script result, will activate both VPP apps
_, _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: h1.ID, ExecutionID: script1_1, Output: "a", ExitCode: 0,
}, nil)
require.NoError(t, err)
// get host script result now returns the result
scriptRes, err = ds.GetHostScriptExecutionResult(ctx, script1_1)
require.NoError(t, err)
require.NotNil(t, scriptRes.ExitCode)
require.EqualValues(t, 0, *scriptRes.ExitCode)
// pending activities are vpp1_1, vpp1_2
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, h1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 2)
require.Equal(t, vpp1_1, pendingActs[0].UUID)
require.Equal(t, vpp1_2, pendingActs[1].UUID)
// nano commands have been inserted
cmd, err := nanoDB.RetrieveNextCommand(nanoCtx, false)
require.NoError(t, err)
require.Equal(t, vpp1_1, cmd.CommandUUID)
require.Equal(t, "InstallApplication", cmd.Command.Command.RequestType)
rawCmd := string(cmd.Raw)
require.Contains(t, rawCmd, ">"+vppApp1.VPPAppTeam.AdamID+"<")
require.Contains(t, rawCmd, ">"+vpp1_1+"<")
require.Contains(t, rawCmd, `<key>ManagementFlags</key>
<integer>0</integer>`, "MacOS VPP app install command should have ManagementFlags 0")
// insert a result for that command and create the past activity,
// which triggers the next activity to be activated (should be none
// in this scenario, as one is still active)
cmdRes := &mdm.CommandResults{
CommandUUID: vpp1_1,
Status: "Acknowledged",
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?>`),
}
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = activitySvc.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
HostID: h1.ID,
AppStoreID: vppApp1.VPPAppTeam.AdamID,
CommandUUID: vpp1_1,
})
require.NoError(t, err)
appleCmdRes, err := ds.GetMDMAppleCommandResults(ctx, vpp1_1, "")
require.NoError(t, err)
require.Len(t, appleCmdRes, 1)
require.Equal(t, "Acknowledged", appleCmdRes[0].Status)
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, h1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 1)
require.Equal(t, vpp1_2, pendingActs[0].UUID)
// vpp1_2 is now the next nano command
cmd, err = nanoDB.RetrieveNextCommand(nanoCtx, false)
require.NoError(t, err)
require.Equal(t, vpp1_2, cmd.CommandUUID)
require.Equal(t, "InstallApplication", cmd.Command.Command.RequestType)
rawCmd = string(cmd.Raw)
require.Contains(t, rawCmd, ">"+vppApp2.VPPAppTeam.AdamID+"<")
require.Contains(t, rawCmd, ">"+vpp1_2+"<")
// create a pending software install request
sw1_1, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// the software install request is not active yet, so with only active, returns nothing
pendingSw, err := ds.ListReadyToExecuteSoftwareInstalls(ctx, h1.ID)
require.NoError(t, err)
require.Len(t, pendingSw, 0)
// without only active, returns it
pendingSw, err = ds.ListPendingSoftwareInstalls(ctx, h1.ID)
require.NoError(t, err)
require.Len(t, pendingSw, 1)
require.Equal(t, sw1_1, pendingSw[0])
// activating does nothing because the VPP app 2 is still activated
execIDs, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), h1.ID, "")
require.NoError(t, err)
require.Empty(t, execIDs)
// trying to activate from a non-activated execution id (here, the software
// install sw1_1 one) does not delete that activity - it deletes only if it
// was activated
execIDs, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), h1.ID, sw1_1)
require.NoError(t, err)
require.Empty(t, execIDs)
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, h1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 2)
require.Equal(t, vpp1_2, pendingActs[0].UUID)
require.Equal(t, sw1_1, pendingActs[1].UUID)
// create a pending uninstall request
sw1_2 := uuid.NewString()
err = ds.InsertSoftwareUninstallRequest(ctx, sw1_2, h1.ID, sw1, false)
require.NoError(t, err)
// still hasn't changed the pending queue
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, h1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 3)
require.Equal(t, vpp1_2, pendingActs[0].UUID)
require.Equal(t, sw1_1, pendingActs[1].UUID)
require.Equal(t, sw1_2, pendingActs[2].UUID)
// insert a result for the vpp1_2 command
cmdRes = &mdm.CommandResults{
CommandUUID: vpp1_2,
Status: "Error",
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?>`),
}
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = activitySvc.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
HostID: h1.ID,
AppStoreID: vppApp2.VPPAppTeam.AdamID,
CommandUUID: vpp1_2,
})
require.NoError(t, err)
appleCmdRes, err = ds.GetMDMAppleCommandResults(ctx, vpp1_2, "")
require.NoError(t, err)
require.Len(t, appleCmdRes, 1)
require.Equal(t, "Error", appleCmdRes[0].Status)
// software install activity is now activated
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, h1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 2)
require.Equal(t, sw1_1, pendingActs[0].UUID)
require.Equal(t, sw1_2, pendingActs[1].UUID)
// set a result for the software install
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: h1.ID,
InstallUUID: sw1_1,
InstallScriptExitCode: ptr.Int(0),
}, nil)
require.NoError(t, err)
swRes, err := ds.GetSoftwareInstallResults(ctx, sw1_1)
require.NoError(t, err)
require.Equal(t, fleet.SoftwareInstalled, swRes.Status)
// activating does nothing because the sw1_2 was automatically activated
execIDs, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), h1.ID, sw1_1)
require.NoError(t, err)
require.Empty(t, execIDs)
pendingActs, _, err = ds.ListHostUpcomingActivities(ctx, h1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 1)
require.Equal(t, sw1_2, pendingActs[0].UUID)
// set a result for the software uninstall
_, _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: h1.ID,
ExecutionID: sw1_2,
ExitCode: 1,
}, nil)
require.NoError(t, err)
// because the install and uninstall are for the same software installer,
// only the latest attempt is shown in the summary and it is the uninstall.
swSummary, err := ds.GetSummaryHostSoftwareInstalls(ctx, sw1)
require.NoError(t, err)
require.Equal(t, fleet.SoftwareInstallerStatusSummary{
FailedUninstall: 1,
}, *swSummary)
// activating does nothing because the queue is now empty
execIDs, err = ds.activateNextUpcomingActivity(ctx, ds.writer(ctx), h1.ID, sw1_2)
require.NoError(t, err)
require.Empty(t, execIDs)
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)
// get next nano command for iOS host is the VPP app
nanoCtx = &mdm.Request{EnrollID: &mdm.EnrollID{ID: hIOS.UUID}, Context: ctx}
cmd, err = nanoDB.RetrieveNextCommand(nanoCtx, false)
require.NoError(t, err)
require.Equal(t, vpp1_1_ios, cmd.CommandUUID)
require.Equal(t, "InstallApplication", cmd.Command.Command.RequestType)
rawCmd = string(cmd.Raw)
require.Contains(t, rawCmd, ">"+vppApp1IOS.VPPAppTeam.AdamID+"<")
require.Contains(t, rawCmd, ">"+vpp1_1_ios+"<")
require.Contains(t, rawCmd, `<key>ManagementFlags</key>
<integer>1</integer>`)
// 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 = activitySvc.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
})
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)
cmd, err = nanoDB.RetrieveNextCommand(nanoCtx, false)
require.NoError(t, err)
require.Equal(t, ihaCmd, cmd.CommandUUID)
require.Equal(t, "InstallApplication", cmd.Command.Command.RequestType)
rawCmd = string(cmd.Raw)
require.Contains(t, rawCmd, ">"+ihaCmd+"<")
require.Contains(t, rawCmd, `<key>ManagementFlags</key>
<integer>1</integer>`)
// 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 = activitySvc.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{
HostID: hIOS.ID,
CommandUUID: ihaCmd,
Status: "Error", // using a failure because otherwise it requires verification to activate next
})
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 = activitySvc.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{
HostID: hIOS.ID,
CommandUUID: vpp1_1_ios,
Status: string(fleet.SoftwareInstalled),
})
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 = activitySvc.NewActivity(ctx, nil, &fleet.ActivityTypeInstalledSoftware{
HostID: hIOS.ID,
CommandUUID: ihaCmd,
Status: string(fleet.SoftwareInstalled),
})
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) {
activitySvc := NewTestActivityService(t, ds)
ctx := context.Background()
test.CreateInsertGlobalVPPToken(t, ds)
h1 := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now())
nanoEnrollAndSetHostMDMData(t, ds, h1, false)
u := test.NewUser(t, ds, "user1", "user1@example.com", false)
nanoDB, err := nanomdm_mysql.New(nanomdm_mysql.WithDB(ds.primary.DB))
require.NoError(t, err)
nanoCtx := &mdm.Request{EnrollID: &mdm.EnrollID{ID: h1.UUID}, Context: ctx}
vppApp1 := &fleet.VPPApp{
Name: "vpp_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "vpp1", Platform: fleet.MacOSPlatform}},
BundleIdentifier: "vpp1",
}
_, err = ds.InsertVPPAppWithTeam(ctx, vppApp1, nil)
require.NoError(t, err)
installer1, err := fleet.NewTempFileReader(strings.NewReader("echo"), t.TempDir)
require.NoError(t, err)
sw1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install foo",
InstallerFile: installer1,
StorageID: uuid.NewString(),
Filename: "foo.pkg",
Title: "foo",
Source: "apps",
Version: "0.0.1",
UserID: u.ID,
UninstallScript: "uninstall foo",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
// create a pending software install request
sw1_1, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// set a result for the software install
_, err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: h1.ID,
InstallUUID: sw1_1,
InstallScriptExitCode: ptr.Int(0),
}, nil)
require.NoError(t, err)
// create a pending script execution request
hsr, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{
HostID: h1.ID,
ScriptContents: "echo 'a'",
})
require.NoError(t, err)
script1_1 := hsr.ExecutionID
// set a result for the script
_, _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: h1.ID, ExecutionID: script1_1, Output: "a", ExitCode: 0,
}, nil)
require.NoError(t, err)
// create a pending uninstall request
sw1_2 := uuid.NewString()
err = ds.InsertSoftwareUninstallRequest(ctx, sw1_2, h1.ID, sw1, false)
require.NoError(t, err)
// set a result for the software uninstall
_, _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: h1.ID,
ExecutionID: sw1_2,
ExitCode: 1,
}, nil)
require.NoError(t, err)
// create a pending vpp app install
vpp1_1 := uuid.NewString()
err = ds.InsertHostVPPSoftwareInstall(ctx, h1.ID, vppApp1.VPPAppID, vpp1_1, "event-id-1", fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
// set the result for the vpp app
cmdRes := &mdm.CommandResults{
CommandUUID: vpp1_1,
Status: "Error",
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?>`),
}
err = nanoDB.StoreCommandReport(nanoCtx, cmdRes)
require.NoError(t, err)
err = activitySvc.NewActivity(ctx, nil, fleet.ActivityInstalledAppStoreApp{
HostID: h1.ID,
AppStoreID: vppApp1.VPPAppTeam.AdamID,
CommandUUID: vpp1_1,
})
require.NoError(t, err)
// the upcoming queue should be empty, each result having emptied the list
// and each enqueue having triggered the next activity.
pendingActs, _, err := ds.ListHostUpcomingActivities(ctx, h1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, pendingActs, 0)
}
func testCancelNonActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
activitySvc := NewTestActivityService(t, ds)
newActivityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
var apiUser *activity_api.User
if user != nil {
apiUser = &activity_api.User{ID: user.ID, Name: user.Name, Email: user.Email}
}
return activitySvc.NewActivity(ctx, apiUser, activity)
}
ctx := context.Background()
test.CreateInsertGlobalVPPToken(t, ds)
u := test.NewUser(t, ds, "user1", "user1@example.com", false)
host := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now())
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)
// enqueue an activity on hostLeftUntouched, must still be there after the tests
execIDUntouched := test.CreateHostScriptUpcomingActivity(t, ds, hostLeftUntouched)
// cancel an activity on a non-existing host
_, err = ds.CancelHostUpcomingActivity(ctx, 999, "non-existing")
var nfe fleet.NotFoundError
require.ErrorAs(t, err, &nfe)
// cancel a non-existing activity on an existing host
_, err = ds.CancelHostUpcomingActivity(ctx, host.ID, "non-existing")
require.ErrorAs(t, err, &nfe)
pluckExecIDs := func(acts []*fleet.UpcomingActivity) []string {
var execIDs []string
for _, act := range acts {
execIDs = append(execIDs, act.UUID)
}
return execIDs
}
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)
t.Cleanup(func() {
test.SetHostScriptResult(t, ds, host, exec1, 0)
})
return []string{exec1, exec2}
},
cancelIndex: 1,
},
{
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)
t.Cleanup(func() {
test.SetHostSoftwareInstallResult(t, ds, host, exec1, 0)
})
return []string{exec1, exec2}
},
cancelIndex: 1,
},
{
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)
t.Cleanup(func() {
test.SetHostSoftwareInstallResult(t, ds, host, exec1, 0)
})
return []string{exec1, exec2}
},
cancelIndex: 1,
},
{
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)
t.Cleanup(func() {
test.SetHostSoftwareUninstallResult(t, ds, host, exec1, 0)
})
return []string{exec1, exec2}
},
cancelIndex: 1,
},
{
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)
exec3 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u)
t.Cleanup(func() {
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec1, adamID, "Acknowledged", newActivityFn)
test.SetHostSoftwareInstallResult(t, ds, host, exec3, 0)
})
return []string{exec1, exec2, exec3}
},
cancelIndex: 1,
},
{
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)
exec3 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u)
t.Cleanup(func() {
test.SetHostSoftwareInstallResult(t, ds, host, exec1, 0)
test.SetHostScriptResult(t, ds, host, exec2, 0)
})
return []string{exec1, exec2, exec3}
},
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", newActivityFn)
})
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, 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, c.host.ID, cancelExecID)
require.NoError(t, err)
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))
})
}
// check that hostLeftUntouched was... left untouched
got, _, err := ds.ListHostUpcomingActivities(ctx, hostLeftUntouched.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, []string{execIDUntouched}, pluckExecIDs(got))
}
func testCancelActivatedUpcomingActivity(t *testing.T, ds *Datastore) {
activitySvc := NewTestActivityService(t, ds)
newActivityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
var apiUser *activity_api.User
if user != nil {
apiUser = &activity_api.User{ID: user.ID, Name: user.Name, Email: user.Email}
}
return activitySvc.NewActivity(ctx, apiUser, activity)
}
ctx := context.Background()
test.CreateInsertGlobalVPPToken(t, ds)
u := test.NewUser(t, ds, "user1", "user1@example.com", false)
host := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now())
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)
// enqueue an activity on hostLeftUntouched, must still be there after the tests
execIDUntouched := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, hostLeftUntouched, u)
pluckExecIDs := func(acts []*fleet.UpcomingActivity) []string {
execIDs := []string{}
for _, act := range acts {
execIDs = append(execIDs, act.UUID)
}
return execIDs
}
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)
t.Cleanup(func() {
test.SetHostSoftwareInstallResult(t, ds, host, exec2, 0)
})
return []string{exec1, exec2}
},
},
{
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)
t.Cleanup(func() {
test.SetHostSoftwareUninstallResult(t, ds, host, exec2, 0)
})
return []string{exec1, exec2}
},
},
{
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)
t.Cleanup(func() {
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec2, adamID, "Acknowledged", newActivityFn)
})
return []string{exec1, exec2}
},
},
{
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)
t.Cleanup(func() {
test.SetHostSoftwareInstallResult(t, ds, host, exec2, 0)
})
return []string{exec1, exec2}
},
},
{
desc: "cancel script none after",
host: host,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostScriptUpcomingActivity(t, ds, host)
return []string{exec1}
},
},
{
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)
exec3 := test.CreateHostScriptUpcomingActivity(t, ds, host)
t.Cleanup(func() {
test.SetHostSoftwareUninstallResult(t, ds, host, exec2, 0)
test.SetHostScriptResult(t, ds, host, exec3, 0)
})
return []string{exec1, exec2, exec3}
},
},
{
desc: "cancel sofware uninstall none after",
host: host,
setup: func(t *testing.T) []string {
exec1 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u)
return []string{exec1}
},
},
{
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)
t.Cleanup(func() {
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec2, adamID, "Acknowledged", newActivityFn)
})
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", newActivityFn)
})
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", newActivityFn)
})
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, 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, c.host.ID, cancelExecID)
require.NoError(t, err)
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))
// 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, 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, 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`, c.host.UUID)
return err
})
require.True(t, len(nanoExecIDs) <= 1)
gotExecIDs = append(gotExecIDs, nanoExecIDs...)
if len(expectedExecIDs) == 0 {
require.Len(t, gotExecIDs, 0)
} else {
require.Len(t, gotExecIDs, 1)
require.Equal(t, expectedExecIDs[0], gotExecIDs[0])
}
})
}
// check that hostLeftUntouched was... left untouched
got, _, err := ds.ListHostUpcomingActivities(ctx, hostLeftUntouched.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, []string{execIDUntouched}, pluckExecIDs(got))
}
func testBatchCancelAllHostUpcomingActivities(t *testing.T, ds *Datastore) {
ctx := context.Background()
test.CreateInsertGlobalVPPToken(t, ds)
u := test.NewUser(t, ds, "user1", "user1@example.com", false)
host := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now())
nanoEnrollAndSetHostMDMData(t, ds, host, false)
hostIOS := test.NewHost(t, ds, "h2.local", "10.10.10.2", "2", "2", time.Now(), test.WithPlatform("ios"))
nanoEnrollAndSetHostMDMData(t, ds, hostIOS, false)
hostLeftUntouched := test.NewHost(t, ds, "h3.local", "10.10.10.3", "3", "3", time.Now())
nanoEnrollAndSetHostMDMData(t, ds, hostLeftUntouched, false)
pluckExecIDs := func(acts []*fleet.UpcomingActivity) []string {
execIDs := []string{}
for _, act := range acts {
execIDs = append(execIDs, act.UUID)
}
return execIDs
}
// edge case: host with no upcoming activities returns empty slice with no error
canceled, err := ds.BatchCancelAllHostUpcomingActivities(ctx, hostLeftUntouched.ID)
require.NoError(t, err)
require.Empty(t, canceled)
// enqueue an activity on hostLeftUntouched, must still be there after the test
execIDUntouched := test.CreateHostScriptUpcomingActivity(t, ds, hostLeftUntouched)
// enqueue mixed activities on the main host: the first becomes activated,
// the rest stay queued.
exec1 := test.CreateHostScriptUpcomingActivity(t, ds, host)
exec2 := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u)
exec3 := test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u)
exec4, _ := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host)
expectedExecIDs := []string{exec1, exec2, exec3, exec4}
got, _, err := ds.ListHostUpcomingActivities(ctx, host.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, got, len(expectedExecIDs))
require.Equal(t, expectedExecIDs, pluckExecIDs(got))
// exec1 should already be activated (single activity at enqueue time)
meta, err := ds.GetHostUpcomingActivityMeta(ctx, host.ID, exec1)
require.NoError(t, err)
require.NotNil(t, meta.ActivatedAt)
// cancel everything in one shot
canceled, err = ds.BatchCancelAllHostUpcomingActivities(ctx, host.ID)
require.NoError(t, err)
require.Len(t, canceled, len(expectedExecIDs))
// queue should now be empty
got, _, err = ds.ListHostUpcomingActivities(ctx, host.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Empty(t, got)
// exec1 was activated, so its host_script_results row must be marked canceled
var scriptCanceled bool
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &scriptCanceled,
`SELECT canceled FROM host_script_results WHERE execution_id = ?`, exec1)
})
require.True(t, scriptCanceled)
// hostLeftUntouched still has its single activity untouched
got, _, err = ds.ListHostUpcomingActivities(ctx, hostLeftUntouched.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, []string{execIDUntouched}, pluckExecIDs(got))
// repeat on an iOS host with an in-house app install (activated) followed by
// a vpp install, to cover the in_house and vpp activated-cancel branches.
exec5 := test.CreateHostInHouseAppInstallUpcomingActivity(t, ds, hostIOS, u)
exec6, _ := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, hostIOS)
got, _, err = ds.ListHostUpcomingActivities(ctx, hostIOS.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Equal(t, []string{exec5, exec6}, pluckExecIDs(got))
canceledIOS, err := ds.BatchCancelAllHostUpcomingActivities(ctx, hostIOS.ID)
require.NoError(t, err)
require.Len(t, canceledIOS, 2)
got, _, err = ds.ListHostUpcomingActivities(ctx, hostIOS.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Empty(t, got)
// exec5 was activated; its host_in_house_software_installs row must be canceled
var inHouseCanceled bool
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &inHouseCanceled,
`SELECT canceled FROM host_in_house_software_installs WHERE command_uuid = ?`, exec5)
})
require.True(t, inHouseCanceled)
}
func testSetResultAfterCancelUpcomingActivity(t *testing.T, ds *Datastore) {
activitySvc := NewTestActivityService(t, ds)
newActivityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
var apiUser *activity_api.User
if user != nil {
apiUser = &activity_api.User{ID: user.ID, Name: user.Name, Email: user.Email}
}
return activitySvc.NewActivity(ctx, apiUser, activity)
}
ctx := context.Background()
test.CreateInsertGlobalVPPToken(t, ds)
u := test.NewUser(t, ds, "user1", "user1@example.com", false)
host := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now())
nanoEnrollAndSetHostMDMData(t, ds, host, false)
nanoDB, err := nanomdm_mysql.New(nanomdm_mysql.WithDB(ds.primary.DB))
require.NoError(t, err)
// set a script result post-cancel
exec := test.CreateHostScriptUpcomingActivity(t, ds, host)
_, err = ds.CancelHostUpcomingActivity(ctx, host.ID, exec)
require.NoError(t, err)
test.SetHostScriptResult(t, ds, host, exec, 0)
// set a software install result post-cancel
exec = test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host, u)
_, err = ds.CancelHostUpcomingActivity(ctx, host.ID, exec)
require.NoError(t, err)
test.SetHostSoftwareInstallResult(t, ds, host, exec, 0)
// set a software uninstall result post-cancel
exec = test.CreateHostSoftwareUninstallUpcomingActivity(t, ds, host, u)
_, err = ds.CancelHostUpcomingActivity(ctx, host.ID, exec)
require.NoError(t, err)
test.SetHostSoftwareUninstallResult(t, ds, host, exec, 0)
// set a vpp app install result post-cancel
exec, adamID := test.CreateHostVPPAppInstallUpcomingActivity(t, ds, host)
_, err = ds.CancelHostUpcomingActivity(ctx, host.ID, exec)
require.NoError(t, err)
test.SetHostVPPAppInstallResult(t, ds, nanoDB, host, exec, adamID, "Acknowledged", newActivityFn)
}
func testGetHostUpcomingActivityMeta(t *testing.T, ds *Datastore) {
ctx := context.Background()
host1 := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now())
host2 := test.NewHost(t, ds, "h2.local", "10.10.10.2", "2", "2", time.Now())
host1.Platform = "linux"
host2.Platform = "linux"
err := ds.UpdateHost(ctx, host1)
require.NoError(t, err)
err = ds.UpdateHost(ctx, host2)
require.NoError(t, err)
u := test.NewUser(t, ds, "user1", "user1@example.com", false)
// get meta with unknown host
_, err = ds.GetHostUpcomingActivityMeta(ctx, 999, "non-existing")
var nfe fleet.NotFoundError
require.ErrorAs(t, err, &nfe)
// get meta with unknown exec ID
_, err = ds.GetHostUpcomingActivityMeta(ctx, host1.ID, "non-existing")
require.ErrorAs(t, err, &nfe)
assertActivityMeta := func(want, got *fleet.UpcomingActivityMeta) {
require.Equal(t, want.ExecutionID, got.ExecutionID)
// we just assert activated vs non-activated
require.Equal(t, want.ActivatedAt != nil, got.ActivatedAt != nil)
require.Equal(t, want.UpcomingActivityType, got.UpcomingActivityType)
require.Equal(t, want.WellKnownAction, got.WellKnownAction)
}
// create an install request that is not any special command
swExecID := test.CreateHostSoftwareInstallUpcomingActivity(t, ds, host1, u)
meta, err := ds.GetHostUpcomingActivityMeta(ctx, host1.ID, swExecID)
require.NoError(t, err)
assertActivityMeta(&fleet.UpcomingActivityMeta{
ExecutionID: swExecID,
ActivatedAt: ptr.Time(time.Now()), // will just check nil vs non-nil
UpcomingActivityType: "software_install",
WellKnownAction: fleet.WellKnownActionNone,
}, meta)
// create a lock request on host1
err = ds.LockHostViaScript(ctx, &fleet.HostScriptRequestPayload{HostID: host1.ID}, "linux")
require.NoError(t, err)
// create a wipe request on host2
err = ds.WipeHostViaScript(ctx, &fleet.HostScriptRequestPayload{HostID: host2.ID}, "linux")
require.NoError(t, err)
// grab the exec ID of the lock
activities, _, err := ds.ListHostUpcomingActivities(ctx, host1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, activities, 2)
require.Equal(t, swExecID, activities[0].UUID)
lockExecID := activities[1].UUID
// grab the exec ID of the wipe
activities, _, err = ds.ListHostUpcomingActivities(ctx, host2.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, activities, 1)
wipeExecID := activities[0].UUID
// lock meta is as expected
meta, err = ds.GetHostUpcomingActivityMeta(ctx, host1.ID, lockExecID)
require.NoError(t, err)
assertActivityMeta(&fleet.UpcomingActivityMeta{
ExecutionID: lockExecID,
ActivatedAt: nil,
UpcomingActivityType: "script",
WellKnownAction: fleet.WellKnownActionLock,
}, meta)
// wipe meta is as expected
meta, err = ds.GetHostUpcomingActivityMeta(ctx, host2.ID, wipeExecID)
require.NoError(t, err)
assertActivityMeta(&fleet.UpcomingActivityMeta{
ExecutionID: wipeExecID,
ActivatedAt: ptr.Time(time.Now()), // will just check nil vs non-nil
UpcomingActivityType: "script",
WellKnownAction: fleet.WellKnownActionWipe,
}, meta)
// set a result for the software install
test.SetHostSoftwareInstallResult(t, ds, host1, swExecID, 0)
// the lock script is now activated
meta, err = ds.GetHostUpcomingActivityMeta(ctx, host1.ID, lockExecID)
require.NoError(t, err)
assertActivityMeta(&fleet.UpcomingActivityMeta{
ExecutionID: lockExecID,
ActivatedAt: ptr.Time(time.Now()), // will just check nil vs non-nil
UpcomingActivityType: "script",
WellKnownAction: fleet.WellKnownActionLock,
}, meta)
// wipe meta on host2 is unchanged
meta, err = ds.GetHostUpcomingActivityMeta(ctx, host2.ID, wipeExecID)
require.NoError(t, err)
assertActivityMeta(&fleet.UpcomingActivityMeta{
ExecutionID: wipeExecID,
ActivatedAt: ptr.Time(time.Now()), // will just check nil vs non-nil
UpcomingActivityType: "script",
WellKnownAction: fleet.WellKnownActionWipe,
}, meta)
// enqueue a new script activity
scrExecID := test.CreateHostScriptUpcomingActivity(t, ds, host1)
// its meta is as expected
meta, err = ds.GetHostUpcomingActivityMeta(ctx, host1.ID, scrExecID)
require.NoError(t, err)
assertActivityMeta(&fleet.UpcomingActivityMeta{
ExecutionID: scrExecID,
ActivatedAt: nil,
UpcomingActivityType: "script",
WellKnownAction: fleet.WellKnownActionNone,
}, meta)
// set a result for the lock action
test.SetHostScriptResult(t, ds, host1, lockExecID, 0)
// its meta is now non-existing
_, err = ds.GetHostUpcomingActivityMeta(ctx, host1.ID, lockExecID)
require.ErrorAs(t, err, &nfe)
// set a result for the wipe action
test.SetHostScriptResult(t, ds, host2, wipeExecID, 0)
// its meta is now non-existing
_, err = ds.GetHostUpcomingActivityMeta(ctx, host2.ID, wipeExecID)
require.ErrorAs(t, err, &nfe)
}
func testUnblockHostsUpcomingActivityQueue(t *testing.T, ds *Datastore) {
ctx := t.Context()
u := test.NewUser(t, ds, "user1", "user1@example.com", false)
// create a few hosts
hosts := make([]*fleet.Host, 5)
for i := range hosts {
host := test.NewHost(t, ds, fmt.Sprintf("h%d.local", i+1), fmt.Sprintf("10.10.10.%d", i+1), fmt.Sprint(i+1), fmt.Sprint(i+1), time.Now())
hosts[i] = host
}
// run without anything in any host queue
n, err := ds.UnblockHostsUpcomingActivityQueue(ctx, 10)
require.NoError(t, err)
require.Equal(t, 0, n)
deleteUpcomingActivityToBlockQueue := func(execID string) {
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `DELETE FROM upcoming_activities WHERE execution_id = ?`, execID)
return err
})
}
// enqueue some activities on some hosts (the nature of the activity is not relevant)
host0ScriptA, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[0].ID, ScriptContents: "A", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host0ScriptB, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[0].ID, ScriptContents: "B", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host1ScriptA, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[1].ID, ScriptContents: "A", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host2ScriptA, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[2].ID, ScriptContents: "A", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
checkUpcomingActivities(t, ds, hosts[0], host0ScriptA.ExecutionID, host0ScriptB.ExecutionID)
checkUpcomingActivities(t, ds, hosts[1], host1ScriptA.ExecutionID)
checkUpcomingActivities(t, ds, hosts[2], host2ScriptA.ExecutionID)
checkUpcomingActivities(t, ds, hosts[3])
checkUpcomingActivities(t, ds, hosts[4])
// nothing to unblock
n, err = ds.UnblockHostsUpcomingActivityQueue(ctx, 10)
require.NoError(t, err)
require.Equal(t, 0, n)
// block queue for host 0
deleteUpcomingActivityToBlockQueue(host0ScriptA.ExecutionID)
n, err = ds.UnblockHostsUpcomingActivityQueue(ctx, 10)
require.NoError(t, err)
require.Equal(t, 1, n)
checkUpcomingActivities(t, ds, hosts[0], host0ScriptB.ExecutionID)
checkUpcomingActivities(t, ds, hosts[1], host1ScriptA.ExecutionID)
checkUpcomingActivities(t, ds, hosts[2], host2ScriptA.ExecutionID)
checkUpcomingActivities(t, ds, hosts[3])
checkUpcomingActivities(t, ds, hosts[4])
// enqueue script C for all hosts
host0ScriptC, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[0].ID, ScriptContents: "C", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host1ScriptC, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[1].ID, ScriptContents: "C", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host2ScriptC, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[2].ID, ScriptContents: "C", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host3ScriptC, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[3].ID, ScriptContents: "C", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host4ScriptC, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[4].ID, ScriptContents: "C", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
checkUpcomingActivities(t, ds, hosts[0], host0ScriptB.ExecutionID, host0ScriptC.ExecutionID)
checkUpcomingActivities(t, ds, hosts[1], host1ScriptA.ExecutionID, host1ScriptC.ExecutionID)
checkUpcomingActivities(t, ds, hosts[2], host2ScriptA.ExecutionID, host2ScriptC.ExecutionID)
checkUpcomingActivities(t, ds, hosts[3], host3ScriptC.ExecutionID)
checkUpcomingActivities(t, ds, hosts[4], host4ScriptC.ExecutionID)
// block queue for all hosts, but since hosts 3 and 4 are now empty, no need
// to unblock
deleteUpcomingActivityToBlockQueue(host0ScriptB.ExecutionID)
deleteUpcomingActivityToBlockQueue(host1ScriptA.ExecutionID)
deleteUpcomingActivityToBlockQueue(host2ScriptA.ExecutionID)
deleteUpcomingActivityToBlockQueue(host3ScriptC.ExecutionID)
deleteUpcomingActivityToBlockQueue(host4ScriptC.ExecutionID)
n, err = ds.UnblockHostsUpcomingActivityQueue(ctx, 10)
require.NoError(t, err)
require.Equal(t, 3, n)
checkUpcomingActivities(t, ds, hosts[0], host0ScriptC.ExecutionID)
checkUpcomingActivities(t, ds, hosts[1], host1ScriptC.ExecutionID)
checkUpcomingActivities(t, ds, hosts[2], host2ScriptC.ExecutionID)
checkUpcomingActivities(t, ds, hosts[3])
checkUpcomingActivities(t, ds, hosts[4])
// enqueue script D and E for all hosts
host0ScriptD, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[0].ID, ScriptContents: "D", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host1ScriptD, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[1].ID, ScriptContents: "D", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host2ScriptD, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[2].ID, ScriptContents: "D", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host3ScriptD, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[3].ID, ScriptContents: "D", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host4ScriptD, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[4].ID, ScriptContents: "D", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host0ScriptE, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[0].ID, ScriptContents: "E", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host1ScriptE, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[1].ID, ScriptContents: "E", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host2ScriptE, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[2].ID, ScriptContents: "E", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host3ScriptE, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[3].ID, ScriptContents: "E", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
host4ScriptE, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: hosts[4].ID, ScriptContents: "E", UserID: &u.ID, SyncRequest: true})
require.NoError(t, err)
checkUpcomingActivities(t, ds, hosts[0], host0ScriptC.ExecutionID, host0ScriptD.ExecutionID, host0ScriptE.ExecutionID)
checkUpcomingActivities(t, ds, hosts[1], host1ScriptC.ExecutionID, host1ScriptD.ExecutionID, host1ScriptE.ExecutionID)
checkUpcomingActivities(t, ds, hosts[2], host2ScriptC.ExecutionID, host2ScriptD.ExecutionID, host2ScriptE.ExecutionID)
checkUpcomingActivities(t, ds, hosts[3], host3ScriptD.ExecutionID, host3ScriptE.ExecutionID)
checkUpcomingActivities(t, ds, hosts[4], host4ScriptD.ExecutionID, host4ScriptE.ExecutionID)
// block queue for all hosts
deleteUpcomingActivityToBlockQueue(host0ScriptC.ExecutionID)
deleteUpcomingActivityToBlockQueue(host1ScriptC.ExecutionID)
deleteUpcomingActivityToBlockQueue(host2ScriptC.ExecutionID)
deleteUpcomingActivityToBlockQueue(host3ScriptD.ExecutionID)
deleteUpcomingActivityToBlockQueue(host4ScriptD.ExecutionID)
// process max 3 hosts
n, err = ds.UnblockHostsUpcomingActivityQueue(ctx, 3)
require.NoError(t, err)
require.Equal(t, 3, n)
// run again, should process the next 2 hosts
n, err = ds.UnblockHostsUpcomingActivityQueue(ctx, 3)
require.NoError(t, err)
require.Equal(t, 2, n)
// run again, nothing to unblock
n, err = ds.UnblockHostsUpcomingActivityQueue(ctx, 3)
require.NoError(t, err)
require.Equal(t, 0, n)
checkUpcomingActivities(t, ds, hosts[0], host0ScriptD.ExecutionID, host0ScriptE.ExecutionID)
checkUpcomingActivities(t, ds, hosts[1], host1ScriptD.ExecutionID, host1ScriptE.ExecutionID)
checkUpcomingActivities(t, ds, hosts[2], host2ScriptD.ExecutionID, host2ScriptE.ExecutionID)
checkUpcomingActivities(t, ds, hosts[3], host3ScriptE.ExecutionID)
checkUpcomingActivities(t, ds, hosts[4], host4ScriptE.ExecutionID)
}
func testActivateScriptPackageInstallWithCorruptPayload(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host1", "192.168.1.1", "1", "1", time.Now())
titleStmt := `INSERT INTO software_titles (name, source, extension_for) VALUES (?, ?, '')`
res, err := ds.writer(ctx).ExecContext(ctx, titleStmt, "Test Script", "sh_packages")
require.NoError(t, err)
titleID, _ := res.LastInsertId()
u := test.NewUser(t, ds, "user1", "user1@example.com", false)
scriptContentStmt := `INSERT INTO script_contents (md5_checksum, contents) VALUES (?, ?)`
res, err = ds.writer(ctx).ExecContext(ctx, scriptContentStmt, "abc123", "#!/bin/bash\necho 'test'")
require.NoError(t, err)
scriptContentID, _ := res.LastInsertId()
installerStmt := `
INSERT INTO software_installers (
team_id, global_or_team_id, title_id, storage_id, filename,
extension, version, platform, install_script_content_id,
pre_install_query, post_install_script_content_id, uninstall_script_content_id,
self_service, user_id, user_name, user_email, package_ids,
fleet_maintained_app_id, url, upgrade_code, patch_query
)
VALUES (NULL, 0, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
`
res, err = ds.writer(ctx).ExecContext(ctx, installerStmt,
titleID, "storage-123", "test-script.sh", "sh", "", "linux", scriptContentID,
"", scriptContentID, 0, u.ID, u.Name, u.Email, "", "", "", "")
require.NoError(t, err)
installerID, _ := res.LastInsertId()
execID := uuid.NewString()
uaStmt := `INSERT INTO upcoming_activities (host_id, priority, activity_type, execution_id, payload) VALUES (?, 1, 'software_install', ?, JSON_OBJECT())`
res, err = ds.writer(ctx).ExecContext(ctx, uaStmt, host.ID, execID)
require.NoError(t, err)
activityID, _ := res.LastInsertId()
siuaStmt := `INSERT INTO software_install_upcoming_activities (upcoming_activity_id, software_installer_id, policy_id, software_title_id) VALUES (?, ?, NULL, NULL)`
_, err = ds.writer(ctx).ExecContext(ctx, siuaStmt, activityID, installerID)
require.NoError(t, err)
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
_, err := ds.activateNextUpcomingActivity(ctx, tx, host.ID, "")
return err
})
require.NoError(t, err)
var result struct {
SoftwareTitleID *uint `db:"software_title_id"`
SoftwareTitleName string `db:"software_title_name"`
InstallerFilename string `db:"installer_filename"`
Version string `db:"version"`
}
err = ds.writer(ctx).GetContext(ctx, &result,
"SELECT software_title_id, software_title_name, installer_filename, version FROM host_software_installs WHERE execution_id = ?",
execID)
require.NoError(t, err)
require.NotNil(t, result.SoftwareTitleID)
require.Equal(t, uint(titleID), *result.SoftwareTitleID) //nolint:gosec // dismiss G115
require.Equal(t, "Test Script", result.SoftwareTitleName)
require.Equal(t, "test-script.sh", result.InstallerFilename)
require.Equal(t, "", result.Version)
}
func testActivateRegularPackageInstall(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host2", "192.168.1.2", "2", "2", time.Now())
u := test.NewUser(t, ds, "user2", "user2@example.com", false)
installer, err := fleet.NewTempFileReader(strings.NewReader("fake pkg"), t.TempDir)
require.NoError(t, err)
installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install regular",
InstallerFile: installer,
StorageID: uuid.NewString(),
Filename: "regular.pkg",
Title: "Regular Package",
Source: "pkg_packages",
Version: "1.0.0",
UserID: u.ID,
Extension: "pkg",
Platform: "darwin",
BundleIdentifier: "com.regular.pkg",
UninstallScript: "uninstall regular",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
execID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, fleet.HostSoftwareInstallOptions{})
require.NoError(t, err)
var result struct {
SoftwareTitleID *uint `db:"software_title_id"`
SoftwareTitleName string `db:"software_title_name"`
InstallerFilename string `db:"installer_filename"`
Version string `db:"version"`
}
err = ds.writer(ctx).GetContext(ctx, &result,
"SELECT software_title_id, software_title_name, installer_filename, version FROM host_software_installs WHERE execution_id = ?",
execID)
require.NoError(t, err)
require.NotNil(t, result.SoftwareTitleID)
require.Equal(t, titleID, *result.SoftwareTitleID)
require.Equal(t, "Regular Package", result.SoftwareTitleName)
require.Equal(t, "regular.pkg", result.InstallerFilename)
require.Equal(t, "1.0.0", result.Version)
}
func testActivateDeletedInstallerShowsPlaceholder(t *testing.T, ds *Datastore) {
ctx := context.Background()
host := test.NewHost(t, ds, "host3", "192.168.1.3", "3", "3", time.Now())
u := test.NewUser(t, ds, "user3", "user3@example.com", false)
installer, err := fleet.NewTempFileReader(strings.NewReader("temp"), t.TempDir)
require.NoError(t, err)
installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install temp",
InstallerFile: installer,
StorageID: uuid.NewString(),
Filename: "temp.pkg",
Title: "Temp Package",
Source: "pkg_packages",
Version: "1.0.0",
UserID: u.ID,
Extension: "pkg",
Platform: "darwin",
UninstallScript: "uninstall temp",
ValidatedLabels: &fleet.LabelIdentsWithScope{},
})
require.NoError(t, err)
execID := uuid.NewString()
uaStmt := `INSERT INTO upcoming_activities (host_id, priority, activity_type, execution_id, payload) VALUES (?, 1, 'software_install', ?, JSON_OBJECT())`
res, err := ds.writer(ctx).ExecContext(ctx, uaStmt, host.ID, execID)
require.NoError(t, err)
activityID, _ := res.LastInsertId()
siuaStmt := `INSERT INTO software_install_upcoming_activities (upcoming_activity_id, software_installer_id, policy_id, software_title_id) VALUES (?, ?, NULL, NULL)`
_, err = ds.writer(ctx).ExecContext(ctx, siuaStmt, activityID, installerID)
require.NoError(t, err)
deleteStmt := `DELETE FROM software_installers WHERE id = ?`
_, err = ds.writer(ctx).ExecContext(ctx, deleteStmt, installerID)
require.NoError(t, err)
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
_, err := ds.activateNextUpcomingActivity(ctx, tx, host.ID, "")
return err
})
require.NoError(t, err)
var result struct {
SoftwareTitleID *uint `db:"software_title_id"`
SoftwareTitleName string `db:"software_title_name"`
InstallerFilename string `db:"installer_filename"`
Version string `db:"version"`
}
err = ds.writer(ctx).GetContext(ctx, &result,
"SELECT software_title_id, software_title_name, installer_filename, version FROM host_software_installs WHERE execution_id = ?",
execID)
require.NoError(t, err)
require.Nil(t, result.SoftwareTitleID)
require.Equal(t, "[deleted title]", result.SoftwareTitleName)
require.Equal(t, "[deleted installer]", result.InstallerFilename)
require.Equal(t, "unknown", result.Version)
}
func testActivateScriptPackageUninstallWithCorruptPayload(t *testing.T, ds *Datastore) {
ctx := context.Background()
titleStmt := `INSERT INTO software_titles (name, source, extension_for) VALUES ('Test Uninstall Script', 'apps', '')`
res, err := ds.writer(ctx).ExecContext(ctx, titleStmt)
require.NoError(t, err)
titleID, _ := res.LastInsertId()
u := test.NewUser(t, ds, "uninstall-user", "uninstall@example.com", false)
scriptStmt := `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(MD5('echo uninstalling')), 'echo uninstalling')`
res, err = ds.writer(ctx).ExecContext(ctx, scriptStmt)
require.NoError(t, err)
scriptContentID, _ := res.LastInsertId()
installerStmt := `
INSERT INTO software_installers (
team_id, global_or_team_id, title_id, storage_id, filename,
extension, version, platform, install_script_content_id,
pre_install_query, post_install_script_content_id, uninstall_script_content_id,
self_service, user_id, user_name, user_email, package_ids,
fleet_maintained_app_id, url, upgrade_code, patch_query
)
VALUES (NULL, 0, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
`
res, err = ds.writer(ctx).ExecContext(ctx, installerStmt,
titleID, "storage-id-uninstall", "test-uninstall.sh", "sh", "", "linux", scriptContentID,
"", scriptContentID, 0, u.ID, u.Name, u.Email, "", "", "", "")
require.NoError(t, err)
installerID, _ := res.LastInsertId()
host := test.NewHost(t, ds, "test-host", "", "test-key", "test-uuid", time.Now())
execID := "uninstall-exec-123"
uaStmt := `INSERT INTO upcoming_activities (host_id, activity_type, execution_id, user_id, payload, priority) VALUES (?, 'software_uninstall', ?, NULL, JSON_OBJECT(), 0)`
res, err = ds.writer(ctx).ExecContext(ctx, uaStmt, host.ID, execID)
require.NoError(t, err)
activityID, _ := res.LastInsertId()
siuaStmt := `INSERT INTO software_install_upcoming_activities (upcoming_activity_id, software_installer_id, software_title_id) VALUES (?, ?, NULL)`
_, err = ds.writer(ctx).ExecContext(ctx, siuaStmt, activityID, installerID)
require.NoError(t, err)
err = ds.activateNextSoftwareUninstallActivity(ctx, ds.writer(ctx), host.ID, []string{execID})
require.NoError(t, err)
var result struct {
SoftwareTitleID *uint `db:"software_title_id"`
SoftwareTitleName string `db:"software_title_name"`
Uninstall bool `db:"uninstall"`
}
err = sqlx.GetContext(ctx, ds.reader(ctx), &result,
"SELECT software_title_id, software_title_name, uninstall FROM host_software_installs WHERE execution_id = ?",
execID)
require.NoError(t, err)
require.True(t, result.Uninstall)
require.NotNil(t, result.SoftwareTitleID)
require.Equal(t, uint(titleID), *result.SoftwareTitleID) //nolint:gosec // dismiss G115
require.Equal(t, "Test Uninstall Script", result.SoftwareTitleName)
}
func testListPolicyAutomationActivities(t *testing.T, ds *Datastore) {
ctx := t.Context()
activitySvc := NewTestActivityService(t, ds)
// adminFilter sees all hosts regardless of team.
adminFilter := fleet.TeamFilter{
User: &fleet.User{GlobalRole: new("admin")},
IncludeObserver: true,
}
// Create a policy to hang activities on.
policy, err := ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{Name: "test-policy", Query: "SELECT 1"})
require.NoError(t, err)
require.NotNil(t, policy)
// Create a second policy; its activities must NOT appear in results for the first.
otherPolicy, err := ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{Name: "other-policy", Query: "SELECT 2"})
require.NoError(t, err)
require.NotNil(t, otherPolicy)
// Create two hosts so we can test per-host rows and the host-name filter.
h1 := test.NewHost(t, ds, "host-alpha", "1.1.1.1", "key1", "uuid1", time.Now())
h2 := test.NewHost(t, ds, "host-beta", "2.2.2.2", "key2", "uuid2", time.Now())
makeDetails := func(policyID uint) map[string]any {
return map[string]any{"policy_id": policyID}
}
// Seed one activity of each type for policy 1 linked to h1,
// plus one success activity linked to both hosts (tests multi-host expansion).
errorTypes := []string{
"failed_automation_webhook",
"failed_automation_ticket",
"failed_automation_calendar_event",
"failed_automation_conditional_access",
}
successTypes := []string{
"ran_automation_webhook",
"ran_automation_ticket",
"ran_automation_calendar_event",
"ran_automation_conditional_access",
}
for _, typ := range errorTypes {
require.NoError(t, activitySvc.NewActivity(ctx, nil, dummyActivity{
name: typ,
details: makeDetails(policy.ID),
hostIDs: []uint{h1.ID},
}))
}
for _, typ := range successTypes {
require.NoError(t, activitySvc.NewActivity(ctx, nil, dummyActivity{
name: typ,
details: makeDetails(policy.ID),
hostIDs: []uint{h1.ID},
}))
}
// One activity linked to both hosts — produces two rows.
require.NoError(t, activitySvc.NewActivity(ctx, nil, dummyActivity{
name: "ran_automation_webhook",
details: makeDetails(policy.ID),
hostIDs: []uint{h1.ID, h2.ID},
}))
// Activity for the other policy — must not appear in results for policy 1.
require.NoError(t, activitySvc.NewActivity(ctx, nil, dummyActivity{
name: "failed_automation_webhook",
details: makeDetails(otherPolicy.ID),
hostIDs: []uint{h1.ID},
}))
listOpts := func(extra ...fleet.ListOptions) fleet.ListOptions {
opts := fleet.ListOptions{OrderKey: "id", IncludeMetadata: true}
if len(extra) > 0 {
if extra[0].PerPage != 0 {
opts.PerPage = extra[0].PerPage
}
if extra[0].Page != 0 {
opts.Page = extra[0].Page
}
if extra[0].MatchQuery != "" {
opts.MatchQuery = extra[0].MatchQuery
}
}
return opts
}
t.Run("returns all types by default", func(t *testing.T) {
// 8 single-host activities + 2 rows from the dual-host one = 10 rows.
activities, meta, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "")
require.NoError(t, err)
require.NotNil(t, meta)
require.Len(t, activities, 10)
for _, a := range activities {
require.NotZero(t, a.HostID)
// Policy automation activities are always Fleet-initiated; actor fields
// are not selected and must be absent (nil) so they're omitted from JSON.
require.Nil(t, a.ActorID)
require.Nil(t, a.ActorFullName)
require.Nil(t, a.ActorEmail)
}
})
t.Run("status=error returns only failed types", func(t *testing.T) {
activities, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "error")
require.NoError(t, err)
require.Len(t, activities, 4)
for _, a := range activities {
require.Contains(t, a.Type, "failed_")
}
})
t.Run("status=success returns only positive types", func(t *testing.T) {
activities, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "success")
require.NoError(t, err)
// 4 single-host success activities + 2 rows from dual-host = 6
require.Len(t, activities, 6)
for _, a := range activities {
require.NotContains(t, a.Type, "failed_")
require.Contains(t, successTypes, a.Type)
}
})
t.Run("pagination", func(t *testing.T) {
activities, meta, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(fleet.ListOptions{PerPage: 3}), "")
require.NoError(t, err)
require.NotNil(t, meta)
require.Len(t, activities, 3)
require.True(t, meta.HasNextResults)
require.False(t, meta.HasPreviousResults)
page2, meta2, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(fleet.ListOptions{PerPage: 3, Page: 1}), "")
require.NoError(t, err)
require.NotNil(t, meta2)
require.Len(t, page2, 3)
require.True(t, meta2.HasPreviousResults)
})
t.Run("host name query filters rows", func(t *testing.T) {
// "host-alpha" matches h1 only: 4 error + 4 success + 1 dual-host row = 9.
activities, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(fleet.ListOptions{MatchQuery: "host-alpha"}), "")
require.NoError(t, err)
require.Len(t, activities, 9)
for _, a := range activities {
require.Equal(t, h1.ID, a.HostID)
require.Equal(t, "host-alpha", a.HostDisplayName)
}
// "host-b" matches h2 only, which appears in just the dual-host activity.
activities, _, err = ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(fleet.ListOptions{MatchQuery: "host-b"}), "")
require.NoError(t, err)
require.Len(t, activities, 1)
for _, a := range activities {
require.Equal(t, h2.ID, a.HostID)
}
})
t.Run("other policy activities excluded", func(t *testing.T) {
activities, _, err := ds.ListPolicyAutomationActivities(ctx, otherPolicy.ID, adminFilter, listOpts(), "")
require.NoError(t, err)
require.Len(t, activities, 1)
require.Equal(t, h1.ID, activities[0].HostID)
})
t.Run("invalid order_key returns error", func(t *testing.T) {
_, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, fleet.ListOptions{OrderKey: "invalid_column"}, "")
require.Error(t, err)
})
t.Run("include_metadata false returns nil meta", func(t *testing.T) {
opts := fleet.ListOptions{OrderKey: "id", IncludeMetadata: false}
_, meta, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, opts, "")
require.NoError(t, err)
require.Nil(t, meta)
})
t.Run("query with wildcard characters matches literally", func(t *testing.T) {
// host-alpha has no '_' in its name; a query of "host_alpha" must NOT match
// it (the underscore is a literal character, not a SQL wildcard).
activities, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(fleet.ListOptions{MatchQuery: "host_alpha"}), "")
require.NoError(t, err)
require.Empty(t, activities)
// An empty result set must be a non-nil slice so it marshals as [] (not null).
require.NotNil(t, activities)
})
t.Run("team filter scopes hosts", func(t *testing.T) {
// Use a dedicated policy so activities seeded here don't affect count
// assertions in other subtests.
teamScopePolicy, err := ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{Name: "team-scope-policy", Query: "SELECT 3"})
require.NoError(t, err)
require.NotNil(t, teamScopePolicy)
// Create two teams and assign one host to each.
teamA, err := ds.NewTeam(ctx, &fleet.Team{Name: "team-A"})
require.NoError(t, err)
teamB, err := ds.NewTeam(ctx, &fleet.Team{Name: "team-B"})
require.NoError(t, err)
hA := test.NewHost(t, ds, "host-team-a", "10.0.0.1", "keyA", "uuidA", time.Now())
hB := test.NewHost(t, ds, "host-team-b", "10.0.0.2", "keyB", "uuidB", time.Now())
// Assign hosts to teams directly to avoid policy-membership side-effects.
_, err = ds.writer(ctx).ExecContext(ctx, `UPDATE hosts SET team_id = ? WHERE id = ?`, teamA.ID, hA.ID)
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx, `UPDATE hosts SET team_id = ? WHERE id = ?`, teamB.ID, hB.ID)
require.NoError(t, err)
// Seed activities for both hosts on the dedicated policy.
for _, typ := range errorTypes {
require.NoError(t, activitySvc.NewActivity(ctx, nil, dummyActivity{
name: typ,
details: makeDetails(teamScopePolicy.ID),
hostIDs: []uint{hA.ID, hB.ID},
}))
}
// A team-A observer filter sees only hA.
filterA := fleet.TeamFilter{
User: &fleet.User{
Teams: []fleet.UserTeam{{Team: fleet.Team{ID: teamA.ID}, Role: fleet.RoleObserver}},
},
IncludeObserver: true,
}
activities, _, err := ds.ListPolicyAutomationActivities(ctx, teamScopePolicy.ID, filterA, listOpts(), "")
require.NoError(t, err)
require.NotEmpty(t, activities)
for _, a := range activities {
require.Equal(t, hA.ID, a.HostID, "team-A filter must not return host from team-B")
}
})
// ── Script-run, software-install and VPP-install branches ─────────────────
// Disable FK checks so we can insert result rows without satisfying every
// foreign key in the test setup.
_, err = ds.writer(ctx).ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0")
require.NoError(t, err)
defer func() {
_, _ = ds.writer(ctx).ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
}()
// ── ran_script ────────────────────────────────────────────────────────────
scriptSuccessExecID := "script-success-exec-1"
scriptFailureExecID := "script-failure-exec-1"
_, err = ds.writer(ctx).ExecContext(ctx,
`INSERT INTO host_script_results (host_id, execution_id, output, exit_code, policy_id)
VALUES (?, ?, 'script ok output', 0, ?), (?, ?, 'script fail output', 1, ?)`,
h1.ID, scriptSuccessExecID, policy.ID,
h1.ID, scriptFailureExecID, policy.ID)
require.NoError(t, err)
require.NoError(t, activitySvc.NewActivity(ctx, nil, dummyActivity{
name: "ran_script",
details: map[string]any{"script_execution_id": scriptSuccessExecID, "script_name": "my-script.sh"},
hostIDs: []uint{h1.ID},
}))
require.NoError(t, activitySvc.NewActivity(ctx, nil, dummyActivity{
name: "ran_script",
details: map[string]any{"script_execution_id": scriptFailureExecID, "script_name": "my-script.sh"},
hostIDs: []uint{h1.ID},
}))
// ── installed_software ────────────────────────────────────────────────────
swSuccessExecID := "sw-success-exec-1"
swFailureExecID := "sw-failure-exec-1"
// insert_script_exit_code=0 → execution_status='installed'; exit_code=1 → 'failed_install'
_, err = ds.writer(ctx).ExecContext(ctx,
`INSERT INTO host_software_installs
(host_id, execution_id, software_installer_id, install_script_exit_code,
install_script_output, pre_install_query_output, post_install_script_output, policy_id)
VALUES
(?, ?, 1, 0, 'install ok', 'pre ok', 'post ok', ?),
(?, ?, 1, 1, 'install fail', 'pre fail', 'post fail', ?)`,
h1.ID, swSuccessExecID, policy.ID,
h1.ID, swFailureExecID, policy.ID)
require.NoError(t, err)
require.NoError(t, activitySvc.NewActivity(ctx, nil, dummyActivity{
name: "installed_software",
details: map[string]any{"install_uuid": swSuccessExecID, "software_title": "My Software", "status": "installed"},
hostIDs: []uint{h1.ID},
}))
require.NoError(t, activitySvc.NewActivity(ctx, nil, dummyActivity{
name: "installed_software",
details: map[string]any{"install_uuid": swFailureExecID, "software_title": "My Software", "status": "failed_install"},
hostIDs: []uint{h1.ID},
}))
// A historically-successful install whose host_software_installs row was later
// marked removed (e.g. after the installer package was edited or the software
// re-installed). The generated status column is NULL for such rows, so the
// outcome must come from the recorded details.status, not the live column.
swRemovedExecID := "sw-removed-exec-1"
_, err = ds.writer(ctx).ExecContext(ctx,
`INSERT INTO host_software_installs
(host_id, execution_id, software_installer_id, install_script_exit_code,
install_script_output, pre_install_query_output, post_install_script_output, policy_id, removed)
VALUES
(?, ?, 1, 0, 'install ok', 'pre ok', 'post ok', ?, 1)`,
h1.ID, swRemovedExecID, policy.ID)
require.NoError(t, err)
require.NoError(t, activitySvc.NewActivity(ctx, nil, dummyActivity{
name: "installed_software",
details: map[string]any{"install_uuid": swRemovedExecID, "software_title": "My Software", "status": "installed"},
hostIDs: []uint{h1.ID},
}))
// ── installed_app_store_app (VPP) ─────────────────────────────────────────
// Outcome comes from the recorded details.status (a terminal snapshot), like
// installed_software — not the live hvsi.verification_* columns. To prove
// that, the live verification columns are set to the OPPOSITE of each row's
// details.status: the "success" row is marked verification_failed_at and the
// "failure" row verification_at. If the query read the live columns, the
// outcomes would flip and the assertions below would fail.
vppSuccessCmdUUID := "vpp-success-cmd-1"
vppFailureCmdUUID := "vpp-failure-cmd-1"
_, err = ds.writer(ctx).ExecContext(ctx,
`INSERT INTO host_vpp_software_installs (host_id, adam_id, command_uuid, policy_id, platform, verification_failed_at)
VALUES (?, 'A001', ?, ?, 'darwin', NOW())`,
h1.ID, vppSuccessCmdUUID, policy.ID)
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx,
`INSERT INTO host_vpp_software_installs (host_id, adam_id, command_uuid, policy_id, platform, verification_at)
VALUES (?, 'A002', ?, ?, 'darwin', NOW())`,
h1.ID, vppFailureCmdUUID, policy.ID)
require.NoError(t, err)
require.NoError(t, activitySvc.NewActivity(ctx, nil, dummyActivity{
name: "installed_app_store_app",
details: map[string]any{"command_uuid": vppSuccessCmdUUID, "software_title": "My VPP App", "status": "installed"},
hostIDs: []uint{h1.ID},
}))
require.NoError(t, activitySvc.NewActivity(ctx, nil, dummyActivity{
name: "installed_app_store_app",
details: map[string]any{"command_uuid": vppFailureCmdUUID, "software_title": "My VPP App", "status": "failed_install"},
hostIDs: []uint{h1.ID},
}))
t.Run("script_software_vpp appear in all", func(t *testing.T) {
activities, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "")
require.NoError(t, err)
types := make(map[string]int)
for _, a := range activities {
types[a.Type]++
}
require.Positive(t, types["ran_script"])
require.Positive(t, types["installed_software"])
require.Positive(t, types["installed_app_store_app"])
})
t.Run("status=error includes script_software_vpp failures", func(t *testing.T) {
activities, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "error")
require.NoError(t, err)
types := make(map[string]int)
for _, a := range activities {
types[a.Type]++
}
// Named automation failures still present.
require.Equal(t, 4, types["failed_automation_webhook"]+
types["failed_automation_ticket"]+
types["failed_automation_calendar_event"]+
types["failed_automation_conditional_access"])
// Script/software/VPP failures present.
require.Positive(t, types["ran_script"])
require.Positive(t, types["installed_software"])
require.Positive(t, types["installed_app_store_app"])
})
t.Run("status=success includes script_software_vpp successes", func(t *testing.T) {
activities, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "success")
require.NoError(t, err)
types := make(map[string]int)
for _, a := range activities {
types[a.Type]++
}
// Named automation successes still present.
require.Positive(t, types["ran_automation_webhook"]+
types["ran_automation_ticket"]+
types["ran_automation_calendar_event"]+
types["ran_automation_conditional_access"])
// Script/software/VPP successes present.
require.Positive(t, types["ran_script"])
require.Positive(t, types["installed_software"])
require.Positive(t, types["installed_app_store_app"])
})
t.Run("task activities are independent of policy_membership", func(t *testing.T) {
// Modifying a policy's query or targets wipes/prunes policy_membership.
// Automation history must survive that, so deleting all membership rows
// for the policy must not drop the script/software/VPP activities.
_, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM policy_membership WHERE policy_id = ?`, policy.ID)
require.NoError(t, err)
activities, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "")
require.NoError(t, err)
types := make(map[string]int)
for _, a := range activities {
types[a.Type]++
}
require.Positive(t, types["ran_script"])
require.Positive(t, types["installed_software"])
require.Positive(t, types["installed_app_store_app"])
})
t.Run("removed install row is categorized by recorded status", func(t *testing.T) {
activities, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "")
require.NoError(t, err)
// hasRemovedInstall reports whether the removed install activity appears in
// the given result set (matched by its install_uuid in the details blob).
hasRemovedInstall := func(as []*fleet.PolicyAutomationActivity) bool {
for _, a := range as {
require.NotNil(t, a.Details)
var m map[string]any
require.NoError(t, json.Unmarshal(*a.Details, &m))
if uuid, _ := m["install_uuid"].(string); uuid == swRemovedExecID {
// The live host_software_installs.status is NULL (removed=1), but
// the recorded details.status is "installed", so the historical
// outcome must be reported as success.
require.Equal(t, "success", a.Status)
return true
}
}
return false
}
require.True(t, hasRemovedInstall(activities), "expected the removed install activity to be returned")
// The status filter must agree with the reported status: the removed row
// is a success, so it appears under status=success and not status=error.
// This guards errorCond/successCond, which the unfiltered query above does
// not exercise.
success, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "success")
require.NoError(t, err)
require.True(t, hasRemovedInstall(success), "removed install should appear under status=success")
errored, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "error")
require.NoError(t, err)
require.False(t, hasRemovedInstall(errored), "removed install must not appear under status=error")
})
t.Run("status and output are populated per activity", func(t *testing.T) {
activities, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "")
require.NoError(t, err)
var sawScriptSuccess, sawScriptFailure bool
var sawSwSuccess, sawSwFailure bool
var sawVPPSuccess, sawVPPFailure bool
var sawNamedError, sawNamedSuccess bool
// detailsValue extracts a string field from an activity's details blob.
detailsValue := func(a *fleet.PolicyAutomationActivity, key string) string {
require.NotNil(t, a.Details, "type %s missing details", a.Type)
var m map[string]any
require.NoError(t, json.Unmarshal(*a.Details, &m))
s, _ := m[key].(string)
return s
}
for _, a := range activities {
// Every activity carries an explicit error/success status.
require.Contains(t, []string{"error", "success"}, a.Status, "type %s", a.Type)
switch a.Type {
case "ran_script":
// Scripts always carry output; the script name comes through in
// the details blob. Pre/post-install output is install-only.
require.NotNil(t, a.Output)
require.Nil(t, a.PreInstallOutput)
require.Nil(t, a.PostInstallOutput)
require.Equal(t, "my-script.sh", detailsValue(a, "script_name"))
if a.Status == "success" {
sawScriptSuccess = true
require.Equal(t, "script ok output", *a.Output)
} else {
sawScriptFailure = true
require.Equal(t, "script fail output", *a.Output)
}
case "installed_software":
// Software installs carry the install-script output plus the
// pre-install query and post-install script output; the software
// title comes through in the details blob.
require.NotNil(t, a.Output)
require.NotNil(t, a.PreInstallOutput)
require.NotNil(t, a.PostInstallOutput)
require.Equal(t, "My Software", detailsValue(a, "software_title"))
if a.Status == "success" {
sawSwSuccess = true
require.Equal(t, "install ok", *a.Output)
require.Equal(t, "pre ok", *a.PreInstallOutput)
require.Equal(t, "post ok", *a.PostInstallOutput)
} else {
sawSwFailure = true
require.Equal(t, "install fail", *a.Output)
require.Equal(t, "pre fail", *a.PreInstallOutput)
require.Equal(t, "post fail", *a.PostInstallOutput)
}
case "installed_app_store_app":
// VPP apps are installed via MDM command, so there is no output;
// the software title comes through in the details blob.
require.Nil(t, a.Output)
require.Nil(t, a.PreInstallOutput)
require.Nil(t, a.PostInstallOutput)
require.Equal(t, "My VPP App", detailsValue(a, "software_title"))
if a.Status == "success" {
sawVPPSuccess = true
} else {
sawVPPFailure = true
}
default:
// Named automation activities encode outcome in the type and have
// no output.
require.Nil(t, a.Output)
require.Nil(t, a.PreInstallOutput)
require.Nil(t, a.PostInstallOutput)
if strings.HasPrefix(a.Type, "failed_") {
sawNamedError = true
require.Equal(t, "error", a.Status)
} else {
sawNamedSuccess = true
require.Equal(t, "success", a.Status)
}
}
}
require.True(t, sawScriptSuccess, "expected a successful ran_script")
require.True(t, sawScriptFailure, "expected a failed ran_script")
require.True(t, sawSwSuccess, "expected a successful installed_software")
require.True(t, sawSwFailure, "expected a failed installed_software")
require.True(t, sawVPPSuccess, "expected a successful installed_app_store_app")
require.True(t, sawVPPFailure, "expected a failed installed_app_store_app")
require.True(t, sawNamedError, "expected a failed named automation")
require.True(t, sawNamedSuccess, "expected a successful named automation")
})
t.Run("installed_software with an unrecorded status is treated as a success", func(t *testing.T) {
// Older installed_software activities can lack a recorded details.status
// (the field was added after the activity type, and back then the activity
// was only emitted on a successful install). 'failed_install' is the sole
// failure value, so a missing status is a success — and the reported status
// must agree with the filters: it appears under "All" and status=success,
// never under status=error.
execID := "sw-no-status-exec-1"
_, err := ds.writer(ctx).ExecContext(ctx,
`INSERT INTO host_software_installs
(host_id, execution_id, software_installer_id, install_script_exit_code,
install_script_output, policy_id)
VALUES (?, ?, 1, 0, 'historical output', ?)`,
h1.ID, execID, policy.ID)
require.NoError(t, err)
// details intentionally omits "status".
require.NoError(t, activitySvc.NewActivity(ctx, nil, dummyActivity{
name: "installed_software",
details: map[string]any{"install_uuid": execID, "software_title": "My Software"},
hostIDs: []uint{h1.ID},
}))
find := func(as []*fleet.PolicyAutomationActivity) *fleet.PolicyAutomationActivity {
for _, a := range as {
if a.Type != "installed_software" || a.Details == nil {
continue
}
var m map[string]any
require.NoError(t, json.Unmarshal(*a.Details, &m))
if m["install_uuid"] == execID {
return a
}
}
return nil
}
all, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "")
require.NoError(t, err)
got := find(all)
require.NotNil(t, got, "unrecorded-status install must appear under All")
require.Equal(t, "success", got.Status, "a non-failed_install status is reported as a success")
success, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "success")
require.NoError(t, err)
require.NotNil(t, find(success), "a success shown under All must also appear under status=success")
errored, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter, listOpts(), "error")
require.NoError(t, err)
require.Nil(t, find(errored), "a success must not appear under status=error")
})
t.Run("status filters partition the feed for every activity type", func(t *testing.T) {
// A row is uniquely identified by (activity id, host id) — one activity
// linked to N hosts expands to N rows.
key := func(a *fleet.PolicyAutomationActivity) string {
return fmt.Sprintf("%d-%d", a.ID, a.HostID)
}
fetch := func(status string) map[string]*fleet.PolicyAutomationActivity {
acts, _, err := ds.ListPolicyAutomationActivities(ctx, policy.ID, adminFilter,
listOpts(fleet.ListOptions{PerPage: 1000}), status)
require.NoError(t, err)
m := make(map[string]*fleet.PolicyAutomationActivity, len(acts))
for _, a := range acts {
m[key(a)] = a
}
return m
}
all := fetch("")
errored := fetch("error")
success := fetch("success")
// error and success are disjoint and together reconstruct the full feed.
for k := range errored {
_, inSuccess := success[k]
require.False(t, inSuccess, "row %s appears under both status=error and status=success", k)
}
require.Equal(t, len(all), len(errored)+len(success),
"status=error and status=success must partition the unfiltered feed")
// Every row shown under All lands in exactly the filter matching its
// reported status — no type is dropped by either filter.
for k, a := range all {
_, inErr := errored[k]
_, inSucc := success[k]
require.True(t, inErr || inSucc,
"row %s (type %s, status %q) shown under All is missing from both filters",
k, a.Type, a.Status)
if a.Status == "error" {
require.True(t, inErr, "row %s (type %s) reports error but is absent from status=error", k, a.Type)
} else {
require.True(t, inSucc, "row %s (type %s) reports success but is absent from status=success", k, a.Type)
}
}
// Each task type is represented by both a success and a failure so the
// partition above is exercised for every branch, not just the named ones.
for _, typ := range []string{"ran_script", "installed_software", "installed_app_store_app"} {
var sawErr, sawSucc bool
for _, a := range all {
if a.Type != typ {
continue
}
if a.Status == "error" {
sawErr = true
} else {
sawSucc = true
}
}
require.True(t, sawErr, "expected at least one failed %s", typ)
require.True(t, sawSucc, "expected at least one successful %s", typ)
}
// Named automations: a failed_* type is an error, a ran_automation_* is a success.
var sawNamedErr, sawNamedSucc bool
for _, a := range all {
switch {
case strings.HasPrefix(a.Type, "failed_"):
sawNamedErr = true
require.Equal(t, "error", a.Status, "type %s", a.Type)
case strings.HasPrefix(a.Type, "ran_automation_"):
sawNamedSucc = true
require.Equal(t, "success", a.Status, "type %s", a.Type)
}
}
require.True(t, sawNamedErr, "expected a failed named automation")
require.True(t, sawNamedSucc, "expected a successful named automation")
})
}