From 3579e5a250aa504526c987fc495bbdd651e1fa70 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 14 May 2024 08:37:07 -0400 Subject: [PATCH] Software installers: backend cleanup tasks part 1 (#18955) --- server/datastore/mysql/activities.go | 2 +- server/datastore/mysql/software.go | 33 +- server/datastore/mysql/software_installers.go | 93 +-- server/service/integration_enterprise_test.go | 708 +++++++++++++++++- server/service/integration_mdm_test.go | 617 --------------- server/service/testing_client.go | 13 +- 6 files changed, 759 insertions(+), 707 deletions(-) diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index 8005f995f0..a37ece00d8 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -306,7 +306,7 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint hsi.host_id = :host_id AND hsi.pre_install_query_output IS NULL AND hsi.install_script_exit_code IS NULL - `, softwareInstallerHostStatusNamedQuery("")), + `, softwareInstallerHostStatusNamedQuery("hsi", "")), } seconds := int(scripts.MaxServerWaitTime.Seconds()) diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index d2fbb3e670..4e276f2c50 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -1721,33 +1721,38 @@ func (ds *Datastore) ListCVEs(ctx context.Context, maxAge time.Duration) ([]flee return result, nil } +// tblAlias is the table alias to use as prefix for the host_script_installs +// column names, no prefix used if empty. // colAlias is the name to be assigned to the computed status column, pass // empty to have the value only, no column alias set. -func softwareInstallerHostStatusNamedQuery(colAlias string) string { +func softwareInstallerHostStatusNamedQuery(tblAlias, colAlias string) string { + if tblAlias != "" { + tblAlias += "." + } if colAlias != "" { colAlias = " AS " + colAlias } return fmt.Sprintf(` CASE - WHEN hsi.post_install_script_exit_code IS NOT NULL AND - hsi.post_install_script_exit_code = 0 THEN :software_status_installed + WHEN %[1]spost_install_script_exit_code IS NOT NULL AND + %[1]spost_install_script_exit_code = 0 THEN :software_status_installed - WHEN hsi.post_install_script_exit_code IS NOT NULL AND - hsi.post_install_script_exit_code != 0 THEN :software_status_failed + WHEN %[1]spost_install_script_exit_code IS NOT NULL AND + %[1]spost_install_script_exit_code != 0 THEN :software_status_failed - WHEN hsi.install_script_exit_code IS NOT NULL AND - hsi.install_script_exit_code = 0 THEN :software_status_installed + WHEN %[1]sinstall_script_exit_code IS NOT NULL AND + %[1]sinstall_script_exit_code = 0 THEN :software_status_installed - WHEN hsi.install_script_exit_code IS NOT NULL AND - hsi.install_script_exit_code != 0 THEN :software_status_failed + WHEN %[1]sinstall_script_exit_code IS NOT NULL AND + %[1]sinstall_script_exit_code != 0 THEN :software_status_failed - WHEN hsi.pre_install_query_output IS NOT NULL AND - hsi.pre_install_query_output = '' THEN :software_status_failed + WHEN %[1]spre_install_query_output IS NOT NULL AND + %[1]spre_install_query_output = '' THEN :software_status_failed - WHEN hsi.host_id IS NOT NULL THEN :software_status_pending + WHEN %[1]shost_id IS NOT NULL THEN :software_status_pending ELSE NULL -- not installed from Fleet installer - END %s `, colAlias) + END %[2]s `, tblAlias, colAlias) } func (ds *Datastore) ListHostSoftware(ctx context.Context, hostID uint, includeAvailableForInstall bool, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { @@ -1792,7 +1797,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, hostID uint, includeA ) OR -- or software install has been attempted on host hsi.host_id IS NOT NULL ) -`, softwareInstallerHostStatusNamedQuery("status")) +`, softwareInstallerHostStatusNamedQuery("hsi", "status")) const stmtAvailable = ` SELECT diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 9bef5471bb..8895411e9a 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -197,7 +197,7 @@ SELECT inst.contents AS install_script, COALESCE(pisnt.contents, '') AS post_install_script, COALESCE(st.name, '') AS software_title -FROM +FROM software_installers si LEFT OUTER JOIN software_titles st ON st.id = si.title_id LEFT OUTER JOIN @@ -206,7 +206,7 @@ FROM LEFT OUTER JOIN script_contents pisnt ON pisnt.id = si.post_install_script_content_id -WHERE +WHERE si.title_id = ? AND si.global_or_team_id = ?` var tmID uint @@ -314,7 +314,7 @@ func (ds *Datastore) InsertSoftwareInstallRequest(ctx context.Context, hostID ui } func (ds *Datastore) GetSoftwareInstallResults(ctx context.Context, resultsUUID string) (*fleet.HostSoftwareInstallerResult, error) { - query := ` + query := fmt.Sprintf(` SELECT hsi.execution_id AS execution_id, COALESCE(hsi.pre_install_query_output, '') AS pre_install_query_output, @@ -324,20 +324,7 @@ SELECT h.computer_name AS host_display_name, st.name AS software_title, st.id AS software_title_id, - COALESCE(CASE - WHEN hsi.post_install_script_exit_code IS NOT NULL AND - hsi.post_install_script_exit_code = 0 THEN ? -- installed - WHEN hsi.post_install_script_exit_code IS NOT NULL AND - hsi.post_install_script_exit_code != 0 THEN ? -- failed - WHEN hsi.install_script_exit_code IS NOT NULL AND - hsi.install_script_exit_code = 0 THEN ? -- installed - WHEN hsi.install_script_exit_code IS NOT NULL AND - hsi.install_script_exit_code != 0 THEN ? -- failed - WHEN hsi.pre_install_query_output IS NOT NULL AND - hsi.pre_install_query_output = '' THEN ? -- failed - WHEN hsi.host_id IS NOT NULL THEN ? -- pending - ELSE NULL -- not installed from Fleet installer - END, '') AS status, + COALESCE(%s, '') AS status, si.filename AS software_package, h.team_id AS host_team_id, hsi.user_id AS user_id @@ -347,11 +334,21 @@ FROM JOIN software_installers si ON si.id = hsi.software_installer_id JOIN software_titles st ON si.title_id = st.id WHERE - hsi.execution_id = ? - ` + hsi.execution_id = :execution_id + `, softwareInstallerHostStatusNamedQuery("hsi", "")) + + stmt, args, err := sqlx.Named(query, map[string]any{ + "execution_id": resultsUUID, + "software_status_failed": fleet.SoftwareInstallerFailed, + "software_status_pending": fleet.SoftwareInstallerPending, + "software_status_installed": fleet.SoftwareInstallerInstalled, + }) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "build named query for get software install results") + } var dest fleet.HostSoftwareInstallerResult - err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, fleet.SoftwareInstallerInstalled, fleet.SoftwareInstallerFailed, fleet.SoftwareInstallerInstalled, fleet.SoftwareInstallerFailed, fleet.SoftwareInstallerFailed, fleet.SoftwareInstallerPending, resultsUUID) + err = sqlx.GetContext(ctx, ds.reader(ctx), &dest, stmt, args...) if err != nil { if err == sql.ErrNoRows { return nil, ctxerr.Wrap(ctx, notFound("HostSoftwareInstallerResult"), "get host software installer results") @@ -362,42 +359,18 @@ WHERE return &dest, nil } -func tmplNamedSQLCaseHostSoftwareInstallStatus(alias string) string { - return fmt.Sprintf(` - CASE WHEN %[1]s.post_install_script_exit_code IS NOT NULL - AND %[1]s.post_install_script_exit_code = 0 THEN - :installed - WHEN %[1]s.post_install_script_exit_code IS NOT NULL - AND %[1]s.post_install_script_exit_code != 0 THEN - :failed - WHEN %[1]s.install_script_exit_code IS NOT NULL - AND %[1]s.install_script_exit_code = 0 THEN - :installed - WHEN %[1]s.install_script_exit_code IS NOT NULL - AND %[1]s.install_script_exit_code != 0 THEN - :failed - WHEN %[1]s.pre_install_query_output IS NOT NULL - AND %[1]s.pre_install_query_output = '' THEN - :failed - WHEN %[1]s.host_id IS NOT NULL THEN - :pending - ELSE - NULL -- not installed from Fleet installer - END`, alias) -} - func (ds *Datastore) GetSummaryHostSoftwareInstalls(ctx context.Context, installerID uint) (*fleet.SoftwareInstallerStatusSummary, error) { var dest fleet.SoftwareInstallerStatusSummary stmt := fmt.Sprintf(` SELECT - COALESCE(SUM( IF(status = :pending, 1, 0)), 0) AS pending, - COALESCE(SUM( IF(status = :failed, 1, 0)), 0) AS failed, - COALESCE(SUM( IF(status = :installed, 1, 0)), 0) AS installed + COALESCE(SUM( IF(status = :software_status_pending, 1, 0)), 0) AS pending, + COALESCE(SUM( IF(status = :software_status_failed, 1, 0)), 0) AS failed, + COALESCE(SUM( IF(status = :software_status_installed, 1, 0)), 0) AS installed FROM ( SELECT software_installer_id, - %s AS status + %s FROM host_software_installs hsi WHERE @@ -406,16 +379,16 @@ WHERE SELECT max(id) -- ensure we use only the most recently created install attempt for each host FROM host_software_installs - WHERE + WHERE software_installer_id = :installer_id GROUP BY - host_id)) s`, tmplNamedSQLCaseHostSoftwareInstallStatus("hsi")) + host_id)) s`, softwareInstallerHostStatusNamedQuery("hsi", "status")) query, args, err := sqlx.Named(stmt, map[string]interface{}{ - "installer_id": installerID, - "pending": fleet.SoftwareInstallerPending, - "failed": fleet.SoftwareInstallerFailed, - "installed": fleet.SoftwareInstallerInstalled, + "installer_id": installerID, + "software_status_pending": fleet.SoftwareInstallerPending, + "software_status_failed": fleet.SoftwareInstallerFailed, + "software_status_installed": fleet.SoftwareInstallerInstalled, }) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get summary host software installs: named query") @@ -446,14 +419,14 @@ WHERE GROUP BY host_id, software_installer_id) AND (%s) = :status) hss ON hss.host_id = h.id -`, tmplNamedSQLCaseHostSoftwareInstallStatus("hsi")) +`, softwareInstallerHostStatusNamedQuery("hsi", "")) return sqlx.Named(stmt, map[string]interface{}{ - "status": status, - "installer_id": installerID, - "installed": fleet.SoftwareInstallerInstalled, - "failed": fleet.SoftwareInstallerFailed, - "pending": fleet.SoftwareInstallerPending, + "status": status, + "installer_id": installerID, + "software_status_installed": fleet.SoftwareInstallerInstalled, + "software_status_failed": fleet.SoftwareInstallerFailed, + "software_status_pending": fleet.SoftwareInstallerPending, }) } diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 9e8201b099..0e5e8fd974 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -5,13 +5,16 @@ import ( "context" "database/sql" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" "io" + "mime/multipart" "net/http" "net/http/httptest" "os" + "path/filepath" "reflect" "sort" "strconv" @@ -8679,14 +8682,20 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { ctx := context.Background() t := s.T() - // clean up any software titles from previous tests - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `DELETE FROM software_titles`) - return err - }) - token := "good_token" - host := createHostAndDeviceToken(t, s.ds, token) + host := createOrbitEnrolledHost(t, "linux", "host1", s.ds) + createDeviceTokenForHost(t, s.ds, host.ID, token) + + // no software yet + var getHostSw getHostSoftwareResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw) + require.Len(t, getHostSw.Software, 0) + + var getDeviceSw getDeviceSoftwareResponse + res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK) + err := json.NewDecoder(res.Body).Decode(&getDeviceSw) + require.NoError(t, err) + require.Len(t, getDeviceSw.Software, 0) // create some software for that host software := []fleet.Software{ @@ -8694,20 +8703,20 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { {Name: "foo", Version: "0.0.2", Source: "chrome_extensions"}, {Name: "bar", Version: "0.0.1", Source: "apps"}, } - _, err := s.ds.UpdateHostSoftware(ctx, host.ID, software) + _, err = s.ds.UpdateHostSoftware(ctx, host.ID, software) require.NoError(t, err) err = s.ds.ReconcileSoftwareTitles(ctx) require.NoError(t, err) - var getHostSw getHostSoftwareResponse + getHostSw = getHostSoftwareResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw) require.Len(t, getHostSw.Software, 2) // foo and bar require.Equal(t, getHostSw.Software[0].Name, "bar") require.Equal(t, getHostSw.Software[1].Name, "foo") require.Len(t, getHostSw.Software[1].InstalledVersions, 2) - var getDeviceSw getDeviceSoftwareResponse - res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK) + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK) + getDeviceSw = getDeviceSoftwareResponse{} err = json.NewDecoder(res.Body).Decode(&getDeviceSw) require.NoError(t, err) require.Len(t, getDeviceSw.Software, 2) // foo and bar @@ -8715,19 +8724,694 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Equal(t, getDeviceSw.Software[1].Name, "foo") require.Len(t, getDeviceSw.Software[1].InstalledVersions, 2) + // create a software installer, not installed on the host + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + Filename: "ruby.deb", + } + s.uploadSoftwareInstaller(payload, http.StatusOK, "") + titleID := getSoftwareTitleID(t, s.ds, "ruby", "deb_packages") + + // available installer is returned by user-authenticated endpoint + getHostSw = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw) + require.Len(t, getHostSw.Software, 3) // foo, bar and ruby.deb + require.Equal(t, getHostSw.Software[0].Name, "bar") + require.Equal(t, getHostSw.Software[1].Name, "foo") + require.Equal(t, getHostSw.Software[2].Name, "ruby") + require.Len(t, getHostSw.Software[1].InstalledVersions, 2) + require.NotNil(t, getHostSw.Software[2].PackageAvailableForInstall) + require.Equal(t, "ruby.deb", *getHostSw.Software[2].PackageAvailableForInstall) + require.Nil(t, getHostSw.Software[2].Status) + + // available installer is not returned by device-authenticated endpoint + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK) + getDeviceSw = getDeviceSoftwareResponse{} + err = json.NewDecoder(res.Body).Decode(&getDeviceSw) + require.NoError(t, err) + require.Len(t, getDeviceSw.Software, 2) // foo and bar + require.Equal(t, getDeviceSw.Software[0].Name, "bar") + require.Equal(t, getDeviceSw.Software[1].Name, "foo") + require.Len(t, getDeviceSw.Software[1].InstalledVersions, 2) + require.Nil(t, getDeviceSw.Software[0].PackageAvailableForInstall) + require.Nil(t, getDeviceSw.Software[1].PackageAvailableForInstall) + + // request installation on the host + var installResp installSoftwareResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", + host.ID, titleID), nil, http.StatusAccepted, &installResp) + + // still returned by user-authenticated endpoint, now pending + getHostSw = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw) + require.Len(t, getHostSw.Software, 3) // foo, bar and ruby.deb + require.Equal(t, getHostSw.Software[0].Name, "bar") + require.Equal(t, getHostSw.Software[1].Name, "foo") + require.Equal(t, getHostSw.Software[2].Name, "ruby") + require.Len(t, getHostSw.Software[1].InstalledVersions, 2) + require.NotNil(t, getHostSw.Software[2].PackageAvailableForInstall) + require.Equal(t, "ruby.deb", *getHostSw.Software[2].PackageAvailableForInstall) + require.NotNil(t, getHostSw.Software[2].Status) + require.Equal(t, fleet.SoftwareInstallerPending, *getHostSw.Software[2].Status) + + // now returned by device-authenticated endpoin + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK) + getDeviceSw = getDeviceSoftwareResponse{} + err = json.NewDecoder(res.Body).Decode(&getDeviceSw) + require.NoError(t, err) + require.Len(t, getDeviceSw.Software, 3) // foo, bar and ruby + require.Equal(t, getDeviceSw.Software[0].Name, "bar") + require.Equal(t, getDeviceSw.Software[1].Name, "foo") + require.Equal(t, getDeviceSw.Software[2].Name, "ruby") + require.Len(t, getDeviceSw.Software[1].InstalledVersions, 2) + require.Nil(t, getDeviceSw.Software[2].PackageAvailableForInstall) + require.NotNil(t, getDeviceSw.Software[2].Status) + require.Equal(t, fleet.SoftwareInstallerPending, *getDeviceSw.Software[2].Status) + // test with a query + getHostSw = getHostSoftwareResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "query", "foo") require.Len(t, getHostSw.Software, 1) // foo only require.Equal(t, getHostSw.Software[0].Name, "foo") require.Len(t, getHostSw.Software[0].InstalledVersions, 2) + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?query=bar", nil, http.StatusOK) + getDeviceSw = getDeviceSoftwareResponse{} err = json.NewDecoder(res.Body).Decode(&getDeviceSw) require.NoError(t, err) require.Len(t, getDeviceSw.Software, 1) // bar only require.Equal(t, getDeviceSw.Software[0].Name, "bar") require.Len(t, getDeviceSw.Software[0].InstalledVersions, 1) +} - // TODO(mna): more advanced integration tests with Software Installers once the APIs are in place. +func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndDelete() { + t := s.T() + + openFile := func(name string) *os.File { + f, err := os.Open(filepath.Join("testdata", "software-installers", name)) + require.NoError(t, err) + return f + } + + var expectBytes []byte + var expectLen int + f := openFile("ruby.deb") + st, err := f.Stat() + require.NoError(t, err) + expectLen = int(st.Size()) + require.Equal(t, expectLen, 11340) + expectBytes = make([]byte, expectLen) + n, err := f.Read(expectBytes) + require.NoError(t, err) + require.Equal(t, n, expectLen) + f.Close() + + checkDownloadResponse := func(t *testing.T, r *http.Response, expectedFilename string) { + require.Equal(t, "application/octet-stream", r.Header.Get("Content-Type")) + require.Equal(t, fmt.Sprintf(`attachment;filename="%s"`, expectedFilename), r.Header.Get("Content-Disposition")) + require.NotZero(t, r.ContentLength) + require.Equal(t, expectLen, int(r.ContentLength)) + b, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Equal(t, expectLen, len(b)) + require.Equal(t, expectBytes, b) + } + + checkSoftwareTitle := func(t *testing.T, title string, source string) uint { + var id uint + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(context.Background(), q, &id, `SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''`, title, source) + }) + return id + } + + checkScriptContentsID := func(t *testing.T, id uint, expectedContents string) { + var contents string + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(context.Background(), q, &contents, `SELECT contents FROM script_contents WHERE id = ?`, id) + }) + require.Equal(t, expectedContents, contents) + } + + checkSoftwareInstaller := func(t *testing.T, payload *fleet.UploadSoftwareInstallerPayload) (installerID uint, titleID uint) { + var id uint + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + var tid uint + if payload.TeamID != nil { + tid = *payload.TeamID + } + return sqlx.GetContext(context.Background(), q, &id, `SELECT id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, tid, payload.Filename) + }) + require.NotZero(t, id) + + meta, err := s.ds.GetSoftwareInstallerMetadata(context.Background(), id) + require.NoError(t, err) + + if payload.TeamID != nil { + require.Equal(t, *payload.TeamID, *meta.TeamID) + } else { + require.Nil(t, meta.TeamID) + } + + checkScriptContentsID(t, meta.InstallScriptContentID, payload.InstallScript) + + if payload.PostInstallScript != "" { + require.NotNil(t, meta.PostInstallScriptContentID) + checkScriptContentsID(t, *meta.PostInstallScriptContentID, payload.PostInstallScript) + } else { + require.Nil(t, meta.PostInstallScriptContentID) + } + + require.Equal(t, payload.PreInstallQuery, meta.PreInstallQuery) + require.Equal(t, payload.StorageID, meta.StorageID) + require.Equal(t, payload.Filename, meta.Name) + require.Equal(t, payload.Version, meta.Version) + require.Equal(t, checkSoftwareTitle(t, payload.Title, "deb_packages"), *meta.TitleID) + require.NotZero(t, meta.UploadedAt) + + return meta.InstallerID, *meta.TitleID + } + + t.Run("upload no team software installer", func(t *testing.T) { + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some install script", + PreInstallQuery: "some pre install query", + PostInstallScript: "some post install script", + Filename: "ruby.deb", + // additional fields below are pre-populated so we can re-use the payload later for the test assertions + Title: "ruby", + Version: "1:2.5.1", + Source: "deb_packages", + StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", + } + + s.uploadSoftwareInstaller(payload, http.StatusOK, "") + + // check activity + s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), `{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null}`, 0) + + // check the software installer + _, titleID := checkSoftwareInstaller(t, payload) + + // upload again fails + s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists") + + // download the installer + s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/%d/package?alt=media", titleID), nil, http.StatusBadRequest) + + // delete the installer + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/%d/package", titleID), nil, http.StatusBadRequest) + }) + + t.Run("create team software installer", func(t *testing.T) { + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{ + Name: t.Name(), + }, http.StatusOK, &createTeamResp) + require.NotZero(t, createTeamResp.Team.ID) + + payload := &fleet.UploadSoftwareInstallerPayload{ + TeamID: &createTeamResp.Team.ID, + InstallScript: "another install script", + PreInstallQuery: "another pre install query", + PostInstallScript: "another post install script", + Filename: "ruby.deb", + // additional fields below are pre-populated so we can re-use the payload later for the test assertions + Title: "ruby", + Version: "1:2.5.1", + Source: "deb_packages", + StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", + } + + s.uploadSoftwareInstaller(payload, http.StatusOK, "") + + // check the software installer + installerID, titleID := checkSoftwareInstaller(t, payload) + + // check activity + s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0) + + // upload again fails + s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists") + + // download the installer + r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", *payload.TeamID)) + checkDownloadResponse(t, r, payload.Filename) + + // create an orbit host, assign to team and request to download the installer + host := createOrbitEnrolledHost(t, "windows", "orbit-host-team", s.ds) + require.NoError(t, s.ds.AddHostsToTeam(context.Background(), &createTeamResp.Team.ID, []uint{host.ID})) + r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ + InstallerID: installerID, + OrbitNodeKey: *host.OrbitNodeKey, + }, http.StatusOK) + checkDownloadResponse(t, r, payload.Filename) + + // delete the installer + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/%d/package", titleID), nil, http.StatusNoContent, "team_id", fmt.Sprintf("%d", *payload.TeamID)) + + // check activity + s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0) + }) +} + +func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestPlatformValidation() { + t := s.T() + + hostsByPlatform := map[string]*fleet.Host{ + "linux": nil, "darwin": nil, "windows": nil, + } + + tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{ + Name: t.Name(), + Description: "desc", + }) + require.NoError(t, err) + + for platform := range hostsByPlatform { + h, err := s.ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name() + uuid.New().String()), + NodeKey: ptr.String(t.Name() + uuid.New().String()), + Hostname: fmt.Sprintf("%sfoo.local", t.Name()), + Platform: platform, + }) + require.NoError(t, err) + setOrbitEnrollment(t, h, s.ds) + + err = s.ds.AddHostsToTeam(context.Background(), &tm.ID, []uint{h.ID}) + require.NoError(t, err) + + hostsByPlatform[platform] = h + } + + softwareTitles := map[string]uint{ + "deb": 0, "msi": 0, "exe": 0, "pkg": 0, + } + + for kind := range softwareTitles { + // TODO(roberto): we need real binaries for exe, msi and pkg to + // perform the API calls. + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + ctx := context.Background() + installScript := fmt.Sprintf(`echo '%s'`, kind) + res, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`, installScript, installScript) + if err != nil { + return err + } + scriptContentID, _ := res.LastInsertId() + + res, err = q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES ('foo', ?)`, kind) + if err != nil { + return err + } + titleID, _ := res.LastInsertId() + softwareTitles[kind] = uint(titleID) + + _, err = q.ExecContext(ctx, ` + INSERT INTO software_installers + (title_id, filename, version, install_script_content_id, storage_id, team_id, global_or_team_id, pre_install_query) + VALUES + (?, ?, ?, ?, unhex(?), ?, ?, ?)`, + titleID, fmt.Sprintf("installer.%s", kind), "v1.0.0", scriptContentID, hex.EncodeToString([]byte("test")), tm.ID, tm.ID, "foo") + return err + }) + } + + testCases := []struct { + platform string + supportedInstallers []string + }{ + {"windows", []string{"exe", "msi"}}, + {"darwin", []string{"pkg"}}, + {"linux", []string{"deb"}}, + } + + for _, tc := range testCases { + for platform, host := range hostsByPlatform { + for _, kind := range tc.supportedInstallers { + wantStatus := http.StatusAccepted + if tc.platform != platform { + wantStatus = http.StatusBadRequest + } + + var resp installSoftwareResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", host.ID, softwareTitles[kind]), nil, wantStatus, &resp) + } + } + } +} + +func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { + t := s.T() + + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{ + Name: t.Name(), + }, http.StatusOK, &createTeamResp) + require.NotZero(t, createTeamResp.Team.ID) + teamID := &createTeamResp.Team.ID + + var resp installSoftwareResponse + // non-existent host + s.DoJSON("POST", "/api/latest/fleet/hosts/1/software/install/1", nil, http.StatusNotFound, &resp) + + // create a host that doesn't have fleetd installed + h, err := s.ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name() + uuid.New().String()), + NodeKey: ptr.String(t.Name() + uuid.New().String()), + Hostname: fmt.Sprintf("%sfoo.local", t.Name()), + Platform: "linux", + }) + require.NoError(t, err) + err = s.ds.AddHostsToTeam(context.Background(), teamID, []uint{h.ID}) + require.NoError(t, err) + + // request fails + resp = installSoftwareResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/1", h.ID), nil, http.StatusUnprocessableEntity, &resp) + + // host installs fleetd + setOrbitEnrollment(t, h, s.ds) + // TODO(roberto) setOrbitEnrollment is a helper function that silently + // sets the team_id to NULL. We need to refactor it to accept a + // parameter with an optional team value. + err = s.ds.AddHostsToTeam(context.Background(), teamID, []uint{h.ID}) + require.NoError(t, err) + + // request fails because of non-existent title + resp = installSoftwareResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/1", h.ID), nil, http.StatusBadRequest, &resp) + + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "another install script", + PreInstallQuery: "another pre install query", + PostInstallScript: "another post install script", + Filename: "ruby.deb", + Title: "ruby", + TeamID: teamID, + } + s.uploadSoftwareInstaller(payload, http.StatusOK, "") + + // install script request succeeds + titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages") + resp = installSoftwareResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", h.ID, titleID), nil, http.StatusAccepted, &resp) + + // Get the results, should be pending + getHostSoftwareResp := getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp) + require.Len(t, getHostSoftwareResp.Software, 1) + require.NotNil(t, getHostSoftwareResp.Software[0].LastInstall) + installUUID := getHostSoftwareResp.Software[0].LastInstall.InstallUUID + + gsirr := getSoftwareInstallResultsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/results/%s", installUUID), nil, http.StatusOK, &gsirr) + require.NoError(t, gsirr.Err) + require.NotNil(t, gsirr.Results) + results := gsirr.Results + require.Equal(t, installUUID, results.InstallUUID) + require.Equal(t, fleet.SoftwareInstallerPending, results.Status) + + // status is reflected in software title response + titleResp := getSoftwareTitleResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp, "team_id", strconv.Itoa(int(*teamID))) + // TODO: confirm expected behavior of the title response host counts (unspecified) + require.Zero(t, titleResp.SoftwareTitle.HostsCount) + require.Nil(t, titleResp.SoftwareTitle.CountsUpdatedAt) + require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage) + require.Equal(t, "ruby.deb", titleResp.SoftwareTitle.SoftwarePackage.Name) + require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage.Status) + require.Equal(t, fleet.SoftwareInstallerStatusSummary{ + Installed: 0, + Pending: 1, + Failed: 0, + }, *titleResp.SoftwareTitle.SoftwarePackage.Status) + + // status is reflected in list hosts responses and counts when filtering by software title and status + var listResp listHostsResponse + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "pending", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) + require.Len(t, listResp.Hosts, 1) + require.Equal(t, h.ID, listResp.Hosts[0].ID) + + var countResp countHostsResponse + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) + require.Equal(t, 1, countResp.Count) + + listResp = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "failed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) + require.Len(t, listResp.Hosts, 0) + + countResp = countHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "failed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) + require.Equal(t, 0, countResp.Count) + + listResp = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "installed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) + require.Len(t, listResp.Hosts, 0) + + countResp = countHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "installed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) + require.Equal(t, 0, countResp.Count) + + var labelResp createLabelResponse + s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{ + Name: "test", + Hosts: []string{h.Hostname}, + }}, http.StatusOK, &labelResp) + require.NotZero(t, labelResp.Label.ID) + + listResp = listHostsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", labelResp.Label.ID), nil, http.StatusOK, &listResp) + require.Len(t, listResp.Hosts, 1) + require.Equal(t, h.ID, listResp.Hosts[0].ID) + + countResp = countHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID)), "label_id", strconv.Itoa(int(labelResp.Label.ID))) + require.Equal(t, 1, countResp.Count) + + listResp = listHostsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", labelResp.Label.ID), nil, http.StatusOK, &listResp, "software_status", "pending", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) + require.Len(t, listResp.Hosts, 1) + require.Equal(t, h.ID, listResp.Hosts[0].ID) + + countResp = countHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID)), "label_id", strconv.Itoa(int(labelResp.Label.ID))) + require.Equal(t, 1, countResp.Count) + + listResp = listHostsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", labelResp.Label.ID), nil, http.StatusOK, &listResp, "software_status", "installed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) + require.Len(t, listResp.Hosts, 0) + + countResp = countHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "installed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID)), "label_id", strconv.Itoa(int(labelResp.Label.ID))) + require.Equal(t, 0, countResp.Count) + + listResp = listHostsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", labelResp.Label.ID), nil, http.StatusOK, &listResp, "software_status", "failed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) + require.Len(t, listResp.Hosts, 0) + + countResp = countHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "failed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID)), "label_id", strconv.Itoa(int(labelResp.Label.ID))) + require.Equal(t, 0, countResp.Count) + + // filter validations + r := s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "uninstalled") + require.Contains(t, extractServerErrorText(r.Body), "Invalid software_status") + r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed") + require.Contains(t, extractServerErrorText(r.Body), "Missing software_title_id") + r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "software_title_id", "1") + require.Contains(t, extractServerErrorText(r.Body), "Missing team_id") + r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "team_id", "1") + require.Contains(t, extractServerErrorText(r.Body), "Missing software_title_id") + r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "team_id", "1", "software_title_id", "1", "software_version_id", "1") + require.Contains(t, extractServerErrorText(r.Body), "Invalid parameters. The combination of software_version_id and software_title_id is not allowed.") + r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "team_id", "1", "software_title_id", "1", "software_id", "1") + require.Contains(t, extractServerErrorText(r.Body), "Invalid parameters. The combination of software_id and software_title_id is not allowed.") + + // TODO(roberto): once we have endpoints to retrieve installers, + // request them using the orbit node key + + // TODO(sarah): test other statuses once we have endpoints to set results via orbit +} + +func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { + ctx := context.Background() + t := s.T() + + host := createOrbitEnrolledHost(t, "linux", "", s.ds) + + // create a software installer and some host install requests + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install script", + PreInstallQuery: "pre install query", + PostInstallScript: "post install script", + Filename: "ruby.deb", + Title: "ruby", + } + s.uploadSoftwareInstaller(payload, http.StatusOK, "") + titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages") + + latestInstallUUID := func() string { + var id string + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &id, `SELECT execution_id FROM host_software_installs ORDER BY id DESC LIMIT 1`) + }) + return id + } + + // create some install requests for the host + installUUIDs := make([]string, 3) + for i := 0; i < len(installUUIDs); i++ { + resp := installSoftwareResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/install/%d", host.ID, titleID), nil, http.StatusAccepted, &resp) + installUUIDs[i] = latestInstallUUID() + } + + type result struct { + HostID uint + InstallUUID string + Status fleet.SoftwareInstallerStatus + } + checkResults := func(want result) { + var resp getSoftwareInstallResultsResponse + s.DoJSON("GET", "/api/v1/fleet/software/install/results/"+want.InstallUUID, nil, http.StatusOK, &resp) + + assert.Equal(t, want.HostID, resp.Results.HostID) + assert.Equal(t, want.InstallUUID, resp.Results.InstallUUID) + assert.Equal(t, want.Status, resp.Results.Status) + } + + s.Do("POST", "/api/fleet/orbit/software_install/result", + json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "1", + "install_script_exit_code": 1, + "install_script_output": "failed" + }`, *host.OrbitNodeKey, installUUIDs[0])), + http.StatusNoContent) + checkResults(result{ + HostID: host.ID, + InstallUUID: installUUIDs[0], + Status: fleet.SoftwareInstallerFailed, + }) + wantAct := fleet.ActivityTypeInstalledSoftware{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + SoftwareTitle: payload.Title, + InstallUUID: installUUIDs[0], + Status: string(fleet.SoftwareInstallerFailed), + } + s.lastActivityMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0) + + s.Do("POST", "/api/fleet/orbit/software_install/result", + json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "" + }`, *host.OrbitNodeKey, installUUIDs[1])), + http.StatusNoContent) + checkResults(result{ + HostID: host.ID, + InstallUUID: installUUIDs[1], + Status: fleet.SoftwareInstallerFailed, + }) + wantAct.InstallUUID = installUUIDs[1] + s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0) + + s.Do("POST", "/api/fleet/orbit/software_install/result", + json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "1", + "install_script_exit_code": 0, + "install_script_output": "success", + "post_install_script_exit_code": 0, + "post_install_script_output": "ok" + }`, *host.OrbitNodeKey, installUUIDs[2])), + http.StatusNoContent) + checkResults(result{ + HostID: host.ID, + InstallUUID: installUUIDs[2], + Status: fleet.SoftwareInstallerInstalled, + }) + wantAct.InstallUUID = installUUIDs[2] + wantAct.Status = string(fleet.SoftwareInstallerInstalled) + lastActID := s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0) + + // non-existing installation uuid + s.Do("POST", "/api/fleet/orbit/software_install/result", + json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": "uuid-no-such", + "pre_install_condition_output": "" + }`, *host.OrbitNodeKey)), + http.StatusNotFound) + // no new activity created + s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), lastActID) +} + +func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(payload *fleet.UploadSoftwareInstallerPayload, expectedStatus int, expectedError string) { + t := s.T() + openFile := func(name string) *os.File { + f, err := os.Open(filepath.Join("testdata", "software-installers", name)) + require.NoError(t, err) + return f + } + + f := openFile(payload.Filename) + defer f.Close() + + payload.InstallerFile = f + + var b bytes.Buffer + w := multipart.NewWriter(&b) + + // add the software field + fw, err := w.CreateFormFile("software", payload.Filename) + require.NoError(t, err) + n, err := io.Copy(fw, payload.InstallerFile) + require.NoError(t, err) + require.NotZero(t, n) + + // add the team_id field + if payload.TeamID != nil { + require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", *payload.TeamID))) + } + // add the remaining fields + require.NoError(t, w.WriteField("install_script", payload.InstallScript)) + require.NoError(t, w.WriteField("pre_install_query", payload.PreInstallQuery)) + require.NoError(t, w.WriteField("post_install_script", payload.PostInstallScript)) + + w.Close() + + headers := map[string]string{ + "Content-Type": w.FormDataContentType(), + "Accept": "application/json", + "Authorization": fmt.Sprintf("Bearer %s", s.token), + } + + r := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/package", b.Bytes(), expectedStatus, headers) + if expectedError != "" { + errMsg := extractServerErrorText(r.Body) + require.Contains(t, errMsg, expectedError) + } +} + +func getSoftwareTitleID(t *testing.T, ds *mysql.Datastore, title, source string) uint { + var id uint + mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(context.Background(), q, &id, `SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''`, title, source) + }) + return id } func genDistributedReqWithPolicyResults(host *fleet.Host, policyResults map[uint]*bool) submitDistributedQueryResultsRequestShim { diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index b90426af2e..7dc1ee4fce 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -6,7 +6,6 @@ import ( "crypto/x509" "database/sql" "encoding/base64" - "encoding/hex" "encoding/json" "encoding/xml" "errors" @@ -363,12 +362,6 @@ func (s *integrationMDMTestSuite) TearDownTest() { _, err := tx.ExecContext(ctx, "DELETE FROM host_mdm_apple_declarations") return err }) - - // clear any lingering software installers - mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error { - _, err := tx.ExecContext(ctx, "DELETE FROM software_installers") - return err - }) } func (s *integrationMDMTestSuite) mockDEPResponse(handler http.Handler) { @@ -8466,613 +8459,3 @@ func (s *integrationMDMTestSuite) TestIsServerBitlockerStatus() { require.NotNil(t, hr.Host.MDM.OSSettings.DiskEncryption.Status) require.Equal(t, fleet.DiskEncryptionEnforcing, *hr.Host.MDM.OSSettings.DiskEncryption.Status) } - -func (s *integrationMDMTestSuite) TestSoftwareInstallerUploadDownloadAndDelete() { - t := s.T() - - openFile := func(name string) *os.File { - f, err := os.Open(filepath.Join("testdata", "software-installers", name)) - require.NoError(t, err) - return f - } - - var expectBytes []byte - var expectLen int - f := openFile("ruby.deb") - st, err := f.Stat() - require.NoError(t, err) - expectLen = int(st.Size()) - require.Equal(t, expectLen, 11340) - expectBytes = make([]byte, expectLen) - n, err := f.Read(expectBytes) - require.NoError(t, err) - require.Equal(t, n, expectLen) - f.Close() - - checkDownloadResponse := func(t *testing.T, r *http.Response, expectedFilename string) { - require.Equal(t, "application/octet-stream", r.Header.Get("Content-Type")) - require.Equal(t, fmt.Sprintf(`attachment;filename="%s"`, expectedFilename), r.Header.Get("Content-Disposition")) - require.NotZero(t, r.ContentLength) - require.Equal(t, expectLen, int(r.ContentLength)) - b, err := io.ReadAll(r.Body) - require.NoError(t, err) - require.Equal(t, expectLen, len(b)) - require.Equal(t, expectBytes, b) - } - - checkSoftwareTitle := func(t *testing.T, title string, source string) uint { - var id uint - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(context.Background(), q, &id, `SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''`, title, source) - }) - return id - } - - checkScriptContentsID := func(t *testing.T, id uint, expectedContents string) { - var contents string - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(context.Background(), q, &contents, `SELECT contents FROM script_contents WHERE id = ?`, id) - }) - require.Equal(t, expectedContents, contents) - } - - checkSoftwareInstaller := func(t *testing.T, payload *fleet.UploadSoftwareInstallerPayload) (installerID uint, titleID uint) { - var id uint - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - var tid uint - if payload.TeamID != nil { - tid = *payload.TeamID - } - return sqlx.GetContext(context.Background(), q, &id, `SELECT id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, tid, payload.Filename) - }) - require.NotZero(t, id) - - meta, err := s.ds.GetSoftwareInstallerMetadata(context.Background(), id) - require.NoError(t, err) - - if payload.TeamID != nil { - require.Equal(t, *payload.TeamID, *meta.TeamID) - } else { - require.Nil(t, meta.TeamID) - } - - checkScriptContentsID(t, meta.InstallScriptContentID, payload.InstallScript) - - if payload.PostInstallScript != "" { - require.NotNil(t, meta.PostInstallScriptContentID) - checkScriptContentsID(t, *meta.PostInstallScriptContentID, payload.PostInstallScript) - } else { - require.Nil(t, meta.PostInstallScriptContentID) - } - - require.Equal(t, payload.PreInstallQuery, meta.PreInstallQuery) - require.Equal(t, payload.StorageID, meta.StorageID) - require.Equal(t, payload.Filename, meta.Name) - require.Equal(t, payload.Version, meta.Version) - require.Equal(t, checkSoftwareTitle(t, payload.Title, "deb_packages"), *meta.TitleID) - require.NotZero(t, meta.UploadedAt) - - return meta.InstallerID, *meta.TitleID - } - - t.Run("upload no team software installer", func(t *testing.T) { - payload := &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "some install script", - PreInstallQuery: "some pre install query", - PostInstallScript: "some post install script", - Filename: "ruby.deb", - // additional fields below are pre-populated so we can re-use the payload later for the test assertions - Title: "ruby", - Version: "1:2.5.1", - Source: "deb_packages", - StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", - } - - s.uploadSoftwareInstaller(payload, http.StatusOK, "") - - // check activity - s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), `{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null}`, 0) - - // check the software installer - _, titleID := checkSoftwareInstaller(t, payload) - - // upload again fails - s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists") - - // download the installer - s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/%d/package?alt=media", titleID), nil, http.StatusBadRequest) - - // delete the installer - s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/%d/package", titleID), nil, http.StatusBadRequest) - }) - - t.Run("create team software installer", func(t *testing.T) { - var createTeamResp teamResponse - s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{ - Name: t.Name(), - }, http.StatusOK, &createTeamResp) - require.NotZero(t, createTeamResp.Team.ID) - - payload := &fleet.UploadSoftwareInstallerPayload{ - TeamID: &createTeamResp.Team.ID, - InstallScript: "another install script", - PreInstallQuery: "another pre install query", - PostInstallScript: "another post install script", - Filename: "ruby.deb", - // additional fields below are pre-populated so we can re-use the payload later for the test assertions - Title: "ruby", - Version: "1:2.5.1", - Source: "deb_packages", - StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", - } - - s.uploadSoftwareInstaller(payload, http.StatusOK, "") - - // check the software installer - installerID, titleID := checkSoftwareInstaller(t, payload) - - // check activity - s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0) - - // upload again fails - s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists") - - // download the installer - r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", *payload.TeamID)) - checkDownloadResponse(t, r, payload.Filename) - - // create an orbit host, assign to team and request to download the installer - host := createOrbitEnrolledHost(t, "windows", "orbit-host-team", s.ds) - require.NoError(t, s.ds.AddHostsToTeam(context.Background(), &createTeamResp.Team.ID, []uint{host.ID})) - r = s.Do("POST", "/api/fleet/orbit/software_install/package?alt=media", orbitDownloadSoftwareInstallerRequest{ - InstallerID: installerID, - OrbitNodeKey: *host.OrbitNodeKey, - }, http.StatusOK) - checkDownloadResponse(t, r, payload.Filename) - - // delete the installer - s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/%d/package", titleID), nil, http.StatusNoContent, "team_id", fmt.Sprintf("%d", *payload.TeamID)) - - // check activity - s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0) - }) -} - -func (s *integrationMDMTestSuite) TestSoftwareInstallerNewInstallRequestPlatformValidation() { - t := s.T() - - hostsByPlatform := map[string]*fleet.Host{ - "linux": nil, "darwin": nil, "windows": nil, - } - - tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{ - Name: t.Name(), - Description: "desc", - }) - require.NoError(t, err) - - for platform := range hostsByPlatform { - h, err := s.ds.NewHost(context.Background(), &fleet.Host{ - DetailUpdatedAt: time.Now(), - LabelUpdatedAt: time.Now(), - PolicyUpdatedAt: time.Now(), - SeenTime: time.Now().Add(-1 * time.Minute), - OsqueryHostID: ptr.String(t.Name() + uuid.New().String()), - NodeKey: ptr.String(t.Name() + uuid.New().String()), - Hostname: fmt.Sprintf("%sfoo.local", t.Name()), - Platform: platform, - }) - require.NoError(t, err) - setOrbitEnrollment(t, h, s.ds) - - err = s.ds.AddHostsToTeam(context.Background(), &tm.ID, []uint{h.ID}) - require.NoError(t, err) - - hostsByPlatform[platform] = h - } - - softwareTitles := map[string]uint{ - "deb": 0, "msi": 0, "exe": 0, "pkg": 0, - } - - for kind := range softwareTitles { - // TODO(roberto): we need real binaries for exe, msi and pkg to - // perform the API calls. - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - ctx := context.Background() - installScript := fmt.Sprintf(`echo '%s'`, kind) - res, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`, installScript, installScript) - if err != nil { - return err - } - scriptContentID, _ := res.LastInsertId() - - res, err = q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES ('foo', ?)`, kind) - if err != nil { - return err - } - titleID, _ := res.LastInsertId() - softwareTitles[kind] = uint(titleID) - - _, err = q.ExecContext(ctx, ` - INSERT INTO software_installers - (title_id, filename, version, install_script_content_id, storage_id, team_id, global_or_team_id, pre_install_query) - VALUES - (?, ?, ?, ?, unhex(?), ?, ?, ?)`, - titleID, fmt.Sprintf("installer.%s", kind), "v1.0.0", scriptContentID, hex.EncodeToString([]byte("test")), tm.ID, tm.ID, "foo") - return err - }) - } - - testCases := []struct { - platform string - supportedInstallers []string - }{ - {"windows", []string{"exe", "msi"}}, - {"darwin", []string{"pkg"}}, - {"linux", []string{"deb"}}, - } - - for _, tc := range testCases { - for platform, host := range hostsByPlatform { - for _, kind := range tc.supportedInstallers { - wantStatus := http.StatusAccepted - if tc.platform != platform { - wantStatus = http.StatusBadRequest - } - - var resp installSoftwareResponse - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", host.ID, softwareTitles[kind]), nil, wantStatus, &resp) - } - } - } -} - -func (s *integrationMDMTestSuite) TestSoftwareInstallerHostRequests() { - t := s.T() - - var createTeamResp teamResponse - s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{ - Name: t.Name(), - }, http.StatusOK, &createTeamResp) - require.NotZero(t, createTeamResp.Team.ID) - teamID := &createTeamResp.Team.ID - - var resp installSoftwareResponse - // non-existent host - s.DoJSON("POST", "/api/latest/fleet/hosts/1/software/install/1", nil, http.StatusNotFound, &resp) - - // create a host that doesn't have fleetd installed - h, err := s.ds.NewHost(context.Background(), &fleet.Host{ - DetailUpdatedAt: time.Now(), - LabelUpdatedAt: time.Now(), - PolicyUpdatedAt: time.Now(), - SeenTime: time.Now().Add(-1 * time.Minute), - OsqueryHostID: ptr.String(t.Name() + uuid.New().String()), - NodeKey: ptr.String(t.Name() + uuid.New().String()), - Hostname: fmt.Sprintf("%sfoo.local", t.Name()), - Platform: "linux", - }) - require.NoError(t, err) - err = s.ds.AddHostsToTeam(context.Background(), teamID, []uint{h.ID}) - require.NoError(t, err) - - // request fails - resp = installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/1", h.ID), nil, http.StatusUnprocessableEntity, &resp) - - // host installs fleetd - setOrbitEnrollment(t, h, s.ds) - // TODO(roberto) setOrbitEnrollment is a helper function that silently - // sets the team_id to NULL. We need to refactor it to accept a - // parameter with an optional team value. - err = s.ds.AddHostsToTeam(context.Background(), teamID, []uint{h.ID}) - require.NoError(t, err) - - // request fails because of non-existent title - resp = installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/1", h.ID), nil, http.StatusBadRequest, &resp) - - payload := &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "another install script", - PreInstallQuery: "another pre install query", - PostInstallScript: "another post install script", - Filename: "ruby.deb", - Title: "ruby", - TeamID: teamID, - } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") - - // install script request succeeds - titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages") - resp = installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", h.ID, titleID), nil, http.StatusAccepted, &resp) - - // Get the results, should be pending - getHostSoftwareResp := getHostSoftwareResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp) - require.Len(t, getHostSoftwareResp.Software, 1) - require.NotNil(t, getHostSoftwareResp.Software[0].LastInstall) - installUUID := getHostSoftwareResp.Software[0].LastInstall.InstallUUID - - gsirr := getSoftwareInstallResultsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/results/%s", installUUID), nil, http.StatusOK, &gsirr) - require.NoError(t, gsirr.Err) - require.NotNil(t, gsirr.Results) - results := gsirr.Results - require.Equal(t, installUUID, results.InstallUUID) - require.Equal(t, fleet.SoftwareInstallerPending, results.Status) - - // status is reflected in software title response - titleResp := getSoftwareTitleResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp, "team_id", strconv.Itoa(int(*teamID))) - // TODO: confirm expected behavior of the title response host counts (unspecified) - require.Zero(t, titleResp.SoftwareTitle.HostsCount) - require.Nil(t, titleResp.SoftwareTitle.CountsUpdatedAt) - require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage) - require.Equal(t, "ruby.deb", titleResp.SoftwareTitle.SoftwarePackage.Name) - require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage.Status) - require.Equal(t, fleet.SoftwareInstallerStatusSummary{ - Installed: 0, - Pending: 1, - Failed: 0, - }, *titleResp.SoftwareTitle.SoftwarePackage.Status) - - // status is reflected in list hosts responses and counts when filtering by software title and status - var listResp listHostsResponse - s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "pending", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) - require.Len(t, listResp.Hosts, 1) - require.Equal(t, h.ID, listResp.Hosts[0].ID) - - var countResp countHostsResponse - s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) - require.Equal(t, 1, countResp.Count) - - listResp = listHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "failed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) - require.Len(t, listResp.Hosts, 0) - - countResp = countHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "failed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) - require.Equal(t, 0, countResp.Count) - - listResp = listHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listResp, "software_status", "installed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) - require.Len(t, listResp.Hosts, 0) - - countResp = countHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "installed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) - require.Equal(t, 0, countResp.Count) - - var labelResp createLabelResponse - s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{ - Name: "test", - Hosts: []string{h.Hostname}, - }}, http.StatusOK, &labelResp) - require.NotZero(t, labelResp.Label.ID) - - listResp = listHostsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", labelResp.Label.ID), nil, http.StatusOK, &listResp) - require.Len(t, listResp.Hosts, 1) - require.Equal(t, h.ID, listResp.Hosts[0].ID) - - countResp = countHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID)), "label_id", strconv.Itoa(int(labelResp.Label.ID))) - require.Equal(t, 1, countResp.Count) - - listResp = listHostsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", labelResp.Label.ID), nil, http.StatusOK, &listResp, "software_status", "pending", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) - require.Len(t, listResp.Hosts, 1) - require.Equal(t, h.ID, listResp.Hosts[0].ID) - - countResp = countHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID)), "label_id", strconv.Itoa(int(labelResp.Label.ID))) - require.Equal(t, 1, countResp.Count) - - listResp = listHostsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", labelResp.Label.ID), nil, http.StatusOK, &listResp, "software_status", "installed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) - require.Len(t, listResp.Hosts, 0) - - countResp = countHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "installed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID)), "label_id", strconv.Itoa(int(labelResp.Label.ID))) - require.Equal(t, 0, countResp.Count) - - listResp = listHostsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", labelResp.Label.ID), nil, http.StatusOK, &listResp, "software_status", "failed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID))) - require.Len(t, listResp.Hosts, 0) - - countResp = countHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "failed", "team_id", strconv.Itoa(int(*teamID)), "software_title_id", strconv.Itoa(int(titleID)), "label_id", strconv.Itoa(int(labelResp.Label.ID))) - require.Equal(t, 0, countResp.Count) - - // filter validations - r := s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "uninstalled") - require.Contains(t, extractServerErrorText(r.Body), "Invalid software_status") - r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed") - require.Contains(t, extractServerErrorText(r.Body), "Missing software_title_id") - r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "software_title_id", "1") - require.Contains(t, extractServerErrorText(r.Body), "Missing team_id") - r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "team_id", "1") - require.Contains(t, extractServerErrorText(r.Body), "Missing software_title_id") - r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "team_id", "1", "software_title_id", "1", "software_version_id", "1") - require.Contains(t, extractServerErrorText(r.Body), "Invalid parameters. The combination of software_version_id and software_title_id is not allowed.") - r = s.Do("GET", "/api/latest/fleet/hosts", nil, http.StatusBadRequest, "software_status", "installed", "team_id", "1", "software_title_id", "1", "software_id", "1") - require.Contains(t, extractServerErrorText(r.Body), "Invalid parameters. The combination of software_id and software_title_id is not allowed.") - - // TODO(roberto): once we have endpoints to retrieve installers, - // request them using the orbit node key - - // TODO(sarah): test other statuses once we have endpoints to set results via orbit -} - -func (s *integrationMDMTestSuite) TestHostSoftwareInstallResult() { - ctx := context.Background() - t := s.T() - - host := createOrbitEnrolledHost(t, "linux", "", s.ds) - - // create a software installer and some host install requests - payload := &fleet.UploadSoftwareInstallerPayload{ - InstallScript: "install script", - PreInstallQuery: "pre install query", - PostInstallScript: "post install script", - Filename: "ruby.deb", - Title: "ruby", - } - s.uploadSoftwareInstaller(payload, http.StatusOK, "") - titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages") - - latestInstallUUID := func() string { - var id string - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(ctx, q, &id, `SELECT execution_id FROM host_software_installs ORDER BY id DESC LIMIT 1`) - }) - return id - } - - // create some install requests for the host - installUUIDs := make([]string, 3) - for i := 0; i < len(installUUIDs); i++ { - resp := installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/install/%d", host.ID, titleID), nil, http.StatusAccepted, &resp) - installUUIDs[i] = latestInstallUUID() - } - - type result struct { - HostID uint - InstallUUID string - Status fleet.SoftwareInstallerStatus - } - checkResults := func(want result) { - var resp getSoftwareInstallResultsResponse - s.DoJSON("GET", "/api/v1/fleet/software/install/results/"+want.InstallUUID, nil, http.StatusOK, &resp) - - assert.Equal(t, want.HostID, resp.Results.HostID) - assert.Equal(t, want.InstallUUID, resp.Results.InstallUUID) - assert.Equal(t, want.Status, resp.Results.Status) - } - - s.Do("POST", "/api/fleet/orbit/software_install/result", - json.RawMessage(fmt.Sprintf(`{ - "orbit_node_key": %q, - "install_uuid": %q, - "pre_install_condition_output": "1", - "install_script_exit_code": 1, - "install_script_output": "failed" - }`, *host.OrbitNodeKey, installUUIDs[0])), - http.StatusNoContent) - checkResults(result{ - HostID: host.ID, - InstallUUID: installUUIDs[0], - Status: fleet.SoftwareInstallerFailed, - }) - wantAct := fleet.ActivityTypeInstalledSoftware{ - HostID: host.ID, - HostDisplayName: host.DisplayName(), - SoftwareTitle: payload.Title, - InstallUUID: installUUIDs[0], - Status: string(fleet.SoftwareInstallerFailed), - } - s.lastActivityMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0) - - s.Do("POST", "/api/fleet/orbit/software_install/result", - json.RawMessage(fmt.Sprintf(`{ - "orbit_node_key": %q, - "install_uuid": %q, - "pre_install_condition_output": "" - }`, *host.OrbitNodeKey, installUUIDs[1])), - http.StatusNoContent) - checkResults(result{ - HostID: host.ID, - InstallUUID: installUUIDs[1], - Status: fleet.SoftwareInstallerFailed, - }) - wantAct.InstallUUID = installUUIDs[1] - s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0) - - s.Do("POST", "/api/fleet/orbit/software_install/result", - json.RawMessage(fmt.Sprintf(`{ - "orbit_node_key": %q, - "install_uuid": %q, - "pre_install_condition_output": "1", - "install_script_exit_code": 0, - "install_script_output": "success", - "post_install_script_exit_code": 0, - "post_install_script_output": "ok" - }`, *host.OrbitNodeKey, installUUIDs[2])), - http.StatusNoContent) - checkResults(result{ - HostID: host.ID, - InstallUUID: installUUIDs[2], - Status: fleet.SoftwareInstallerInstalled, - }) - wantAct.InstallUUID = installUUIDs[2] - wantAct.Status = string(fleet.SoftwareInstallerInstalled) - lastActID := s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0) - - // non-existing installation uuid - s.Do("POST", "/api/fleet/orbit/software_install/result", - json.RawMessage(fmt.Sprintf(`{ - "orbit_node_key": %q, - "install_uuid": "uuid-no-such", - "pre_install_condition_output": "" - }`, *host.OrbitNodeKey)), - http.StatusNotFound) - // no new activity created - s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), lastActID) -} - -func (s *integrationMDMTestSuite) uploadSoftwareInstaller(payload *fleet.UploadSoftwareInstallerPayload, expectedStatus int, expectedError string) { - t := s.T() - openFile := func(name string) *os.File { - f, err := os.Open(filepath.Join("testdata", "software-installers", name)) - require.NoError(t, err) - return f - } - - f := openFile(payload.Filename) - defer f.Close() - - payload.InstallerFile = f - - var b bytes.Buffer - w := multipart.NewWriter(&b) - - // add the software field - fw, err := w.CreateFormFile("software", payload.Filename) - require.NoError(t, err) - n, err := io.Copy(fw, payload.InstallerFile) - require.NoError(t, err) - require.NotZero(t, n) - - // add the team_id field - if payload.TeamID != nil { - require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", *payload.TeamID))) - } - // add the remaining fields - require.NoError(t, w.WriteField("install_script", payload.InstallScript)) - require.NoError(t, w.WriteField("pre_install_query", payload.PreInstallQuery)) - require.NoError(t, w.WriteField("post_install_script", payload.PostInstallScript)) - - w.Close() - - headers := map[string]string{ - "Content-Type": w.FormDataContentType(), - "Accept": "application/json", - "Authorization": fmt.Sprintf("Bearer %s", s.token), - } - - r := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/package", b.Bytes(), expectedStatus, headers) - if expectedError != "" { - errMsg := extractServerErrorText(r.Body) - require.Contains(t, errMsg, expectedError) - } -} - -func getSoftwareTitleID(t *testing.T, ds *mysql.Datastore, title, source string) uint { - var id uint - mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(context.Background(), q, &id, `SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''`, title, source) - }) - return id -} diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 7a182f5652..028a452d91 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -118,8 +118,11 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { require.NoError(t, ts.ds.DeleteHost(ctx, host.ID)) } - // recalculate software counts will remove the software entries - require.NoError(t, ts.ds.SyncHostsSoftware(context.Background(), time.Now())) + // clean up any software installers + mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `DELETE FROM software_installers`) + return err + }) lbls, err := ts.ds.ListLabels(ctx, fleet.TeamFilter{}, fleet.ListOptions{}) require.NoError(t, err) @@ -176,9 +179,13 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { require.NoError(t, err) } - // SyncHostsSoftware performs a cleanup. + // Do the software/titles cleanup. err = ts.ds.SyncHostsSoftware(ctx, time.Now()) require.NoError(t, err) + err = ts.ds.ReconcileSoftwareTitles(ctx) + require.NoError(t, err) + err = ts.ds.SyncHostsSoftwareTitles(ctx, time.Now()) + require.NoError(t, err) // delete orphaned scripts mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {