Software installers: backend cleanup tasks part 1 (#18955)
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user