Uninstall packages (#21892)

#20320

# Demo video(s)
- API demo: https://www.loom.com/share/037c82cbde9743cfa42778eb04612482

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] Added/updated BE tests
- [ ] Added/updated FE tests
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [x] Manual BE QA for all new/changed functionality
- [ ] Manual end-to-end QA for all new/changed functionality
This commit is contained in:
Victor Lyuboslavsky 2024-09-12 11:39:41 -05:00 committed by GitHub
commit 8e5d056198
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 3143 additions and 759 deletions

View file

@ -0,0 +1 @@
* Implement the ability to use Fleet to uninstall packages from hosts.

View file

@ -25,3 +25,4 @@ flag_management:
ignore:
- "server/mock"
- "server/fleet/activities.go" # mostly contains code for documentation -- not interesting for tests

View file

@ -1170,6 +1170,29 @@ This activity contains the following fields:
}
```
## uninstalled_software
Generated when a software is uninstalled on a host.
This activity contains the following fields:
- "host_id": ID of the host.
- "host_display_name": Display name of the host.
- "software_title": Name of the software.
- "script_execution_id": ID of the software uninstall script.
- "status": Status of the software uninstallation.
#### Example
```json
{
"host_id": 1,
"host_display_name": "Anna's MacBook Pro",
"software_title": "Falcon.app",
"script_execution_id": "ece8d99d-4313-446a-9af2-e152cd1bad1e",
"status": "uninstalled"
}
```
## added_software
Generated when a software installer is uploaded to Fleet.

View file

@ -12,9 +12,12 @@ import (
"net/http"
"net/url"
"path/filepath"
"regexp"
"strings"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
@ -44,6 +47,7 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
// shebang when the file is directly executed.
payload.InstallScript = file.Dos2UnixNewlines(payload.InstallScript)
payload.PostInstallScript = file.Dos2UnixNewlines(payload.PostInstallScript)
payload.UninstallScript = file.Dos2UnixNewlines(payload.UninstallScript)
if _, err := svc.addMetadataToSoftwarePayload(ctx, payload); err != nil {
return ctxerr.Wrap(ctx, err, "adding metadata to payload")
@ -56,6 +60,9 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
// TODO: basic validation of install and post-install script (e.g., supported interpreters)?
// TODO: any validation of pre-install query?
// Update $PACKAGE_ID in uninstall script
preProcessUninstallScript(payload)
installerID, err := svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload)
if err != nil {
return ctxerr.Wrap(ctx, err, "matching or creating software installer")
@ -87,6 +94,28 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
return nil
}
var packageIDRegex = regexp.MustCompile(`((("\$PACKAGE_ID")|(\$PACKAGE_ID))(?P<suffix>\W|$))|(("\${PACKAGE_ID}")|(\${PACKAGE_ID}))`)
func preProcessUninstallScript(payload *fleet.UploadSoftwareInstallerPayload) {
// We assume that we already validated that payload.PackageIDs is not empty.
// Replace $PACKAGE_ID in the uninstall script with the package ID(s).
var packageID string
switch payload.Extension {
case "pkg":
var sb strings.Builder
_, _ = sb.WriteString("(\n")
for _, pkgID := range payload.PackageIDs {
_, _ = sb.WriteString(fmt.Sprintf(" \"%s\"\n", pkgID))
}
_, _ = sb.WriteString(")") // no ending newline
packageID = sb.String()
default:
packageID = fmt.Sprintf("\"%s\"", payload.PackageIDs[0])
}
payload.UninstallScript = packageIDRegex.ReplaceAllString(payload.UninstallScript, fmt.Sprintf("%s${suffix}", packageID))
}
func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error {
if teamID == nil {
return fleet.NewInvalidArgumentError("team_id", "is required")
@ -389,11 +418,12 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
if err != nil {
return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID)
}
if lastInstallRequest != nil && lastInstallRequest.Status != nil && *lastInstallRequest.Status == fleet.SoftwareInstallerPending {
if lastInstallRequest != nil && lastInstallRequest.Status != nil &&
(*lastInstallRequest.Status == fleet.SoftwareInstallPending || *lastInstallRequest.Status == fleet.SoftwareUninstallPending) {
return &fleet.BadRequestError{
Message: "Couldn't install software. Host has a pending install request.",
Message: "Couldn't install software. Host has a pending install/uninstall request.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "host already has a pending install for this installer",
ctx, err, "host already has a pending install/uninstall for this installer",
map[string]any{
"host_id": host.ID,
"software_installer_id": installer.InstallerID,
@ -568,6 +598,152 @@ func (svc *Service) installSoftwareTitleUsingInstaller(ctx context.Context, host
return ctxerr.Wrap(ctx, err, "inserting software install request")
}
func (svc *Service) UninstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error {
// First check if scripts are disabled globally. If so, no need for further processing.
cfg, err := svc.ds.AppConfig(ctx)
if err != nil {
svc.authz.SkipAuthorization(ctx)
return err
}
if cfg.ServerSettings.ScriptsDisabled {
svc.authz.SkipAuthorization(ctx)
return fleet.NewUserMessageError(errors.New(fleet.RunScriptScriptsDisabledGloballyErrMsg), http.StatusForbidden)
}
// we need to use ds.Host because ds.HostLite doesn't return the orbit node key
host, err := svc.ds.Host(ctx, hostID)
if err != nil {
// if error is because the host does not exist, check first if the user
// had access to install/uninstall software (to prevent leaking valid host ids).
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionWrite); err != nil {
return err
}
}
svc.authz.SkipAuthorization(ctx)
return ctxerr.Wrap(ctx, err, "get host")
}
if host.OrbitNodeKey == nil || *host.OrbitNodeKey == "" {
// fleetd is required to install software so if the host is enrolled via plain osquery we return an error
svc.authz.SkipAuthorization(ctx)
return fleet.NewUserMessageError(errors.New("host does not have fleetd installed"), http.StatusUnprocessableEntity)
}
// If scripts are disabled (according to the last detail query), we return an error.
// host.ScriptsEnabled may be nil for older orbit versions.
if host.ScriptsEnabled != nil && !*host.ScriptsEnabled {
svc.authz.SkipAuthorization(ctx)
return fleet.NewUserMessageError(errors.New(fleet.RunScriptsOrbitDisabledErrMsg), http.StatusUnprocessableEntity)
}
// authorize with the host's team
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: host.TeamID}, fleet.ActionWrite); err != nil {
return err
}
installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false)
if err != nil {
if fleet.IsNotFound(err) {
return &fleet.BadRequestError{
Message: "Couldn't uninstall software. Software title is not available for uninstall. Please add software package to install/uninstall.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "couldn't find an installer for software title",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
return ctxerr.Wrap(ctx, err, "finding software installer for title")
}
lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID)
}
if lastInstallRequest != nil && lastInstallRequest.Status != nil &&
(*lastInstallRequest.Status == fleet.SoftwareInstallPending || *lastInstallRequest.Status == fleet.SoftwareUninstallPending) {
return &fleet.BadRequestError{
Message: "Couldn't uninstall software. Host has a pending install/uninstall request.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "host already has a pending install/uninstall for this installer",
map[string]any{
"host_id": host.ID,
"software_installer_id": installer.InstallerID,
"team_id": host.TeamID,
"title_id": softwareTitleID,
"status": *lastInstallRequest.Status,
},
),
}
}
// Validate platform
ext := filepath.Ext(installer.Name)
requiredPlatform := packageExtensionToPlatform(ext)
if requiredPlatform == "" {
// this should never happen
return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext)
}
if host.FleetPlatform() != requiredPlatform {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Package (%s) can be uninstalled only on %s hosts.", ext, requiredPlatform),
InternalErr: ctxerr.NewWithData(
ctx, "invalid host platform for requested uninstall",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": installer.TitleID},
),
}
}
// Get the uninstall script and use the standard script infrastructure to run it.
contents, err := svc.ds.GetAnyScriptContents(ctx, installer.UninstallScriptContentID)
if err != nil {
if fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError("software_title_id", `No uninstall script exists for the provided "software_title_id".`).
WithStatus(http.StatusNotFound), "getting uninstall script contents")
}
return err
}
var teamID uint
if host.TeamID != nil {
teamID = *host.TeamID
}
// create the script execution request, the host will be notified of the
// script execution request via the orbit config's Notifications mechanism.
request := fleet.HostScriptRequestPayload{
HostID: host.ID,
ScriptContents: string(contents),
ScriptContentID: installer.UninstallScriptContentID,
TeamID: teamID,
}
if ctxUser := authz.UserFromContext(ctx); ctxUser != nil {
request.UserID = &ctxUser.ID
}
scriptResult, err := svc.ds.NewHostScriptExecutionRequest(ctx, &request)
if err != nil {
return ctxerr.Wrap(ctx, err, "create script execution request")
}
// Update the host software installs table with the uninstall request.
// Pending uninstalls will automatically show up in the UI Host Details -> Activity -> Upcoming tab.
if err = svc.insertSoftwareUninstallRequest(ctx, scriptResult.ExecutionID, host, installer); err != nil {
return err
}
return nil
}
func (svc *Service) insertSoftwareUninstallRequest(ctx context.Context, executionID string, host *fleet.Host,
installer *fleet.SoftwareInstaller) error {
if err := svc.ds.InsertSoftwareUninstallRequest(ctx, executionID, host.ID, installer.InstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "inserting software uninstall request")
}
return nil
}
func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID string) (*fleet.HostSoftwareInstallerResult, error) {
// Basic auth check
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
@ -656,6 +832,13 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f
}
}
if len(meta.PackageIDs) == 0 {
return "", &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add. Fleet couldn't read the package IDs, product code, or name from %s.", payload.Filename),
InternalErr: ctxerr.New(ctx, "extracting package IDs from installer metadata"),
}
}
payload.Title = meta.Name
if payload.Title == "" {
// use the filename if no title from metadata
@ -664,6 +847,8 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f
payload.Version = meta.Version
payload.StorageID = hex.EncodeToString(meta.SHASum)
payload.BundleIdentifier = meta.BundleIdentifier
payload.PackageIDs = meta.PackageIDs
payload.Extension = meta.Extension
// reset the reader (it was consumed to extract metadata)
if _, err := payload.InstallerFile.Seek(0, 0); err != nil {
@ -674,6 +859,10 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f
payload.InstallScript = file.GetInstallScript(meta.Extension)
}
if payload.UninstallScript == "" {
payload.UninstallScript = file.GetUninstallScript(meta.Extension)
}
source, err := fleet.SofwareInstallerSourceFromExtensionAndName(meta.Extension, meta.Name)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "determining source from extension and name")

View file

@ -0,0 +1,223 @@
package service
import (
"context"
"testing"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPreProcessUninstallScript(t *testing.T) {
t.Parallel()
var input = `
blah$PACKAGE_IDS
pkgids=$PACKAGE_ID
they are $PACKAGE_ID, right $MY_SECRET?
quotes for "$PACKAGE_ID"
blah${PACKAGE_ID}withConcat
quotes and braces for "${PACKAGE_ID}"
${PACKAGE_ID}`
payload := fleet.UploadSoftwareInstallerPayload{
Extension: "exe",
UninstallScript: input,
PackageIDs: []string{"com.foo"},
}
preProcessUninstallScript(&payload)
expected := `
blah$PACKAGE_IDS
pkgids="com.foo"
they are "com.foo", right $MY_SECRET?
quotes for "com.foo"
blah"com.foo"withConcat
quotes and braces for "com.foo"
"com.foo"`
assert.Equal(t, expected, payload.UninstallScript)
payload = fleet.UploadSoftwareInstallerPayload{
Extension: "pkg",
UninstallScript: input,
PackageIDs: []string{"com.foo", "com.bar"},
}
preProcessUninstallScript(&payload)
expected = `
blah$PACKAGE_IDS
pkgids=(
"com.foo"
"com.bar"
)
they are (
"com.foo"
"com.bar"
), right $MY_SECRET?
quotes for (
"com.foo"
"com.bar"
)
blah(
"com.foo"
"com.bar"
)withConcat
quotes and braces for (
"com.foo"
"com.bar"
)
(
"com.foo"
"com.bar"
)`
assert.Equal(t, expected, payload.UninstallScript)
}
func TestInstallUninstallAuth(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
svc := newTestService(t, ds)
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
return &fleet.Host{
OrbitNodeKey: ptr.String("orbit_key"),
Platform: "darwin",
TeamID: ptr.Uint(1),
}, nil
}
ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint,
withScriptContents bool) (*fleet.SoftwareInstaller, error) {
return &fleet.SoftwareInstaller{
Name: "installer.pkg",
Platform: "darwin",
TeamID: ptr.Uint(1),
}, nil
}
ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error) {
return nil, nil
}
ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string,
error) {
return "request_id", nil
}
ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) {
return []byte("script"), nil
}
ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult,
error) {
return &fleet.HostScriptResult{
ExecutionID: "execution_id",
}, nil
}
ds.InsertSoftwareUninstallRequestFunc = func(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint) error {
return nil
}
testCases := []struct {
name string
user *fleet.User
shouldFail bool
}{
{
"global admin",
&fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
false,
},
{
"global maintainer",
&fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
false,
},
{
"global observer",
&fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
true,
},
{
"team admin",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
false,
},
{
"team maintainer",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
false,
},
{
"team observer",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: tt.user})
checkAuthErr(t, tt.shouldFail, svc.InstallSoftwareTitle(ctx, 1, 10))
checkAuthErr(t, tt.shouldFail, svc.UninstallSoftwareTitle(ctx, 1, 10))
})
}
}
// TestUninstallSoftwareTitle is mostly tested in enterprise integration test. This test hits a few edge cases.
func TestUninstallSoftwareTitle(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
svc := newTestService(t, ds)
host := &fleet.Host{
OrbitNodeKey: ptr.String("orbit_key"),
Platform: "darwin",
TeamID: ptr.Uint(1),
}
ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
return host, nil
}
// Scripts disabled
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
ServerSettings: fleet.ServerSettings{
ScriptsDisabled: true,
},
}, nil
}
require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptScriptsDisabledGloballyErrMsg)
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
// Host scripts disabled
host.ScriptsEnabled = ptr.Bool(false)
require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptsOrbitDisabledErrMsg)
}
func checkAuthErr(t *testing.T, shouldFail bool, err error) {
t.Helper()
if shouldFail {
require.Error(t, err)
var forbiddenError *authz.Forbidden
require.ErrorAs(t, err, &forbiddenError)
} else {
require.NoError(t, err)
}
}
func newTestService(t *testing.T, ds fleet.Datastore) *Service {
t.Helper()
authorizer, err := authz.NewAuthorizer()
require.NoError(t, err)
svc := &Service{
authz: authorizer,
ds: ds,
}
return svc
}

View file

@ -24,6 +24,7 @@ const DEFAULT_SCRIPT_RESULT_MOCK: IScriptResultResponse = {
runtime: 0,
host_timeout: false,
script_id: 1,
created_at: "2020-01-01T00:00:00.000Z",
};
export const createMockScriptResult = (

View file

@ -204,8 +204,10 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
icon_url: null,
status: {
installed: 1,
pending: 2,
failed: 3,
pending_install: 2,
failed_install: 1,
pending_uninstall: 1,
failed_uninstall: 1,
},
};

View file

@ -1,5 +1,6 @@
import React from "react";
import { useQuery } from "react-query";
import { formatDistanceToNow } from "date-fns";
import { IActivityDetails } from "interfaces/activity";
import {
@ -29,7 +30,14 @@ export type IPackageInstallDetails = Pick<
>;
const StatusMessage = ({
result: { host_display_name, software_package, software_title, status },
result: {
host_display_name,
software_package,
software_title,
status,
updated_at,
created_at,
},
}: {
result: ISoftwareInstallResult;
}) => {
@ -38,13 +46,24 @@ const StatusMessage = ({
) : (
"the host"
);
const timeStamp = updated_at || created_at;
const displayTimeStamp = ["failed_install", "installed"].includes(
status || ""
)
? ` (${formatDistanceToNow(new Date(timeStamp), {
includeSeconds: true,
addSuffix: true,
})})`
: "";
return (
<div className={`${baseClass}__status-message`}>
<Icon name={INSTALL_DETAILS_STATUS_ICONS[status]} />
<span>
Fleet {getInstallDetailsStatusPredicate(status)} <b>{software_title}</b>{" "}
({software_package}) on {formattedHost}
{status === "pending" ? " when it comes online" : ""}.
{status === "pending_install" ? " when it comes online" : ""}
{displayTimeStamp}.
</span>
</div>
);
@ -104,7 +123,7 @@ export const SoftwareInstallDetails = ({
result.host_display_name ? result : { ...result, host_display_name } // prefer result.host_display_name (it may be empty if the host was deleted) otherwise default to whatever we received via props
}
/>
{result.status !== "pending" && (
{result.status !== "pending_install" && (
<>
{result.pre_install_query_output && (
<Output displayKey="pre_install_query_output" result={result} />

View file

@ -0,0 +1,157 @@
import Button from "components/buttons/Button";
import DataError from "components/DataError";
import Icon from "components/Icon";
import Modal from "components/Modal";
import Spinner from "components/Spinner";
import Textarea from "components/Textarea";
import { formatDistanceToNow } from "date-fns";
import { IActivityDetails } from "interfaces/activity";
import { isPendingStatus, SoftwareInstallStatus } from "interfaces/software";
import React from "react";
import { useQuery } from "react-query";
import scriptsAPI, { IScriptResultResponse } from "services/entities/scripts";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import {
getInstallDetailsStatusPredicate,
INSTALL_DETAILS_STATUS_ICONS,
} from "../constants";
const baseClass = "software-uninstall-details-modal";
type ISoftwareUninstallDetails = Pick<
IActivityDetails,
"script_execution_id" | "host_display_name" | "software_title" | "status"
>;
// TODO - rely on activity created_at for timestamp? what else?
interface IUninstallStatusMessage {
host_display_name: string;
// TODO - improve status typing
status: string;
software_title: string;
timestamp: string;
}
const StatusMessage = ({
host_display_name,
status,
software_title,
timestamp,
}: IUninstallStatusMessage) => {
const formattedHost = host_display_name ? (
<b>{host_display_name}</b>
) : (
"the host"
);
const isPending = isPendingStatus(status);
const displayTimeStamp =
!isPending && timestamp
? ` (${formatDistanceToNow(new Date(timestamp), {
includeSeconds: true,
addSuffix: true,
})})`
: "";
return (
<div className={`${baseClass}__status-message`}>
<Icon
name={INSTALL_DETAILS_STATUS_ICONS[status as SoftwareInstallStatus]}
/>
<span>
Fleet {getInstallDetailsStatusPredicate(status)} <b>{software_title}</b>{" "}
from {formattedHost}
{isPending ? " when it comes online" : ""}
{displayTimeStamp}.
</span>
</div>
);
};
const SoftwareUninstallDetailsModal = ({
details,
onCancel,
}: {
details: ISoftwareUninstallDetails;
onCancel: () => void;
}) => {
const SoftwareUninstallDetails = ({
script_execution_id = "",
host_display_name = "",
software_title = "",
status = "",
}: ISoftwareUninstallDetails) => {
const {
data: scriptResult,
isLoading,
isError,
} = useQuery<IScriptResultResponse>(
["uninstallResult", details.script_execution_id],
() => {
return scriptsAPI.getScriptResult(script_execution_id);
},
{ ...DEFAULT_USE_QUERY_OPTIONS }
);
if (isLoading) {
return <Spinner />;
} else if (isError) {
return <DataError description="Close this modal and try again." />;
} else if (!scriptResult) {
// FIXME: Find a better solution for this.
return <DataError description="No data returned." />;
}
status = status === "failed" ? "failed_uninstall" : status;
return (
<>
<StatusMessage
host_display_name={host_display_name}
status={status}
software_title={software_title}
timestamp={scriptResult.created_at}
/>
{!isPendingStatus(status) && scriptResult && (
<>
<div className={`${baseClass}__script-output`}>
Uninstall script content:
<Textarea className={`${baseClass}__output-textarea`}>
{scriptResult.script_contents}
</Textarea>
</div>
<div className={`${baseClass}__script-output`}>
Uninstall script output:
<Textarea className={`${baseClass}__output-textarea`}>
{scriptResult.output}
</Textarea>
</div>
</>
)}
</>
);
};
return (
<Modal
title="Uninstall details"
onExit={onCancel}
onEnter={onCancel}
width="large"
className={baseClass}
>
<>
<div className={`${baseClass}__modal-content`}>
<SoftwareUninstallDetails {...details} />
</div>
<div className="modal-cta-wrap">
<Button onClick={onCancel} variant="brand">
Done
</Button>
</div>
</>
</Modal>
);
};
export default SoftwareUninstallDetailsModal;

View file

@ -0,0 +1,23 @@
.software-uninstall-details-modal {
&__modal-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
&__status-message {
display: flex;
align-items: center;
gap: $pad-small;
margin: 0;
.icon {
align-self: flex-start;
}
}
&__script-output {
.textarea {
margin-top: $pad-medium;
overflow-wrap: break-word;
font-family: "SourceCodePro", $monospace;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./SoftwareUninstallDetailsModal";

View file

@ -5,30 +5,36 @@ export const INSTALL_DETAILS_STATUS_ICONS: Record<
SoftwareInstallStatus,
IconNames
> = {
pending: "pending-outline",
pending_install: "pending-outline",
installed: "success-outline",
failed: "error-outline",
uninstalled: "success-outline",
failed_install: "error-outline",
pending_uninstall: "pending-outline",
failed_uninstall: "error-outline",
} as const;
const INSTALL_DETAILS_STATUS_PREDICATES: Record<
SoftwareInstallStatus,
string
> = {
pending: "is installing or will install",
pending_install: "is installing or will install",
installed: "installed",
failed: "failed to install",
uninstalled: "uninstalled",
failed_install: "failed to install",
pending_uninstall: "is uninstalling or will uninstall",
failed_uninstall: "failed to uninstall",
} as const;
export const getInstallDetailsStatusPredicate = (
status: string | undefined
) => {
if (!status) {
return INSTALL_DETAILS_STATUS_PREDICATES.pending;
return INSTALL_DETAILS_STATUS_PREDICATES.pending_install;
}
return (
INSTALL_DETAILS_STATUS_PREDICATES[
status.toLowerCase() as SoftwareInstallStatus
] || INSTALL_DETAILS_STATUS_PREDICATES.pending
] || INSTALL_DETAILS_STATUS_PREDICATES.pending_install
);
};

View file

@ -13,6 +13,7 @@ export interface IRevealButtonProps {
autofocus?: boolean;
disabled?: boolean;
tooltipContent?: React.ReactNode;
disabledTooltipContent?: React.ReactNode;
onClick?:
| ((value?: any) => void)
| ((evt: React.MouseEvent<HTMLButtonElement>) => void);
@ -29,6 +30,7 @@ const RevealButton = ({
autofocus,
disabled,
tooltipContent,
disabledTooltipContent,
onClick,
}: IRevealButtonProps): JSX.Element => {
const classNames = classnames(baseClass, className);
@ -36,11 +38,12 @@ const RevealButton = ({
const buttonContent = () => {
const text = isShowing ? hideText : showText;
const buttonText = tooltipContent ? (
<TooltipWrapper tipContent={tooltipContent}>{text}</TooltipWrapper>
) : (
text
);
const buttonText =
tooltipContent && !disabled ? (
<TooltipWrapper tipContent={tooltipContent}>{text}</TooltipWrapper>
) : (
text
);
return (
<>
@ -61,7 +64,7 @@ const RevealButton = ({
);
};
return (
const button = (
<Button
variant="text-icon"
className={classNames}
@ -72,6 +75,22 @@ const RevealButton = ({
{buttonContent()}
</Button>
);
if (disabled && disabledTooltipContent) {
// wrap the tooltip around the Button so it works while disabled
return (
<TooltipWrapper
tipContent={disabledTooltipContent}
showArrow
underline={false}
position="right"
tipOffset={12}
>
{button}
</TooltipWrapper>
);
}
return button;
};
export default RevealButton;

View file

@ -80,6 +80,7 @@ export enum ActivityType {
AddedSoftware = "added_software",
DeletedSoftware = "deleted_software",
InstalledSoftware = "installed_software",
UninstalledSoftware = "uninstalled_software",
EnabledVpp = "enabled_vpp",
DisabledVpp = "disabled_vpp",
AddedAppStoreApp = "added_app_store_app",
@ -93,12 +94,14 @@ export type IHostPastActivityType =
| ActivityType.LockedHost
| ActivityType.UnlockedHost
| ActivityType.InstalledSoftware
| ActivityType.UninstalledSoftware
| ActivityType.InstalledAppStoreApp;
// This is a subset of ActivityType that are shown only for the host upcoming activities
export type IHostUpcomingActivityType =
| ActivityType.RanScript
| ActivityType.InstalledSoftware
| ActivityType.UninstalledSoftware
| ActivityType.InstalledAppStoreApp;
export interface IActivity {

View file

@ -0,0 +1,22 @@
const unixPackageTypes = ["pkg", "deb"] as const;
const windowsPackageTypes = ["msi", "exe"] as const;
export const packageTypes = [
...unixPackageTypes,
...windowsPackageTypes,
] as const;
export type WindowsPackageType = typeof windowsPackageTypes[number];
export type UnixPackageType = typeof unixPackageTypes[number];
export type PackageType = WindowsPackageType | UnixPackageType;
export const isWindowsPackageType = (s: any): s is WindowsPackageType => {
return windowsPackageTypes.includes(s);
};
export const isUnixPackageType = (s: any): s is UnixPackageType => {
return unixPackageTypes.includes(s);
};
export const isPackageType = (s: any): s is PackageType => {
return packageTypes.includes(s);
};

View file

@ -66,8 +66,10 @@ export interface ISoftwarePackage {
icon_url: string | null;
status: {
installed: number;
pending: number;
failed: number;
pending_install: number;
failed_install: number;
pending_uninstall: number;
failed_uninstall: number;
};
}
@ -194,10 +196,19 @@ export const formatSoftwareType = ({
/**
* This list comprises all possible states of software install operations.
*/
export const SOFTWARE_UNINSTALL_STATUSES = [
"uninstalled",
"pending_uninstall",
"failed_uninstall",
] as const;
export type SoftwareUninstallStatus = typeof SOFTWARE_UNINSTALL_STATUSES[number];
export const SOFTWARE_INSTALL_STATUSES = [
"failed",
"installed",
"pending",
"pending_install",
"failed_install",
...SOFTWARE_UNINSTALL_STATUSES,
] as const;
/*
@ -206,26 +217,38 @@ export const SOFTWARE_INSTALL_STATUSES = [
export type SoftwareInstallStatus = typeof SOFTWARE_INSTALL_STATUSES[number];
export const isValidSoftwareInstallStatus = (
s: string | undefined
s: string | undefined | null
): s is SoftwareInstallStatus =>
!!s && SOFTWARE_INSTALL_STATUSES.includes(s as SoftwareInstallStatus);
export const isSoftwareUninstallStatus = (
s: string | undefined | null
): s is SoftwareUninstallStatus =>
!!s && SOFTWARE_UNINSTALL_STATUSES.includes(s as SoftwareUninstallStatus);
// not a typeguard, as above 2 functions are
export const isPendingStatus = (s: string | undefined | null) =>
["pending_install", "pending_uninstall"].includes(s || "");
/**
* ISoftwareInstallResult is the shape of a software install result object
* returned by the Fleet API.
*/
export interface ISoftwareInstallResult {
host_display_name?: string;
install_uuid: string;
software_title: string;
software_title_id: number;
software_package: string;
host_id: number;
host_display_name: string;
status: SoftwareInstallStatus;
detail: string;
output: string;
pre_install_query_output: string;
post_install_script_output: string;
created_at: string;
updated_at: string | null;
self_service: boolean;
}
export interface ISoftwareInstallResults {
@ -276,16 +299,23 @@ export interface IHostSoftware {
app_store_app: IHostAppStoreApp | null;
source: string;
bundle_identifier?: string;
status: SoftwareInstallStatus | null;
status: Exclude<SoftwareInstallStatus, "uninstalled"> | null;
installed_versions: ISoftwareInstallVersion[] | null;
}
export type IDeviceSoftware = IHostSoftware;
const INSTALL_STATUS_PREDICATES: Record<SoftwareInstallStatus, string> = {
failed: "failed to install",
const INSTALL_STATUS_PREDICATES: Record<
SoftwareInstallStatus | "pending",
string
> = {
pending: "pending",
installed: "installed",
pending: "told Fleet to install",
uninstalled: "uninstalled",
pending_install: "told Fleet to install",
failed_install: "failed to install",
pending_uninstall: "told Fleet to uninstall",
failed_uninstall: "failed to uninstall",
} as const;
export const getInstallStatusPredicate = (status: string | undefined) => {
@ -298,10 +328,18 @@ export const getInstallStatusPredicate = (status: string | undefined) => {
);
};
export const INSTALL_STATUS_ICONS: Record<SoftwareInstallStatus, IconNames> = {
export const INSTALL_STATUS_ICONS: Record<
SoftwareInstallStatus | "pending" | "failed",
IconNames
> = {
pending: "pending-outline",
pending_install: "pending-outline",
installed: "success-outline",
uninstalled: "success-outline",
failed: "error-outline",
failed_install: "error-outline",
pending_uninstall: "pending-outline",
failed_uninstall: "error-outline",
} as const;
type IHostSoftwarePackageWithLastInstall = IHostSoftwarePackage & {

View file

@ -18,6 +18,7 @@ import FleetIcon from "components/icons/FleetIcon";
import { AppInstallDetailsModal } from "components/ActivityDetails/InstallDetails/AppInstallDetails";
import { SoftwareInstallDetailsModal } from "components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails";
import SoftwareUninstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal";
import ActivityItem from "./ActivityItem";
import ScriptDetailsModal from "./components/ScriptDetailsModal/ScriptDetailsModal";
@ -41,6 +42,10 @@ const ActivityFeed = ({
packageInstallDetails,
setPackageInstallDetails,
] = useState<IActivityDetails | null>(null);
const [
packageUninstallDetails,
setPackageUninstallDetails,
] = useState<IActivityDetails | null>(null);
const [
appInstallDetails,
setAppInstallDetails,
@ -106,6 +111,9 @@ const ActivityFeed = ({
case ActivityType.InstalledSoftware:
setPackageInstallDetails({ ...details });
break;
case ActivityType.UninstalledSoftware:
setPackageUninstallDetails({ ...details });
break;
case ActivityType.InstalledAppStoreApp:
setAppInstallDetails({ ...details });
break;
@ -205,6 +213,12 @@ const ActivityFeed = ({
onCancel={() => setPackageInstallDetails(null)}
/>
)}
{packageUninstallDetails && (
<SoftwareUninstallDetailsModal
details={packageUninstallDetails}
onCancel={() => setPackageUninstallDetails(null)}
/>
)}
{appInstallDetails && (
<AppInstallDetailsModal
details={appInstallDetails}

View file

@ -907,6 +907,40 @@ const TAGGED_TEMPLATES = {
</>
);
},
uninstalledSoftware: (
activity: IActivity,
onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void
) => {
const { details } = activity;
if (!details) {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
}
const { host_display_name: hostName, software_title: title } = details;
const status =
details.status === "failed" ? "failed_uninstall" : details.status;
const showSoftwarePackage =
!!details.software_package &&
activity.type === ActivityType.InstalledSoftware;
return (
<>
{" "}
{getInstallStatusPredicate(status)} software <b>{title}</b>
{showSoftwarePackage && ` (${details.software_package})`} from{" "}
<b>{hostName}</b>.{" "}
<Button
className={`${baseClass}__show-query-link`}
variant="text-link"
onClick={() => onDetailsClick?.(activity.type, details)}
>
Show details{" "}
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
</Button>
</>
);
},
enabledVpp: (activity: IActivity) => {
return (
<>
@ -1168,6 +1202,9 @@ const getDetail = (
case ActivityType.InstalledSoftware: {
return TAGGED_TEMPLATES.installedSoftware(activity, onDetailsClick);
}
case ActivityType.UninstalledSoftware: {
return TAGGED_TEMPLATES.uninstalledSoftware(activity, onDetailsClick);
}
case ActivityType.AddedAppStoreApp: {
return TAGGED_TEMPLATES.addedAppStoreApp(activity);
}
@ -1234,6 +1271,7 @@ const ActivityItem = ({
DEFAULT_ACTOR_DISPLAY
);
case ActivityType.InstalledSoftware:
case ActivityType.UninstalledSoftware:
case ActivityType.InstalledAppStoreApp:
return activity.details?.self_service ? (
<span>An end user</span>

View file

@ -86,8 +86,11 @@ interface IStatusDisplayOption {
tooltip: React.ReactNode;
}
// "pending" and "failed" each encompass both "_install" and "_uninstall" sub-statuses
type SoftwareInstallDisplayStatus = "installed" | "pending" | "failed";
const STATUS_DISPLAY_OPTIONS: Record<
SoftwareInstallStatus,
SoftwareInstallDisplayStatus,
IStatusDisplayOption
> = {
installed: {
@ -106,16 +109,22 @@ const STATUS_DISPLAY_OPTIONS: Record<
pending: {
displayName: "Pending",
iconName: "pending-outline",
tooltip: "Fleet is installing or will install when the host comes online.",
tooltip: (
<>
Fleet is installing/uninstalling or will
<br />
do so when the host comes online.
</>
),
},
failed: {
displayName: "Failed",
iconName: "error",
tooltip: (
<>
These hosts failed to install software. Click on a host to view
These hosts failed to install/uninstall software.
<br />
error(s).
Click on a host to view error(s).
</>
),
},
@ -123,7 +132,7 @@ const STATUS_DISPLAY_OPTIONS: Record<
interface IPackageStatusCountProps {
softwareId: number;
status: SoftwareInstallStatus;
status: SoftwareInstallDisplayStatus;
count: number;
teamId?: number;
}

View file

@ -1,10 +1,16 @@
import {
IAppStoreApp,
ISoftwarePackage,
ISoftwareTitleDetails,
isSoftwarePackage,
} from "interfaces/software";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
const mergePackageStatuses = (packageStatuses: ISoftwarePackage["status"]) => ({
installed: packageStatuses.installed,
pending: packageStatuses.pending_install + packageStatuses.pending_uninstall,
failed: packageStatuses.failed_install + packageStatuses.failed_uninstall,
});
/**
* Generates the data needed to render the package card.
*/
@ -24,7 +30,9 @@ export const getPackageCardInfo = (softwareTitle: ISoftwareTitleDetails) => {
? packageData.version
: packageData.latest_version) || DEFAULT_EMPTY_CELL_VALUE,
uploadedAt: isSoftwarePackage(packageData) ? packageData.uploaded_at : "",
status: packageData.status,
status: isSoftwarePackage(packageData)
? mergePackageStatuses(packageData.status)
: packageData.status,
isSelfService: packageData.self_service,
};
};

View file

@ -1,33 +1,160 @@
import React, { useState } from "react";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import {
isPackageType,
isWindowsPackageType,
PackageType,
} from "interfaces/package_type";
import Editor from "components/Editor";
import CustomLink from "components/CustomLink";
import FleetAce from "components/FleetAce";
import RevealButton from "components/buttons/RevealButton";
import { IAddPackageFormData } from "../AddPackageForm/AddPackageForm";
const getSupportedScriptTypeText = (pkgType: PackageType) => {
return `Currently, ${
isWindowsPackageType(pkgType) ? "PowerS" : "s"
}hell scripts are supported.`;
};
const PKG_TYPE_TO_ID_TEXT = {
pkg: "package IDs",
deb: "package name",
msi: "product code",
exe: "software name",
} as const;
const getInstallHelpText = (pkgType: PackageType) => (
<>
Use the $INSTALLER_PATH variable to point to the installer.{" "}
{getSupportedScriptTypeText(pkgType)}{" "}
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/install-scripts`}
text="Learn more about install scripts"
newTab
/>
</>
);
const getPostInstallHelpText = (pkgType: PackageType) => {
return getSupportedScriptTypeText(pkgType);
};
const getUninstallHelpText = (pkgType: PackageType) => {
return (
<>
$PACKAGE_ID will be populated with the {PKG_TYPE_TO_ID_TEXT[pkgType]} from
the .{pkgType} file after the software is added.{" "}
{getSupportedScriptTypeText(pkgType)}{" "}
<CustomLink
url={`${LEARN_MORE_ABOUT_BASE_LINK}/uninstall-scripts`}
text="Learn more about uninstall scripts"
newTab
/>
</>
);
};
const baseClass = "add-package-advanced-options";
interface IAddPackageAdvancedOptionsProps {
errors: { preInstallQuery?: string; postInstallScript?: string };
selectedPackage: IAddPackageFormData["software"];
preInstallQuery?: string;
installScript: string;
postInstallScript?: string;
uninstallScript?: string;
onChangePreInstallQuery: (value?: string) => void;
onChangeInstallScript: (value: string) => void;
onChangePostInstallScript: (value?: string) => void;
onChangeUninstallScript: (value?: string) => void;
}
const AddPackageAdvancedOptions = ({
errors,
selectedPackage,
preInstallQuery,
installScript,
postInstallScript,
uninstallScript,
onChangePreInstallQuery,
onChangeInstallScript,
onChangePostInstallScript,
onChangeUninstallScript,
}: IAddPackageAdvancedOptionsProps) => {
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const renderAdvancedOptions = () => {
const name = selectedPackage?.name || "";
const ext = name.split(".").pop() as PackageType;
if (!isPackageType(ext)) {
// this should never happen
return null;
}
return (
<div className={`${baseClass}__input-fields`}>
<FleetAce
className="form-field"
focus
error={errors.preInstallQuery}
value={preInstallQuery}
placeholder="SELECT * FROM osquery_info WHERE start_time > 1"
label="Pre-install query"
name="preInstallQuery"
maxLines={10}
onChange={onChangePreInstallQuery}
helpText={
<>
Software will be installed only if the{" "}
<CustomLink
className={`${baseClass}__table-link`}
text="query returns results"
url="https://fleetdm.com/tables"
newTab
/>
</>
}
/>
<Editor
wrapEnabled
maxLines={10}
name="install-script"
onChange={onChangeInstallScript}
value={installScript}
helpText={getInstallHelpText(ext)}
label="Install script"
isFormField
/>
<Editor
label="Post-install script"
focus
error={errors.postInstallScript}
wrapEnabled
name="post-install-script-editor"
maxLines={10}
onChange={onChangePostInstallScript}
value={postInstallScript}
helpText={getPostInstallHelpText(ext)}
isFormField
/>
<Editor
label="Uninstall script"
focus
wrapEnabled
name="uninstall-script-editor"
maxLines={20}
onChange={onChangeUninstallScript}
value={uninstallScript}
helpText={getUninstallHelpText(ext)}
isFormField
/>
</div>
);
};
return (
<div className={baseClass}>
<RevealButton
@ -37,63 +164,14 @@ const AddPackageAdvancedOptions = ({
hideText="Advanced options"
caretPosition="after"
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
disabled={!selectedPackage}
disabledTooltipContent={
selectedPackage
? "Choose a file to modify advanced options."
: undefined
}
/>
{showAdvancedOptions && (
<div className={`${baseClass}__input-fields`}>
<FleetAce
className="form-field"
focus
error={errors.preInstallQuery}
value={preInstallQuery}
placeholder="SELECT * FROM osquery_info WHERE start_time > 1"
label="Pre-install query"
name="preInstallQuery"
maxLines={10}
onChange={onChangePreInstallQuery}
helpText={
<>
Software will be installed only if the{" "}
<CustomLink
className={`${baseClass}__table-link`}
text="query returns results"
url="https://fleetdm.com/tables"
newTab
/>
</>
}
/>
<Editor
wrapEnabled
maxLines={10}
name="install-script"
onChange={onChangeInstallScript}
value={installScript}
helpText="Shell (macOS and Linux) or PowerShell (Windows)."
label="Install script"
labelTooltip={
<>
Fleet will run this script on hosts to install software. Use the
<br />
$INSTALLER_PATH variable to point to the installer.
</>
}
isFormField
/>
<Editor
label="Post-install script"
labelTooltip="Fleet will run this script after install."
focus
error={errors.postInstallScript}
wrapEnabled
name="post-install-script-editor"
maxLines={10}
onChange={onChangePostInstallScript}
value={postInstallScript}
helpText="Shell (macOS and Linux) or PowerShell (Windows)."
isFormField
/>
</div>
)}
{showAdvancedOptions && !!selectedPackage && renderAdvancedOptions()}
</div>
);
};

View file

@ -2,7 +2,8 @@ import React, { useContext, useState } from "react";
import { NotificationContext } from "context/notification";
import { getFileDetails } from "utilities/file/fileUtils";
import getInstallScript from "utilities/software_install_scripts";
import getDefaultInstallScript from "utilities/software_install_scripts";
import getDefaultUninstallScript from "utilities/software_uninstall_scripts";
import Button from "components/buttons/Button";
import Checkbox from "components/forms/fields/Checkbox";
@ -30,9 +31,10 @@ const UploadingSoftware = () => {
export interface IAddPackageFormData {
software: File | null;
installScript: string;
preInstallQuery?: string;
installScript: string;
postInstallScript?: string;
uninstallScript?: string;
selfService: boolean;
}
@ -59,9 +61,10 @@ const AddPackageForm = ({
const [formData, setFormData] = useState<IAddPackageFormData>({
software: null,
installScript: "",
preInstallQuery: undefined,
installScript: "",
postInstallScript: undefined,
uninstallScript: undefined,
selfService: false,
});
const [formValidation, setFormValidation] = useState<IFormValidation>({
@ -69,13 +72,21 @@ const AddPackageForm = ({
software: { isValid: false },
});
const onFileUpload = (files: FileList | null) => {
const onFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const file = files[0];
let installScript: string;
let defaultInstallScript: string;
try {
installScript = getInstallScript(file.name);
defaultInstallScript = getDefaultInstallScript(file.name);
} catch (e) {
renderFlash("error", `${e}`);
return;
}
let defaultUninstallScript: string;
try {
defaultUninstallScript = getDefaultUninstallScript(file.name);
} catch (e) {
renderFlash("error", `${e}`);
return;
@ -84,7 +95,8 @@ const AddPackageForm = ({
const newData = {
...formData,
software: file,
installScript,
installScript: defaultInstallScript,
uninstallScript: defaultUninstallScript,
};
setFormData(newData);
setFormValidation(generateFormValidation(newData));
@ -112,6 +124,12 @@ const AddPackageForm = ({
setFormValidation(generateFormValidation(newData));
};
const onChangeUninstallScript = (value?: string) => {
const newData = { ...formData, uninstallScript: value };
setFormData(newData);
setFormValidation(generateFormValidation(newData));
};
const onToggleSelfServiceCheckbox = (value: boolean) => {
const newData = { ...formData, selfService: value };
setFormData(newData);
@ -130,7 +148,7 @@ const AddPackageForm = ({
graphicName={"file-pkg"}
accept=".pkg,.msi,.exe,.deb"
message=".pkg, .msi, .exe, or .deb"
onFileUpload={onFileUpload}
onFileUpload={onFileSelect}
buttonMessage="Choose file"
buttonType="link"
className={`${baseClass}__file-uploader`}
@ -156,16 +174,19 @@ const AddPackageForm = ({
</TooltipWrapper>
</Checkbox>
<AddPackageAdvancedOptions
selectedPackage={formData.software}
errors={{
preInstallQuery: formValidation.preInstallQuery?.message,
postInstallScript: formValidation.postInstallScript?.message,
}}
preInstallQuery={formData.preInstallQuery}
installScript={formData.installScript}
postInstallScript={formData.postInstallScript}
uninstallScript={formData.uninstallScript}
onChangePreInstallQuery={onChangePreInstallQuery}
onChangeInstallScript={onChangeInstallScript}
onChangePostInstallScript={onChangePostInstallScript}
installScript={formData.installScript}
onChangeUninstallScript={onChangeUninstallScript}
/>
<div className="modal-cta-wrap">
<Button type="submit" variant="brand" disabled={isSubmitDisabled}>

View file

@ -1,5 +1,3 @@
import validator from "validator";
// @ts-ignore
import validateQuery from "components/forms/validators/validate_query";
@ -7,7 +5,7 @@ import { IAddPackageFormData, IFormValidation } from "./AddPackageForm";
type IAddPackageFormValidatorKey = Exclude<
keyof IAddPackageFormData,
"installScript"
"installScript" | "uninstallScript"
>;
type IMessageFunc = (formData: IAddPackageFormData) => string;

View file

@ -415,7 +415,7 @@ const DeviceUserPage = ({
<SoftwareCard
id={deviceAuthToken}
softwareUpdatedAt={host.software_updated_at}
hostCanInstallSoftware={!!host.orbit_version}
hostCanWriteSoftware={!!host.orbit_version}
router={router}
pathname={location.pathname}
queryParams={parseHostSoftwareQueryParams(location.query)}

View file

@ -68,6 +68,7 @@ import {
SoftwareInstallDetailsModal,
IPackageInstallDetails,
} from "components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails";
import SoftwareUninstallDetailsModal from "components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal";
import HostSummaryCard from "../cards/HostSummary";
import AboutCard from "../cards/About";
@ -181,6 +182,10 @@ const HostDetailsPage = ({
packageInstallDetails,
setPackageInstallDetails,
] = useState<IPackageInstallDetails | null>(null);
const [
packageUninstallDetails,
setPackageUninstallDetails,
] = useState<IPackageInstallDetails | null>(null);
const [
appInstallDetails,
setAppInstallDetails,
@ -602,6 +607,13 @@ const HostDetailsPage = ({
host?.display_name || details?.host_display_name || "",
});
break;
case "uninstalled_software":
setPackageUninstallDetails({
...details,
host_display_name:
host?.display_name || details?.host_display_name || "",
});
break;
case "installed_app_store_app":
setAppInstallDetails({
...details,
@ -933,9 +945,7 @@ const HostDetailsPage = ({
id={host.id}
platform={host.platform}
softwareUpdatedAt={host.software_updated_at}
hostCanInstallSoftware={
!!host.orbit_version || isIosOrIpadosHost
}
hostCanWriteSoftware={!!host.orbit_version || isIosOrIpadosHost}
isSoftwareEnabled={featuresConfig?.enable_software_inventory}
router={router}
queryParams={parseHostSoftwareQueryParams(location.query)}
@ -1065,6 +1075,12 @@ const HostDetailsPage = ({
onCancel={onCancelSoftwareInstallDetailsModal}
/>
)}
{packageUninstallDetails && (
<SoftwareUninstallDetailsModal
details={packageUninstallDetails}
onCancel={() => setPackageUninstallDetails(null)}
/>
)}
{!!appInstallDetails && (
<AppInstallDetailsModal
details={appInstallDetails}

View file

@ -36,6 +36,7 @@ export const pastActivityComponentMap: Record<
[ActivityType.LockedHost]: LockedHostActivityItem,
[ActivityType.UnlockedHost]: UnlockedHostActivityItem,
[ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem,
[ActivityType.UninstalledSoftware]: InstalledSoftwareActivityItem,
[ActivityType.InstalledAppStoreApp]: InstalledSoftwareActivityItem,
};
@ -46,5 +47,6 @@ export const upcomingActivityComponentMap: Record<
> = {
[ActivityType.RanScript]: RanScriptActivityItem,
[ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem,
[ActivityType.UninstalledSoftware]: InstalledSoftwareActivityItem,
[ActivityType.InstalledAppStoreApp]: InstalledSoftwareActivityItem,
};

View file

@ -13,7 +13,9 @@ const InstalledSoftwareActivityItem = ({
onShowDetails,
}: IHostActivityItemComponentPropsWithShowDetails) => {
const { actor_full_name: actorName, details } = activity;
const { self_service, status, software_title: title } = details;
const { self_service, software_title: title } = details;
const status =
details.status === "failed" ? "failed_uninstall" : details.status;
const actorDisplayName = self_service ? (
<span>An end user</span>

View file

@ -441,7 +441,6 @@ const HostSummary = ({
};
const renderSummary = () => {
console.log(hostMdmProfiles);
// for windows hosts we have to manually add a profile for disk encryption
// as this is not currently included in the `profiles` value from the API
// response for windows hosts.

View file

@ -37,7 +37,7 @@ interface IHostSoftwareProps {
id: number | string;
platform?: HostPlatform;
softwareUpdatedAt?: string;
hostCanInstallSoftware: boolean;
hostCanWriteSoftware: boolean;
router: InjectedRouter;
queryParams: ReturnType<typeof parseHostSoftwareQueryParams>;
pathname: string;
@ -86,7 +86,7 @@ const HostSoftware = ({
id,
platform,
softwareUpdatedAt,
hostCanInstallSoftware,
hostCanWriteSoftware,
router,
queryParams,
pathname,
@ -105,7 +105,8 @@ const HostSoftware = ({
isTeamMaintainer,
} = useContext(AppContext);
const [installingSoftwareId, setInstallingSoftwareId] = useState<
// disables install/uninstall actions after click
const [softwareIdActionPending, setSoftwareIdActionPending] = useState<
number | null
>(null);
@ -175,13 +176,13 @@ const HostSoftware = ({
[isMyDevicePage, refetchDeviceSoftware, refetchHostSoftware]
);
const userHasSWInstallPermission = Boolean(
const userHasSWWritePermission = Boolean(
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer
);
const installHostSoftwarePackage = useCallback(
async (softwareId: number) => {
setInstallingSoftwareId(softwareId);
setSoftwareIdActionPending(softwareId);
try {
await hostAPI.installHostSoftwarePackage(id as number, softwareId);
renderFlash(
@ -191,7 +192,28 @@ const HostSoftware = ({
} catch (e) {
renderFlash("error", getErrorMessage(e));
}
setInstallingSoftwareId(null);
setSoftwareIdActionPending(null);
refetchSoftware();
},
[id, renderFlash, refetchSoftware]
);
const uninstallHostSoftwarePackage = useCallback(
async (softwareId: number) => {
setSoftwareIdActionPending(softwareId);
try {
await hostAPI.uninstallHostSoftwarePackage(id as number, softwareId);
renderFlash(
"success",
<>
Software is uninstalling or will uninstall when the host comes
online. To see details, go to <b>Details &gt; Activity</b>.
</>
);
} catch (e) {
renderFlash("error", "Couldn't uninstall. Please try again.");
}
setSoftwareIdActionPending(null);
refetchSoftware();
},
[id, renderFlash, refetchSoftware]
@ -203,6 +225,9 @@ const HostSoftware = ({
case "install":
installHostSoftwarePackage(software.id);
break;
case "uninstall":
uninstallHostSoftwarePackage(software.id);
break;
case "showDetails":
onShowSoftwareDetails?.(software);
break;
@ -210,7 +235,11 @@ const HostSoftware = ({
break;
}
},
[installHostSoftwarePackage, onShowSoftwareDetails]
[
installHostSoftwarePackage,
onShowSoftwareDetails,
uninstallHostSoftwarePackage,
]
);
const tableConfig = useMemo(() => {
@ -218,20 +247,20 @@ const HostSoftware = ({
? generateDeviceSoftwareTableConfig()
: generateHostSoftwareTableConfig({
router,
installingSoftwareId,
userHasSWInstallPermission,
softwareIdActionPending,
userHasSWWritePermission,
onSelectAction,
teamId: hostTeamId,
hostCanInstallSoftware,
hostCanWriteSoftware,
});
}, [
isMyDevicePage,
router,
installingSoftwareId,
userHasSWInstallPermission,
softwareIdActionPending,
userHasSWWritePermission,
onSelectAction,
hostTeamId,
hostCanInstallSoftware,
hostCanWriteSoftware,
]);
const isLoading = isMyDevicePage

View file

@ -33,6 +33,7 @@ import InstallStatusCell from "./InstallStatusCell";
const DEFAULT_ACTION_OPTIONS: IDropdownOption[] = [
{ value: "showDetails", label: "Show details", disabled: false },
{ value: "install", label: "Install", disabled: false },
{ value: "uninstall", label: "Uninstall", disabled: false },
];
type ISoftwareTableConfig = Column<IHostSoftware>;
@ -50,17 +51,18 @@ type IInstalledVersionsCellProps = CellProps<
type IVulnerabilitiesCellProps = IInstalledVersionsCellProps;
const generateActions = ({
userHasSWInstallPermission,
hostCanInstallSoftware,
installingSoftwareId,
userHasSWWritePermission,
// Commenting below in case there is a quick decision to use these conditions after all
// hostCanWriteSoftware,
// software_package,
softwareIdActionPending,
softwareId,
status,
software_package,
app_store_app,
}: {
userHasSWInstallPermission: boolean;
hostCanInstallSoftware: boolean;
installingSoftwareId: number | null;
userHasSWWritePermission: boolean;
hostCanWriteSoftware: boolean;
softwareIdActionPending: number | null;
softwareId: number;
status: SoftwareInstallStatus | null;
software_package: IHostSoftwarePackage | null;
@ -76,39 +78,44 @@ const generateActions = ({
// error to fail loudly so that we know to update this function
throw new Error("Install action not found in default actions");
}
const indexUninstallAction = actions.findIndex(
(a) => a.value === "uninstall"
);
if (indexUninstallAction === -1) {
// this should never happen unless the default actions change, but if it does we'll throw an
// error to fail loudly so that we know to update this function
throw new Error("Uninstall action not found in default actions");
}
const hasSoftwareToInstall = !!software_package || !!app_store_app;
// remove install if there is no package to install or if the software is already installed
if (
!hasSoftwareToInstall ||
!userHasSWInstallPermission ||
status === "installed"
) {
if (!userHasSWWritePermission) {
actions.splice(indexInstallAction, 1);
return actions;
actions.splice(indexUninstallAction, 1);
} else {
// user has software write permission for host
const pendingStatuses = ["pending_install", "pending_uninstall"];
if (
// if locally pending (waiting for API response) or pending install/uninstall, disable both
// install and uninstall
softwareId === softwareIdActionPending ||
pendingStatuses.includes(status || "")
) {
actions[indexInstallAction].disabled = true;
actions[indexUninstallAction].disabled = true;
}
}
// disable install option if not a fleetd, iPad, or iOS host
if (!hostCanInstallSoftware) {
actions[indexInstallAction].disabled = true;
actions[indexInstallAction].tooltipContent =
"To install software on this host, deploy the fleetd agent with --enable-scripts and refetch host vitals.";
return actions;
if (app_store_app) {
// remove uninstall for VPP apps
actions.splice(indexUninstallAction, 1);
}
// disable install option if software is already installing
if (softwareId === installingSoftwareId || status === "pending") {
actions[indexInstallAction].disabled = true;
return actions;
}
return actions;
};
interface ISoftwareTableHeadersProps {
userHasSWInstallPermission: boolean;
hostCanInstallSoftware: boolean;
installingSoftwareId: number | null;
userHasSWWritePermission: boolean;
hostCanWriteSoftware: boolean;
softwareIdActionPending: number | null;
router: InjectedRouter;
teamId: number;
onSelectAction: (software: IHostSoftware, action: string) => void;
@ -117,9 +124,9 @@ interface ISoftwareTableHeadersProps {
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
export const generateSoftwareTableHeaders = ({
userHasSWInstallPermission,
hostCanInstallSoftware,
installingSoftwareId,
userHasSWWritePermission,
hostCanWriteSoftware,
softwareIdActionPending,
router,
teamId,
onSelectAction,
@ -209,9 +216,9 @@ export const generateSoftwareTableHeaders = ({
<DropdownCell
placeholder="Actions"
options={generateActions({
userHasSWInstallPermission,
hostCanInstallSoftware,
installingSoftwareId,
userHasSWWritePermission,
hostCanWriteSoftware,
softwareIdActionPending,
softwareId,
status,
software_package,

View file

@ -30,7 +30,7 @@ export type IStatusDisplayConfig = {
};
export const INSTALL_STATUS_DISPLAY_OPTIONS: Record<
IStatusValue | "selfService",
Exclude<IStatusValue, "uninstalled"> | "selfService",
IStatusDisplayConfig
> = {
installed: {
@ -39,20 +39,42 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record<
tooltip: () =>
"Software is installed (install script finished with exit code 0).",
},
pending: {
pending_install: {
iconName: "pending-outline",
displayText: "Pending",
displayText: "Installing (pending)",
tooltip: () =>
"Fleet is installing or will install when the host comes online.",
},
failed: {
iconName: "error",
displayText: "Failed",
pending_uninstall: {
iconName: "pending-outline",
displayText: "Uninstalling (pending)",
tooltip: () => (
<>
The host failed to install software. To view errors, select
Fleet is uninstalling or will uninstall
<br />
<b>Actions &gt; Show details</b>.
software when the host comes online.
</>
),
},
failed_install: {
iconName: "error",
displayText: "Install (failed)",
tooltip: () => (
<>
The host failed to install software.
<br />
Select <b>Actions &gt; Show details</b> view errors.
</>
),
},
failed_uninstall: {
iconName: "error",
displayText: "Uninstall (failed)",
tooltip: () => (
<>
The host failed to uninstall software.
<br />
Select <b>Details &gt; Activity</b> to view errors.
</>
),
},

View file

@ -112,13 +112,13 @@ describe("SelfService", () => {
).toHaveTextContent("Reinstall");
});
it("renders 'Retry' action button with 'Failed' status", async () => {
it("renders 'Retry' action button with 'failed_install' status", async () => {
mockServer.use(
customDeviceSoftwareHandler({
software: [
createMockDeviceSoftware({
name: "test-software",
status: "failed",
status: "failed_install",
}),
],
})
@ -166,13 +166,13 @@ describe("SelfService", () => {
).toHaveTextContent("Install");
});
it("renders no action button with 'Pending' status", async () => {
it("renders no action button with 'pending_install' status", async () => {
mockServer.use(
customDeviceSoftwareHandler({
software: [
createMockDeviceSoftware({
name: "test-software",
status: "pending",
status: "pending_install",
}),
],
})

View file

@ -21,24 +21,30 @@ import { IStatusDisplayConfig } from "../../InstallStatusCell/InstallStatusCell"
const baseClass = "self-service-item";
const STATUS_CONFIG: Record<SoftwareInstallStatus, IStatusDisplayConfig> = {
const STATUS_CONFIG: Record<
Exclude<
SoftwareInstallStatus,
"pending_uninstall" | "failed_uninstall" | "uninstalled"
>,
IStatusDisplayConfig
> = {
installed: {
iconName: "success",
displayText: "Installed",
tooltip: ({ lastInstalledAt }) =>
`Software is installed (${dateAgo(lastInstalledAt as string)}).`,
},
pending: {
pending_install: {
iconName: "pending-outline",
displayText: "Pending",
tooltip: () => "Fleet is installing software.",
},
failed: {
failed_install: {
iconName: "error",
displayText: "Failed",
tooltip: ({ lastInstalledAt = "" }) => (
<>
Software failed to install{" "}
Software failed to install
{lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : ""}. Select{" "}
<b>Retry</b> to install again, or contact your IT department.
</>
@ -134,7 +140,7 @@ const getInstallButtonText = (status: SoftwareInstallStatus | null) => {
switch (status) {
case null:
return "Install";
case "failed":
case "failed_install":
return "Retry";
case "installed":
return "Reinstall";
@ -163,7 +169,7 @@ const InstallerStatusAction = ({
// if the localStatus is "failed", we don't want our tooltip to include the old installed_at date so we
// set this to null, which tells the tooltip to omit the parenthetical date
const lastInstall = localStatus === "failed" ? null : last_install;
const lastInstall = localStatus === "failed_install" ? null : last_install;
const isMountedRef = useRef(false);
useEffect(() => {
@ -174,7 +180,7 @@ const InstallerStatusAction = ({
}, []);
const onClick = useCallback(async () => {
setLocalStatus("pending");
setLocalStatus("pending_install");
try {
await deviceApi.installSelfServiceSoftware(deviceToken, id);
if (isMountedRef.current) {
@ -183,7 +189,7 @@ const InstallerStatusAction = ({
} catch (error) {
renderFlash("error", "Couldn't install. Please try again.");
if (isMountedRef.current) {
setLocalStatus("failed");
setLocalStatus("failed_install");
}
}
}, [deviceToken, id, onInstall, renderFlash]);
@ -200,7 +206,7 @@ const InstallerStatusAction = ({
type="button"
className={`${baseClass}__item-action-button`}
onClick={onClick}
disabled={localStatus === "pending"}
disabled={localStatus === "pending_install"}
>
<span data-testid={`${baseClass}__item-action-button--test`}>
{installButtonText}

View file

@ -590,4 +590,11 @@ export default {
HOST_SOFTWARE_PACKAGE_INSTALL(hostId, softwareId)
);
},
uninstallHostSoftwarePackage: (hostId: number, softwareId: number) => {
const { HOST_SOFTWARE_PACKAGE_UNINSTALL } = endpoints;
return sendRequest(
"POST",
HOST_SOFTWARE_PACKAGE_UNINSTALL(hostId, softwareId)
);
},
};

View file

@ -39,6 +39,7 @@ export interface IScriptResultResponse {
message: string;
runtime: number;
host_timeout: boolean;
created_at: string;
}
/**

View file

@ -219,6 +219,8 @@ export default {
formData.append("software", data.software);
formData.append("self_service", data.selfService.toString());
data.installScript && formData.append("install_script", data.installScript);
data.uninstallScript &&
formData.append("uninstall_script", data.uninstallScript);
data.preInstallQuery &&
formData.append("pre_install_query", data.preInstallQuery);
data.postInstallScript &&

View file

@ -132,6 +132,9 @@ $max-width: 2560px;
font-size: $xx-small;
font-weight: $regular;
@include grey-text;
.custom-link {
font-size: inherit;
}
}
@mixin link {

View file

@ -52,7 +52,9 @@ export default {
`/${API_VERSION}/fleet/hosts/${hostId}/configuration_profiles/resend/${profileUUID}`,
HOST_SOFTWARE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/software`,
HOST_SOFTWARE_PACKAGE_INSTALL: (hostId: number, softwareId: number) =>
`/${API_VERSION}/fleet/hosts/${hostId}/software/install/${softwareId}`,
`/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/install`,
HOST_SOFTWARE_PACKAGE_UNINSTALL: (hostId: number, softwareId: number) =>
`/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/uninstall`,
INVITES: `/${API_VERSION}/fleet/invites`,
@ -165,7 +167,7 @@ export default {
SOFTWARE_PACKAGE_TOKEN: (id: number) =>
`/${API_VERSION}/fleet/software/titles/${id}/package/token`,
SOFTWARE_INSTALL_RESULTS: (uuid: string) =>
`/${API_VERSION}/fleet/software/install/results/${uuid}`,
`/${API_VERSION}/fleet/software/install/${uuid}/results`,
SOFTWARE_PACKAGE_INSTALL: (id: number) =>
`/${API_VERSION}/fleet/software/packages/${id}`,
SOFTWARE_AVAILABLE_FOR_INSTALL: (id: number) =>

View file

@ -11,7 +11,7 @@ import installDeb from "../../pkg/file/scripts/install_deb.sh";
* getInstallScript returns a string with a script to install the
* provided software.
* */
const getInstallScript = (fileName: string): string => {
const getDefaultInstallScript = (fileName: string): string => {
const extension = fileName.split(".").pop();
switch (extension) {
case "pkg":
@ -27,4 +27,4 @@ const getInstallScript = (fileName: string): string => {
}
};
export default getInstallScript;
export default getDefaultInstallScript;

View file

@ -0,0 +1,30 @@
// @ts-ignore
import uninstallPkg from "../../pkg/file/scripts/uninstall_pkg.sh";
// @ts-ignore
import uninstallMsi from "../../pkg/file/scripts/uninstall_msi.ps1";
// @ts-ignore
import uninstallExe from "../../pkg/file/scripts/uninstall_exe.ps1";
// @ts-ignore
import uninstallDeb from "../../pkg/file/scripts/uninstall_deb.sh";
/*
* getUninstallScript returns a string with a script to uninstall the
* provided software.
* */
const getDefaultUninstallScript = (fileName: string): string => {
const extension = fileName.split(".").pop();
switch (extension) {
case "pkg":
return uninstallPkg;
case "msi":
return uninstallMsi;
case "deb":
return uninstallDeb;
case "exe":
return uninstallExe;
default:
throw new Error(`unsupported file extension: ${extension}`);
}
};
export default getDefaultUninstallScript;

58
go.mod
View file

@ -3,7 +3,7 @@ module github.com/fleetdm/fleet/v4
go 1.23.1
require (
cloud.google.com/go/pubsub v1.36.1
cloud.google.com/go/pubsub v1.37.0
fyne.io/systray v1.10.1-0.20240111184411-11c585fff98d
github.com/AbGuthrie/goquery/v2 v2.0.1
github.com/DATA-DOG/go-sqlmock v1.5.0
@ -90,7 +90,7 @@ require (
github.com/rs/zerolog v1.32.0
github.com/russellhaering/goxmldsig v1.2.0
github.com/saferwall/pe v1.5.2
github.com/sassoftware/relic/v7 v7.6.2
github.com/sassoftware/relic/v8 v8.0.1
github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9
github.com/sethvargo/go-password v0.3.0
github.com/shirou/gopsutil/v3 v3.24.3
@ -103,7 +103,7 @@ require (
github.com/theupdateframework/go-tuf v0.5.2
github.com/throttled/throttled/v2 v2.8.0
github.com/tj/assert v0.0.3
github.com/ulikunitz/xz v0.5.11
github.com/ulikunitz/xz v0.5.12
github.com/urfave/cli/v2 v2.23.5
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8
github.com/ziutek/mymysql v1.5.4
@ -126,7 +126,7 @@ require (
golang.org/x/sys v0.21.0
golang.org/x/text v0.16.0
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d
google.golang.org/api v0.169.0
google.golang.org/api v0.178.0
google.golang.org/grpc v1.64.1
gopkg.in/guregu/null.v3 v3.5.0
gopkg.in/ini.v1 v1.67.0
@ -137,11 +137,13 @@ require (
)
require (
cloud.google.com/go v0.112.1 // indirect
cloud.google.com/go v0.112.2 // indirect
cloud.google.com/go/auth v0.3.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
cloud.google.com/go/compute/metadata v0.3.0 // indirect
cloud.google.com/go/iam v1.1.6 // indirect
cloud.google.com/go/kms v1.15.7 // indirect
cloud.google.com/go/storage v1.38.0 // indirect
cloud.google.com/go/iam v1.1.8 // indirect
cloud.google.com/go/kms v1.15.9 // indirect
cloud.google.com/go/storage v1.39.1 // indirect
code.gitea.io/sdk/gitea v0.15.0 // indirect
dario.cat/mergo v1.0.0 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
@ -167,7 +169,7 @@ require (
github.com/Masterminds/sprig v2.22.0+incompatible // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/OneOfOne/xxhash v1.2.8 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/agnivade/levenshtein v1.1.1 // indirect
github.com/akavel/rsrc v0.10.2 // indirect
github.com/alecthomas/jsonschema v0.0.0-20211022214203-8b29eab41725 // indirect
@ -175,20 +177,20 @@ require (
github.com/apache/thrift v0.18.1 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/atc0005/go-teams-notify/v2 v2.6.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.24.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.26.6 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.16.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect
github.com/aws/aws-sdk-go-v2/service/kms v1.27.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect
github.com/aws/smithy-go v1.19.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.26.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.11 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 // indirect
github.com/aws/aws-sdk-go-v2/service/kms v1.31.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 // indirect
github.com/aws/smithy-go v1.20.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/c-bata/go-prompt v0.2.3 // indirect
github.com/caarlos0/ctrlc v1.0.0 // indirect
@ -197,7 +199,7 @@ require (
github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cloudflare/circl v1.3.8 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
@ -236,7 +238,7 @@ require (
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/wire v0.5.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.4 // indirect
github.com/goreleaser/chglog v0.1.2 // indirect
github.com/goreleaser/fileglob v1.2.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
@ -257,7 +259,7 @@ require (
github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.17.7 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.5 // indirect
@ -272,7 +274,7 @@ require (
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0-rc6 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/oschwald/maxminddb-golang v1.10.0 // indirect
github.com/pelletier/go-toml v1.9.4 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
@ -321,7 +323,7 @@ require (
golang.org/x/term v0.21.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 // indirect
google.golang.org/genproto v0.0.0-20240506185236-b8a5c65736ae // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect
google.golang.org/protobuf v1.34.2 // indirect

116
go.sum
View file

@ -31,8 +31,12 @@ cloud.google.com/go v0.92.2/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+Y
cloud.google.com/go v0.92.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
cloud.google.com/go v0.94.0/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM=
cloud.google.com/go v0.112.1/go.mod h1:+Vbu+Y1UU+I1rjmzeMOb/8RfkKJK2Gyxi1X6jJCZLo4=
cloud.google.com/go v0.112.2 h1:ZaGT6LiG7dBzi6zNOvVZwacaXlmf3lRqnC4DQzqyRQw=
cloud.google.com/go v0.112.2/go.mod h1:iEqjp//KquGIJV/m+Pk3xecgKNhV+ry+vVTsy4TbDms=
cloud.google.com/go/auth v0.3.0 h1:PRyzEpGfx/Z9e8+lHsbkoUVXD0gnu4MNmm7Gp8TQNIs=
cloud.google.com/go/auth v0.3.0/go.mod h1:lBv6NKTWp8E3LPzmO1TbiiRKc4drLOfHsgmlH9ogv5w=
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
@ -45,19 +49,19 @@ cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/firestore v1.5.0/go.mod h1:c4nNYR1qdq7eaZ+jSc5fonrQN2k3M7sWATcYTiakjEo=
cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc=
cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI=
cloud.google.com/go/iam v1.1.8 h1:r7umDwhj+BQyz0ScZMp4QrGXjSTI3ZINnpgU2nlB/K0=
cloud.google.com/go/iam v1.1.8/go.mod h1:GvE6lyMmfxXauzNq8NbgJbeVQNspG+tcdL/W8QO1+zE=
cloud.google.com/go/kms v0.1.0/go.mod h1:8Qp8PCAypHg4FdmlyW1QRAv09BGQ9Uzh7JnmIZxPk+c=
cloud.google.com/go/kms v1.15.7 h1:7caV9K3yIxvlQPAcaFffhlT7d1qpxjB1wHBtjWa13SM=
cloud.google.com/go/kms v1.15.7/go.mod h1:ub54lbsa6tDkUwnu4W7Yt1aAIFLnspgh0kPGToDukeI=
cloud.google.com/go/kms v1.15.9 h1:ouZjTxCqDNEdxWfaAAbRzG22s/2iewRw6JPARQL+0vc=
cloud.google.com/go/kms v1.15.9/go.mod h1:5v/R/RRuBUVO+eJioGcqENr3syh8ZqNn1y1Wc9DjM+4=
cloud.google.com/go/monitoring v0.1.0/go.mod h1:Hpm3XfzJv+UTiXzCG5Ffp0wijzHTC7Cv4eR7o3x/fEE=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/pubsub v1.16.0/go.mod h1:6A8EfoWZ/lUvCWStKGwAWauJZSiuV0Mkmu6WilK/TxQ=
cloud.google.com/go/pubsub v1.36.1 h1:dfEPuGCHGbWUhaMCTHUFjfroILEkx55iUmKBZTP5f+Y=
cloud.google.com/go/pubsub v1.36.1/go.mod h1:iYjCa9EzWOoBiTdd4ps7QoMtMln5NwaZQpK1hbRfBDE=
cloud.google.com/go/pubsub v1.37.0 h1:0uEEfaB1VIJzabPpwpZf44zWAKAme3zwKKxHk7vJQxQ=
cloud.google.com/go/pubsub v1.37.0/go.mod h1:YQOQr1uiUM092EXwKs56OPT650nwnawc+8/IjoUeGzQ=
cloud.google.com/go/secretmanager v0.1.0/go.mod h1:3nGKHvnzDUVit7U0S9KAKJ4aOsO1xtwRG+7ey5LK1bM=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
@ -65,8 +69,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.16.1/go.mod h1:LaNorbty3ehnU3rEjXSNV/NRgQA0O8Y+uh6bPe5UOk4=
cloud.google.com/go/storage v1.38.0 h1:Az68ZRGlnNTpIBbLjSMIV2BDcwwXYlRlQzis0llkpJg=
cloud.google.com/go/storage v1.38.0/go.mod h1:tlUADB0mAb9BgYls9lq+8MGkfzOXuLrnHXlpHmvFJoY=
cloud.google.com/go/storage v1.39.1 h1:MvraqHKhogCOTXTlct/9C3K3+Uy2jBmFYb3/Sp6dVtY=
cloud.google.com/go/storage v1.39.1/go.mod h1:xK6xZmxZmo+fyP7+DEF6FhNc24/JAe95OLyOHCXFH1o=
cloud.google.com/go/trace v0.1.0/go.mod h1:wxEwsoeRVPbeSkt7ZC9nWCgmoKQRAoySN7XHW2AmI7g=
code.gitea.io/gitea-vet v0.2.1/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE=
code.gitea.io/sdk/gitea v0.15.0 h1:tsNhxDM/2N1Ohv1Xq5UWrht/esg0WmtRj4wsHVHriTg=
@ -174,8 +178,8 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/ProtonMail/go-mime v0.0.0-20190923161245-9b5a4261663a h1:W6RrgN/sTxg1msqzFFb+G80MFmpjMw61IU+slm+wln4=
github.com/ProtonMail/go-mime v0.0.0-20190923161245-9b5a4261663a/go.mod h1:NYt+V3/4rEeDuaev/zw1zCq8uqVEuPHzDPo3OZrlGJ4=
github.com/ProtonMail/gopenpgp/v2 v2.2.2 h1:u2m7xt+CZWj88qK1UUNBoXeJCFJwJCZ/Ff4ymGoxEXs=
@ -243,45 +247,45 @@ github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm
github.com/aws/aws-sdk-go v1.44.288 h1:Ln7fIao/nl0ACtelgR1I4AiEw/GLNkKcXfCaHupUW5Q=
github.com/aws/aws-sdk-go v1.44.288/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU=
github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA=
github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=
github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
github.com/aws/aws-sdk-go-v2/config v1.26.6 h1:Z/7w9bUqlRI0FFQpetVuFYEsjzE3h7fpU6HuGmfPL/o=
github.com/aws/aws-sdk-go-v2/config v1.26.6/go.mod h1:uKU6cnDmYCvJ+pxO9S4cWDb2yWWIH5hra+32hVh1MI4=
github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA=
github.com/aws/aws-sdk-go-v2/config v1.27.11/go.mod h1:SMsV78RIOYdve1vf36z8LmnszlRWkwMQtomCAI0/mIE=
github.com/aws/aws-sdk-go-v2/credentials v1.4.0/go.mod h1:dgGR+Qq7Wjcd4AOAW5Rf5Tnv3+x7ed6kETXyS9WCuAY=
github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8=
github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0=
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs=
github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.5.0/go.mod h1:CpNzHK9VEFUCknu50kkB8z58AH2B5DvPP7ea1LHve/Y=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.2.2/go.mod h1:BQV0agm+JEhqR+2RT5e1XTFIDcAAV0eW6z2trp+iduw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 h1:n3GDfwqF2tzEkXlv5cuy4iy7LpKDtqDMcNLfZDu9rls=
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.0/go.mod h1:R1KK+vY8AfalhG1AOu5e35pOD2SdoPKQCFLTvnxiohk=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk=
github.com/aws/aws-sdk-go-v2/service/kms v1.5.0/go.mod h1:w7JuP9Oq1IKMFQPkNe3V6s9rOssXzOVEMNEqK1L1bao=
github.com/aws/aws-sdk-go-v2/service/kms v1.27.9 h1:W9PbZAZAEcelhhjb7KuwUtf+Lbc+i7ByYJRuWLlnxyQ=
github.com/aws/aws-sdk-go-v2/service/kms v1.27.9/go.mod h1:2tFmR7fQnOdQlM2ZCEPpFnBIQD1U8wmXmduBgZbOag0=
github.com/aws/aws-sdk-go-v2/service/kms v1.31.0 h1:yl7wcqbisxPzknJVfWTLnK83McUvXba+pz2+tPbIUmQ=
github.com/aws/aws-sdk-go-v2/service/kms v1.31.0/go.mod h1:2snWQJQUKsbN66vAawJuOGX7dr37pfOq9hb0tZDGIqQ=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.6.0/go.mod h1:B+7C5UKdVq1ylkI/A6O8wcurFtaux0R1njePNPtKwoA=
github.com/aws/aws-sdk-go-v2/service/ssm v1.10.0/go.mod h1:4dXS5YNqI3SNbetQ7X7vfsMlX6ZnboJA2dulBwJx7+g=
github.com/aws/aws-sdk-go-v2/service/sso v1.4.0/go.mod h1:+1fpWnL96DL23aXPpMGbsmKe8jLTEfbjuQoA4WS1VaA=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow=
github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8=
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 h1:vN8hEbpRnL7+Hopy9dzmRle1xmDc7o8tmY0klsr175w=
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2KCuY61kzoCpvtvJJBtOE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak=
github.com/aws/aws-sdk-go-v2/service/sts v1.7.0/go.mod h1:0qcSMCyASQPN2sk/1KQLQ2Fh6yq8wm0HSDAimPhzCoM=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0=
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U=
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU=
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw=
github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/beevik/etree v1.3.0 h1:hQTc+pylzIKDb23yYprodCWWTt+ojFfUZyzU09a/hmU=
@ -336,8 +340,8 @@ github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
@ -668,8 +672,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksP
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
github.com/googleapis/gax-go/v2 v2.12.2 h1:mhN09QQW1jEWeMF74zGR81R30z4VJzjZsfkUhuHF+DA=
github.com/googleapis/gax-go/v2 v2.12.2/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc=
github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg=
github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/goreleaser/chglog v0.1.2 h1:tdzAb/ILeMnphzI9zQ7Nkq+T8R9qyXli8GydD8plFRY=
github.com/goreleaser/chglog v0.1.2/go.mod h1:tTZsFuSZK4epDXfjMkxzcGbrIOXprf0JFp47BjIr3B8=
@ -805,8 +809,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab h1:KVR7cs+oPyy85i+8t1ZaNSy1bymCy5FuWyt51pdrXu4=
github.com/kolide/kit v0.0.0-20221107170827-fb85e3d59eab/go.mod h1:OYYulo9tUqRadRLwB0+LE914sa1ui2yL7OrcU3Q/1XY=
github.com/kolide/launcher v1.0.12 h1:f2uT1kKYGIbj/WVsHDc10f7MIiwu8MpmgwaGaT7D09k=
@ -944,8 +948,8 @@ github.com/open-policy-agent/opa v0.44.0/go.mod h1:YpJaFIk5pq89n/k72c1lVvfvR5uop
github.com/opencensus-integrations/ocsql v0.1.1/go.mod h1:ozPYpNVBHZsX33jfoQPO5TlI5lqh0/3R36kirEqJKAM=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0-rc6 h1:XDqvyKsJEbRtATzkgItUqBA7QHk58yxX1Ov9HERHNqU=
github.com/opencontainers/image-spec v1.1.0-rc6/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/oschwald/geoip2-golang v1.8.0 h1:KfjYB8ojCEn/QLqsDU0AzrJ3R5Qa9vFlx3z6SLNcKTs=
github.com/oschwald/geoip2-golang v1.8.0/go.mod h1:R7bRvYjOeaoenAp9sKRS8GX5bJWcZ0laWO5+DauEktw=
github.com/oschwald/maxminddb-golang v1.10.0 h1:Xp1u0ZhqkSuopaKmk1WwHtjF0H9Hd9181uj2MQ5Vndg=
@ -1024,8 +1028,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/saferwall/pe v1.5.2 h1:h5lLtLsyxGHQ9dN6cd8EfeLEBEo5gdqJpkuw4o4vTMY=
github.com/saferwall/pe v1.5.2/go.mod h1:SNzv3cdgk8SBI0UwHfyTcdjawfdnN+nbydnEL7GZ25s=
github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4=
github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k=
github.com/sassoftware/relic/v8 v8.0.1 h1:uYUoaoTQMs67up8/46NgrSxSftgfY4VWBusDVg56k7I=
github.com/sassoftware/relic/v8 v8.0.1/go.mod h1:s/MwugRcovgYcNJNOyvLfqRHDX7iArHtFtUR9kEodz8=
github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9 h1:rc/CcqLH3lh8n+csdOuDfP+NuykE0U6AeYSJJHKDgSg=
github.com/scjalliance/comshim v0.0.0-20230315213746-5e51f40bd3b9/go.mod h1:a/83NAfUXvEuLpmxDssAXxgUgrEy12MId3Wd7OTs76s=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
@ -1142,8 +1146,8 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/urfave/cli/v2 v2.23.5 h1:xbrU7tAYviSpqeR3X4nEFWUdB/uDZ6DE+HxmRU7Xtyw=
github.com/urfave/cli/v2 v2.23.5/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/vartanbeno/go-reddit/v2 v2.0.0 h1:fxYMqx5lhbmJ3yYRN1nnQC/gecRB3xpUS2BbG7GLpsk=
@ -1655,8 +1659,8 @@ google.golang.org/api v0.52.0/go.mod h1:Him/adpjt0sxtkWViy0b6xyKW/SD71CwdJ7HqJo7
google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
google.golang.org/api v0.169.0 h1:QwWPy71FgMWqJN/l6jVlFHUa29a7dcUy02I8o799nPY=
google.golang.org/api v0.169.0/go.mod h1:gpNOiMA2tZ4mf5R9Iwf4rK/Dcz0fbdIgWYWVoxmsyLg=
google.golang.org/api v0.178.0 h1:yoW/QMI4bRVCHF+NWOTa4cL8MoWL3Jnuc7FlcFF91Ok=
google.golang.org/api v0.178.0/go.mod h1:84/k2v8DFpDRebpGcooklv/lais3MEfqpaBLA12gl2U=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -1726,8 +1730,8 @@ google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEc
google.golang.org/genproto v0.0.0-20210825212027-de86158e7fda/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y=
google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:mqHbVIp48Muh7Ywss/AD6I5kNVKZMmAa/QEW58Gxp2s=
google.golang.org/genproto v0.0.0-20240506185236-b8a5c65736ae h1:HjgkYCl6cWQEKSHkpUp4Q8VB74swzyBwTz1wtTzahm0=
google.golang.org/genproto v0.0.0-20240506185236-b8a5c65736ae/go.mod h1:i4np6Wrjp8EujFAUn0CM0SH+iZhY1EbrfzEIJbFkHFM=
google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 h1:0+ozOGcrp+Y8Aq8TLNN2Aliibms5LEzsq99ZZmAGYm0=
google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094/go.mod h1:fJ/e3If/Q67Mj99hin0hMhiNyCRmt6BQ2aWIJshUSJw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA=

View file

@ -33,9 +33,9 @@ func ExtractDebMetadata(r io.Reader) (*InstallerMetadata, error) {
return nil, fmt.Errorf("failed to advance to next file in archive: %w", err)
}
name := path.Clean(hdr.Name)
if strings.HasPrefix(name, "control.tar") {
ext := filepath.Ext(name)
filename := path.Clean(hdr.Name)
if strings.HasPrefix(filename, "control.tar") {
ext := filepath.Ext(filename)
if ext == ".tar" {
ext = ""
}
@ -49,9 +49,10 @@ func ExtractDebMetadata(r io.Reader) (*InstallerMetadata, error) {
return nil, fmt.Errorf("failed to read all content: %w", err)
}
return &InstallerMetadata{
Name: name,
Version: version,
SHASum: h.Sum(nil),
Name: name,
Version: version,
PackageIDs: []string{name},
SHASum: h.Sum(nil),
}, nil
}
}

View file

@ -25,6 +25,7 @@ type InstallerMetadata struct {
BundleIdentifier string
SHASum []byte
Extension string
PackageIDs []string
}
// ExtractInstallerMetadata extracts the software name and version from the

View file

@ -61,3 +61,32 @@ func GetRemoveScript(extension string) string {
return ""
}
}
//go:embed scripts/uninstall_exe.ps1
var uninstallExeScript string
//go:embed scripts/uninstall_pkg.sh
var uninstallPkgScript string
//go:embed scripts/uninstall_msi.ps1
var uninstallMsiScript string
//go:embed scripts/uninstall_deb.sh
var uninstallDebScript string
// GetUninstallScript returns a script that can be used to uninstall a
// software item with the given extension.
func GetUninstallScript(extension string) string {
switch extension {
case "msi":
return uninstallMsiScript
case "deb":
return uninstallDebScript
case "pkg":
return uninstallPkgScript
case "exe":
return uninstallExeScript
default:
return ""
}
}

View file

@ -7,6 +7,7 @@ import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -19,35 +20,43 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
// Note: to update the goldens, run the tests with `-update`:
// Note: to update the goldens, delete testdata/scripts/* and run the tests with `-update`:
//
// go test ./pkg/file/... -update
func TestGetInstallAndRemoveScript(t *testing.T) {
scriptsByType := map[string][2]string{
t.Parallel()
scriptsByType := map[string]map[string]string{
"msi": {
"./scripts/install_msi.ps1",
"./scripts/remove_msi.ps1",
"install": "./scripts/install_msi.ps1",
"remove": "./scripts/remove_msi.ps1",
"uninstall": "./scripts/uninstall_msi.ps1",
},
"pkg": {
"./scripts/install_pkg.sh",
"./scripts/remove_pkg.sh",
"install": "./scripts/install_pkg.sh",
"remove": "./scripts/remove_pkg.sh",
"uninstall": "./scripts/uninstall_pkg.sh",
},
"deb": {
"./scripts/install_deb.sh",
"./scripts/remove_deb.sh",
"install": "./scripts/install_deb.sh",
"remove": "./scripts/remove_deb.sh",
"uninstall": "./scripts/uninstall_deb.sh",
},
"exe": {
"./scripts/install_exe.ps1",
"./scripts/remove_exe.ps1",
"install": "./scripts/install_exe.ps1",
"remove": "./scripts/remove_exe.ps1",
"uninstall": "./scripts/uninstall_exe.ps1",
},
}
for itype, scripts := range scriptsByType {
gotScript := GetInstallScript(itype)
assertGoldenMatches(t, scripts[0], gotScript, *update)
assertGoldenMatches(t, scripts["install"], gotScript, *update)
gotScript = GetRemoveScript(itype)
assertGoldenMatches(t, scripts[1], gotScript, *update)
assertGoldenMatches(t, scripts["remove"], gotScript, *update)
gotScript = GetUninstallScript(itype)
assertGoldenMatches(t, scripts["uninstall"], gotScript, *update)
}
}
@ -67,5 +76,5 @@ func assertGoldenMatches(t *testing.T, goldenFile string, actual string, update
content, err := io.ReadAll(f)
require.NoError(t, err)
require.Equal(t, string(content), actual)
assert.Equal(t, string(content), actual)
}

View file

@ -9,7 +9,7 @@ import (
"io"
"strings"
"github.com/sassoftware/relic/v7/lib/comdoc"
"github.com/sassoftware/relic/v8/lib/comdoc"
)
func ExtractMSIMetadata(r io.Reader) (*InstallerMetadata, error) {
@ -77,10 +77,12 @@ func ExtractMSIMetadata(r io.Reader) (*InstallerMetadata, error) {
return nil, err
}
// MSI installer product information properties: https://learn.microsoft.com/en-us/windows/win32/msi/property-reference#product-information-properties
return &InstallerMetadata{
Name: strings.TrimSpace(props["ProductName"]),
Version: strings.TrimSpace(props["ProductVersion"]),
SHASum: h.Sum(nil),
Name: strings.TrimSpace(props["ProductName"]),
Version: strings.TrimSpace(props["ProductVersion"]),
PackageIDs: []string{strings.TrimSpace(props["ProductCode"])},
SHASum: h.Sum(nil),
}, nil
}

View file

@ -50,10 +50,12 @@ func ExtractPEMetadata(r io.Reader) (*InstallerMetadata, error) {
if err != nil {
return nil, fmt.Errorf("error parsing PE version resources: %w", err)
}
name := strings.TrimSpace(v["ProductName"])
return applySpecialCases(&InstallerMetadata{
Name: strings.TrimSpace(v["ProductName"]),
Version: strings.TrimSpace(v["ProductVersion"]),
SHASum: h.Sum(nil),
Name: name,
Version: strings.TrimSpace(v["ProductVersion"]),
PackageIDs: []string{name},
SHASum: h.Sum(nil),
}, v), nil
}

21
pkg/file/pe_test.go Normal file
View file

@ -0,0 +1,21 @@
package file
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractPEMetadata(t *testing.T) {
t.Parallel()
file, err := os.Open("testdata/software-installers/hello-world-installer.exe")
require.NoError(t, err)
meta, err := ExtractPEMetadata(file)
require.NoError(t, err)
require.NotNil(t, meta)
assert.Equal(t, "Hello world", meta.Name)
assert.Equal(t, "1.0.0", meta.Version)
assert.Equal(t, []string{"Hello world"}, meta.PackageIDs)
}

View file

@ -1,16 +1,20 @@
# Learn more about .exe install scripts: http://fleetdm.com/learn-more-about/exe-install-scripts
$exeFilePath = "${env:INSTALLER_PATH}"
# extract the name of the executable to use as the sub-directory name
$exeName = [System.IO.Path]::GetFileName($exeFilePath)
$subDir = [System.IO.Path]::GetFileNameWithoutExtension($exeFilePath)
$destinationPath = Join-Path -Path $env:ProgramFiles -ChildPath $subDir
# check if the directory does not exist, and create it if necessary
if (-not (Test-Path -Path $destinationPath)) {
New-Item -ItemType Directory -Path $destinationPath
# Add argument to install silently
# Argument to make install silent depends on installer,
# each installer might use different argument (usually it's "/S" or "/s")
$processOptions = @{
FilePath = "$exeFilePath"
ArgumentList = "/S"
PassThru = $true
Wait = $true
}
# Start process and track exit code
$process = Start-Process @processOptions
$exitCode = $process.ExitCode
# copy the .exe file to the new sub-directory
$destinationExePath = Join-Path -Path $destinationPath -ChildPath $exeName
Copy-Item -Path $exeFilePath -Destination $destinationExePath
# Prints the exit code
Write-Host "Install exit code: $exitCode"

View file

@ -0,0 +1,4 @@
package_name=$PACKAGE_ID
# Fleet uninstalls app using product name that's extracted on upload
apt remove "$package_name" -y

View file

@ -0,0 +1,17 @@
# Fleet extracts name from installer (EXE) and saves it to package ID variable
$softwareName = $PACKAGE_ID
# Get the list of subkeys under the Uninstall registry path
$uninstallKeys = Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall" | ForEach-Object { Get-ItemProperty $_.PSPath }
# Loop through each registry key to find the one containing "$softwareName" in DisplayName and run uninstall command from UninstallString
foreach ($key in $uninstallKeys) {
if ($key.DisplayName -like "*$softwareName*") {
# Get the uninstall command
$uninstallCommand = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
# Run the uninstall command with arguments using the call operator &
& $uninstallCommand
break # Exit the loop once the software is found and uninstalled
}
}

View file

@ -0,0 +1,4 @@
$product_code = $PACKAGE_ID
# Fleet uninstalls app using product code that's extracted on upload
msiexec /quiet /x $product_code

View file

@ -0,0 +1,21 @@
#!/bin/sh
# Fleet extracts and saves package IDs.
pkg_ids=$PACKAGE_ID
# Get all files associated with package and remove them
for pkg_id in "${pkg_ids[@]}"
do
# Get volume and location of package
volume=$(pkgutil --pkg-info "$pkg_id" | grep -i "volume" | awk '{for (i=2; i<NF; i++) printf $i " "; print $NF}')
location=$(pkgutil --pkg-info "$pkg_id" | grep -i "location" | awk '{for (i=2; i<NF; i++) printf $i " "; print $NF}')
# Check if this package id corresponds to a valid/installed package
if [[ ! -z "$volume" && ! -z "$location" ]]; then
# Remove individual files/directories belonging to package
pkgutil --files "$pkg_id" | sed -e 's@^@'"$volume""$location"'/@' | tr '\n' '\0' | xargs -n 1 -0 rm -rf
# Remove receipts
pkgutil --forget "$pkg_id"
else
echo "WARNING: volume or location are empty for package ID $pkg_id"
fi
done

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<installer-gui-script minSpecVersion="2">
<pkg-ref id="com.bozo.zeroinstallsize" installKBytes="0" packageIdentifier="com.bozo.zeroinstallsize.app">
<bundle-version>
<bundle CFBundleShortVersionString="1.2.3" CFBundleVersion="8.10.34.234040" id="com.bozo.zeroinstallsize" path="ZeroInstallSize.app"/>
</bundle-version>
</pkg-ref>
<product id="com.bozo.zeroinstallsize" version="1.2.3"/>
<title>ZeroInstallSize</title>
</installer-gui-script>

View file

@ -1,16 +1,20 @@
# Learn more about .exe install scripts: http://fleetdm.com/learn-more-about/exe-install-scripts
$exeFilePath = "${env:INSTALLER_PATH}"
# extract the name of the executable to use as the sub-directory name
$exeName = [System.IO.Path]::GetFileName($exeFilePath)
$subDir = [System.IO.Path]::GetFileNameWithoutExtension($exeFilePath)
$destinationPath = Join-Path -Path $env:ProgramFiles -ChildPath $subDir
# check if the directory does not exist, and create it if necessary
if (-not (Test-Path -Path $destinationPath)) {
New-Item -ItemType Directory -Path $destinationPath
# Add argument to install silently
# Argument to make install silent depends on installer,
# each installer might use different argument (usually it's "/S" or "/s")
$processOptions = @{
FilePath = "$exeFilePath"
ArgumentList = "/S"
PassThru = $true
Wait = $true
}
# Start process and track exit code
$process = Start-Process @processOptions
$exitCode = $process.ExitCode
# copy the .exe file to the new sub-directory
$destinationExePath = Join-Path -Path $destinationPath -ChildPath $exeName
Copy-Item -Path $exeFilePath -Destination $destinationExePath
# Prints the exit code
Write-Host "Install exit code: $exitCode"

View file

@ -0,0 +1,4 @@
package_name=$PACKAGE_ID
# Fleet uninstalls app using product name that's extracted on upload
apt remove "$package_name" -y

View file

@ -0,0 +1,17 @@
# Fleet extracts name from installer (EXE) and saves it to package ID variable
$softwareName = $PACKAGE_ID
# Get the list of subkeys under the Uninstall registry path
$uninstallKeys = Get-ChildItem "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall" | ForEach-Object { Get-ItemProperty $_.PSPath }
# Loop through each registry key to find the one containing "$softwareName" in DisplayName and run uninstall command from UninstallString
foreach ($key in $uninstallKeys) {
if ($key.DisplayName -like "*$softwareName*") {
# Get the uninstall command
$uninstallCommand = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
# Run the uninstall command with arguments using the call operator &
& $uninstallCommand
break # Exit the loop once the software is found and uninstalled
}
}

View file

@ -0,0 +1,4 @@
$product_code = $PACKAGE_ID
# Fleet uninstalls app using product code that's extracted on upload
msiexec /quiet /x $product_code

View file

@ -0,0 +1,21 @@
#!/bin/sh
# Fleet extracts and saves package IDs.
pkg_ids=$PACKAGE_ID
# Get all files associated with package and remove them
for pkg_id in "${pkg_ids[@]}"
do
# Get volume and location of package
volume=$(pkgutil --pkg-info "$pkg_id" | grep -i "volume" | awk '{for (i=2; i<NF; i++) printf $i " "; print $NF}')
location=$(pkgutil --pkg-info "$pkg_id" | grep -i "location" | awk '{for (i=2; i<NF; i++) printf $i " "; print $NF}')
# Check if this package id corresponds to a valid/installed package
if [[ ! -z "$volume" && ! -z "$location" ]]; then
# Remove individual files/directories belonging to package
pkgutil --files "$pkg_id" | sed -e 's@^@'"$volume""$location"'/@' | tr '\n' '\0' | xargs -n 1 -0 rm -rf
# Remove receipts
pkgutil --forget "$pkg_id"
else
echo "WARNING: volume or location are empty for package ID $pkg_id"
fi
done

View file

@ -0,0 +1 @@
- `hello-world-installer.exe` is an installer with a text file. It was created using [Inno Setup](https://jrsoftware.org/isinfo.php) on Windows.

Binary file not shown.

View file

@ -123,6 +123,7 @@ type distributionPkgRef struct {
BundleVersions []distributionBundleVersion `xml:"bundle-version"`
MustClose distributionMustClose `xml:"must-close"`
PackageIdentifier string `xml:"packageIdentifier,attr"`
InstallKBytes string `xml:"installKBytes,attr"`
}
// distributionBundleVersion represents the bundle-version element
@ -223,21 +224,55 @@ func parseDistributionFile(rawXML []byte) (*InstallerMetadata, error) {
return nil, fmt.Errorf("unmarshal Distribution XML: %w", err)
}
name, identifier, version := getDistributionInfo(&distXML)
name, identifier, version, packageIDs := getDistributionInfo(&distXML)
return &InstallerMetadata{
Name: name,
Version: version,
BundleIdentifier: identifier,
PackageIDs: packageIDs,
}, nil
}
// getDistributionInfo gets the name, bundle identifier and version of a PKG distribution file
func getDistributionInfo(d *distributionXML) (name string, identifier string, version string) {
func getDistributionInfo(d *distributionXML) (name string, identifier string, version string, packageIDs []string) {
var appVersion string
// find the package ids that have an installation size
var packageIDSet = make(map[string]struct{}, 1)
for _, pkg := range d.PkgRefs {
if pkg.InstallKBytes != "" && pkg.InstallKBytes != "0" {
var id string
if pkg.PackageIdentifier != "" {
id = pkg.PackageIdentifier
} else if pkg.ID != "" {
id = pkg.ID
}
if id != "" {
packageIDSet[id] = struct{}{}
}
}
}
if len(packageIDSet) == 0 {
// if we didn't find any package IDs with installation size, then grab all of them
for _, pkg := range d.PkgRefs {
var id string
if pkg.PackageIdentifier != "" {
id = pkg.PackageIdentifier
} else if pkg.ID != "" {
id = pkg.ID
}
if id != "" {
packageIDSet[id] = struct{}{}
}
}
}
for id := range packageIDSet {
packageIDs = append(packageIDs, id)
}
out:
// first, look in all the bundle versions for one that has a `path` attribute
// look in all the bundle versions for one that has a `path` attribute
// that is not nested, this is generally the case for packages that distribute
// `.app` files, which are ultimately picked up as an installed app by osquery
for _, pkg := range d.PkgRefs {
@ -284,6 +319,11 @@ out:
identifier = d.Product.ID
}
// if package IDs are still empty, use the identifier as the package ID
if len(packageIDs) == 0 && identifier != "" {
packageIDs = append(packageIDs, identifier)
}
// for the name, try to use the title and fallback to the bundle
// identifier
if name == "" && d.Title != "" {
@ -296,7 +336,7 @@ out:
// for the version, try to use the top-level product version, if not,
// fallback to any version definition alongside the name or the first
// version in a pkg-ref we find.
if version == "" && d.Product.Version != "" {
if d.Product.Version != "" {
version = d.Product.Version
}
if version == "" && appVersion != "" {
@ -310,7 +350,7 @@ out:
}
}
return name, identifier, version
return name, identifier, version, packageIDs
}
// isValidAppFilePath checks if the given input is a file name ending with .app

View file

@ -8,6 +8,7 @@ import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -52,113 +53,147 @@ func TestCheckPKGSignature(t *testing.T) {
}
func TestParseRealDistributionFiles(t *testing.T) {
t.Parallel()
tests := []struct {
file string
expectedName string
expectedVersion string
expectedBundleID string
file string
expectedName string
expectedVersion string
expectedBundleID string
expectedPackageIDs []string
}{
{
file: "distribution-1password.xml",
expectedName: "1Password.app",
expectedVersion: "8.10.34",
expectedBundleID: "com.1password.1password",
file: "distribution-1password.xml",
expectedName: "1Password.app",
expectedVersion: "8.10.34",
expectedBundleID: "com.1password.1password",
expectedPackageIDs: []string{"com.1password.1password"},
},
{
file: "distribution-chrome.xml",
expectedName: "Google Chrome.app",
expectedVersion: "126.0.6478.62",
expectedBundleID: "com.google.Chrome",
file: "distribution-chrome.xml",
expectedName: "Google Chrome.app",
expectedVersion: "126.0.6478.62",
expectedBundleID: "com.google.Chrome",
expectedPackageIDs: []string{"com.google.Chrome"},
},
{
file: "distribution-edge.xml",
expectedName: "Microsoft Edge.app",
expectedVersion: "126.0.2592.56",
expectedBundleID: "com.microsoft.edgemac",
file: "distribution-edge.xml",
expectedName: "Microsoft Edge.app",
expectedVersion: "126.0.2592.56",
expectedBundleID: "com.microsoft.edgemac",
expectedPackageIDs: []string{"com.microsoft.edgemac"},
},
{
file: "distribution-firefox.xml",
expectedName: "Firefox.app",
expectedVersion: "99.0",
expectedBundleID: "org.mozilla.firefox",
file: "distribution-firefox.xml",
expectedName: "Firefox.app",
expectedVersion: "99.0",
expectedBundleID: "org.mozilla.firefox",
expectedPackageIDs: []string{"org.mozilla.firefox"},
},
{
file: "distribution-fleet.xml",
expectedName: "Fleet osquery",
expectedVersion: "42.0.0",
expectedBundleID: "com.fleetdm.orbit",
file: "distribution-fleet.xml",
expectedName: "Fleet osquery",
expectedVersion: "42.0.0",
expectedBundleID: "com.fleetdm.orbit",
expectedPackageIDs: []string{"com.fleetdm.orbit.base.pkg"},
},
{
file: "distribution-go.xml",
expectedName: "Go",
expectedVersion: "go1.22.4",
expectedBundleID: "org.golang.go",
file: "distribution-go.xml",
expectedName: "Go",
expectedVersion: "go1.22.4",
expectedBundleID: "org.golang.go",
expectedPackageIDs: []string{"org.golang.go"},
},
{
file: "distribution-microsoft-teams.xml",
expectedName: "Microsoft Teams.app",
expectedVersion: "24124.1412.2911.3341",
expectedBundleID: "com.microsoft.teams2",
expectedPackageIDs: []string{"com.microsoft.teams2", "com.microsoft.package.Microsoft_AutoUpdate.app",
"com.microsoft.MSTeamsAudioDevice"},
},
{
file: "distribution-zoom.xml",
expectedName: "zoom.us.app",
expectedVersion: "6.0.11.35001",
expectedBundleID: "us.zoom.xos",
file: "distribution-zoom.xml",
expectedName: "zoom.us.app",
expectedVersion: "6.0.11.35001",
expectedBundleID: "us.zoom.xos",
expectedPackageIDs: []string{"us.zoom.pkg.videomeeting"},
},
{
file: "distribution-acrobatreader.xml",
expectedName: "Adobe Acrobat Reader.app",
expectedVersion: "24.002.20857",
expectedBundleID: "com.adobe.Reader",
expectedPackageIDs: []string{"com.adobe.acrobat.DC.reader.app.pkg.MUI", "com.adobe.acrobat.DC.reader.appsupport.pkg.MUI",
"com.adobe.acrobat.reader.DC.reader.app.pkg.MUI", "com.adobe.armdc.app.pkg"},
},
{
file: "distribution-airtame.xml",
expectedName: "Airtame.app",
expectedVersion: "4.10.1",
expectedBundleID: "com.airtame.airtame-application",
file: "distribution-airtame.xml",
expectedName: "Airtame.app",
expectedVersion: "4.10.1",
expectedBundleID: "com.airtame.airtame-application",
expectedPackageIDs: []string{"com.airtame.airtame-application"},
},
{
file: "distribution-boxdrive.xml",
expectedName: "Box.app",
expectedVersion: "2.38.173",
expectedBundleID: "com.box.desktop",
expectedPackageIDs: []string{"com.box.desktop.installer.desktop", "com.box.desktop.installer.local.appsupport",
"com.box.desktop.installer.autoupdater", "com.box.desktop.installer.osxfuse"},
},
{
file: "distribution-iriunwebcam.xml",
expectedName: "IriunWebcam.app",
expectedVersion: "2.8.8",
expectedBundleID: "com.iriun.macwebcam",
// Note: "com.iriun.pkg.multicam" is part of the installer package, but it is not actually installed by default.
// We can't reliably determine which packages are installed by the installer, so we just list all of them.
expectedPackageIDs: []string{"com.iriun.pkg.webcam.tmp", "com.iriun.pkg.multicam"},
},
{
file: "distribution-microsoftexcel.xml",
expectedName: "Microsoft Excel.app",
expectedVersion: "16.86",
expectedBundleID: "com.microsoft.Excel",
expectedPackageIDs: []string{"com.microsoft.package.Microsoft_Excel.app", "com.microsoft.package.Microsoft_AutoUpdate.app",
"com.microsoft.pkg.licensing"},
},
{
file: "distribution-microsoftword.xml",
expectedName: "Microsoft Word.app",
expectedVersion: "16.86",
expectedBundleID: "com.microsoft.Word",
expectedPackageIDs: []string{"com.microsoft.package.Microsoft_Word.app", "com.microsoft.package.Microsoft_AutoUpdate.app",
"com.microsoft.pkg.licensing"},
},
{
file: "distribution-miscrosoftpowerpoint.xml",
expectedName: "Microsoft PowerPoint.app",
expectedVersion: "16.86",
expectedBundleID: "com.microsoft.Powerpoint",
expectedPackageIDs: []string{"com.microsoft.package.Microsoft_PowerPoint.app", "com.microsoft.package.Microsoft_AutoUpdate.app",
"com.microsoft.pkg.licensing"},
},
{
file: "distribution-ringcentral.xml",
expectedName: "RingCentral.app",
expectedVersion: "24.1.32.9774",
expectedBundleID: "com.ringcentral.glip",
file: "distribution-ringcentral.xml",
expectedName: "RingCentral.app",
expectedVersion: "24.1.32.9774",
expectedBundleID: "com.ringcentral.glip",
expectedPackageIDs: []string{"com.ringcentral.glip"},
},
{
file: "distribution-zoom-full.xml",
expectedName: "Zoom Workplace",
expectedVersion: "6.1.1.36333",
expectedBundleID: "us.zoom.xos",
file: "distribution-zoom-full.xml",
expectedName: "Zoom Workplace",
expectedVersion: "6.1.1.36333",
expectedBundleID: "us.zoom.xos",
expectedPackageIDs: []string{"us.zoom.pkg.videomeeting"},
},
{
file: "test-zero-installkbytes.xml",
expectedName: "ZeroInstallSize.app",
expectedVersion: "1.2.3",
expectedBundleID: "com.bozo.zeroinstallsize",
expectedPackageIDs: []string{"com.bozo.zeroinstallsize.app"},
},
}
@ -168,6 +203,7 @@ func TestParseRealDistributionFiles(t *testing.T) {
require.NoError(t, err)
metadata, err := parseDistributionFile(rawXML)
require.NoError(t, err)
assert.ElementsMatch(t, tt.expectedPackageIDs, metadata.PackageIDs)
require.Equal(t, tt.expectedName, metadata.Name)
require.Equal(t, tt.expectedVersion, metadata.Version)
require.Equal(t, tt.expectedBundleID, metadata.BundleIdentifier)

View file

@ -677,7 +677,7 @@ allow {
# Host software installs
##
# Global admins and maintainers can write (install) software on hosts (not
# Global admins and maintainers can write (install/uninstall) software on hosts (not
# gitops as this is not something that relates to fleetctl apply).
allow {
object.type == "host_software_installer_result"
@ -685,7 +685,7 @@ allow {
action == write
}
# Team admin and maintainers can write (install) software on hosts for their
# Team admin and maintainers can write (install/uninstall) software on hosts for their
# teams (not gitops as this is not something that relates to fleetctl apply).
allow {
object.type == "host_software_installer_result"
@ -937,7 +937,7 @@ allow {
action == write
}
# Global admins, maintainers, observer_plus and observers can read scripts.
# Global admins, maintainers, observer_plus and observers can read script results, including software uninstall results.
allow {
object.type == "host_script_result"
subject.global_role == [admin, maintainer, observer, observer_plus][_]
@ -953,7 +953,7 @@ allow {
action == write
}
# Team admins, maintainers, observer_plus and observers can read scripts for their teams.
# Team admins, maintainers, observer_plus and observers can read script results for their teams, including software uninstall results.
allow {
object.type == "host_script_result"
not is_null(object.team_id)

View file

@ -242,15 +242,22 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
`SELECT
COUNT(*) c
FROM host_script_results hsr
LEFT OUTER JOIN
host_software_installs hsi ON hsi.execution_id = hsr.execution_id
WHERE hsr.host_id = :host_id AND
exit_code IS NULL AND
(sync_request = 0 OR created_at >= DATE_SUB(NOW(), INTERVAL :max_wait_time SECOND))`,
hsi.execution_id IS NULL AND
(sync_request = 0 OR hsr.created_at >= DATE_SUB(NOW(), INTERVAL :max_wait_time SECOND))`,
`SELECT
COUNT(*) c
FROM host_software_installs hsi
WHERE hsi.host_id = :host_id AND
pre_install_query_output IS NULL AND
install_script_exit_code IS NULL`,
hsi.status = :software_status_install_pending`,
`SELECT
COUNT(*) c
FROM host_software_installs hsi
WHERE hsi.host_id = :host_id AND
hsi.status = :software_status_uninstall_pending`,
`
SELECT
COUNT(*) c
@ -265,8 +272,10 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
seconds := int(scripts.MaxServerWaitTime.Seconds())
countStmt, args, err := sqlx.Named(countStmt, map[string]any{
"host_id": hostID,
"max_wait_time": seconds,
"host_id": hostID,
"max_wait_time": seconds,
"software_status_install_pending": fleet.SoftwareInstallPending,
"software_status_uninstall_pending": fleet.SoftwareUninstallPending,
})
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "build count query from named args")
@ -305,13 +314,16 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
host_display_names hdn ON hdn.host_id = hsr.host_id
LEFT OUTER JOIN
scripts scr ON scr.id = hsr.script_id
LEFT OUTER JOIN
host_software_installs hsi ON hsi.execution_id = hsr.execution_id
WHERE
hsr.host_id = :host_id AND
hsr.exit_code IS NULL AND
(
hsr.sync_request = 0 OR
hsr.created_at >= DATE_SUB(NOW(), INTERVAL :max_wait_time SECOND)
)
) AND
hsi.execution_id IS NULL
`,
// list pending software installs
fmt.Sprintf(`SELECT
@ -330,7 +342,7 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
'software_title', COALESCE(st.name, ''),
'software_package', si.filename,
'install_uuid', hsi.execution_id,
'status', CAST(%s AS CHAR),
'status', CAST(hsi.status AS CHAR),
'self_service', si.self_service IS TRUE
) as details
FROM
@ -347,9 +359,42 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
host_display_names hdn ON hdn.host_id = hsi.host_id
WHERE
hsi.host_id = :host_id AND
hsi.pre_install_query_output IS NULL AND
hsi.install_script_exit_code IS NULL
`, softwareInstallerHostStatusNamedQuery("hsi", "")),
hsi.status = :software_status_install_pending
`),
// list pending software uninstalls
fmt.Sprintf(`SELECT
hsi.execution_id as uuid,
-- policies with automatic installers generate a host_software_installs with (user_id=NULL,self_service=0),
-- thus the user_id for the upcoming activity needs to be the user that uploaded the software installer.
IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.name, u.name) AS name,
IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.id, u.id) as user_id,
IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.gravatar_url, u.gravatar_url) as gravatar_url,
IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.email, u.email) AS user_email,
:uninstalled_software_type as activity_type,
hsi.created_at as created_at,
JSON_OBJECT(
'host_id', hsi.host_id,
'host_display_name', COALESCE(hdn.display_name, ''),
'software_title', COALESCE(st.name, ''),
'script_execution_id', hsi.execution_id,
'status', CAST(hsi.status AS CHAR)
) as details
FROM
host_software_installs hsi
INNER JOIN
software_installers si ON si.id = hsi.software_installer_id
LEFT OUTER JOIN
software_titles st ON st.id = si.title_id
LEFT OUTER JOIN
users u ON u.id = hsi.user_id
LEFT OUTER JOIN
users u2 ON u2.id = si.user_id
LEFT OUTER JOIN
host_display_names hdn ON hdn.host_id = hsi.host_id
WHERE
hsi.host_id = :host_id AND
hsi.status = :software_status_uninstall_pending
`),
`
SELECT
hvsi.command_uuid AS uuid,
@ -367,7 +412,7 @@ SELECT
'command_uuid', hvsi.command_uuid,
'self_service', hvsi.self_service IS TRUE,
-- status is always pending because only pending MDM commands are upcoming.
'status', :software_status_pending
'status', :software_status_install_pending
) AS details
FROM
host_vpp_software_installs hvsi
@ -399,14 +444,14 @@ WHERE
details
FROM ( ` + strings.Join(listStmts, " UNION ALL ") + ` ) AS upcoming `
listStmt, args, err = sqlx.Named(listStmt, map[string]any{
"host_id": hostID,
"ran_script_type": fleet.ActivityTypeRanScript{}.ActivityName(),
"installed_software_type": fleet.ActivityTypeInstalledSoftware{}.ActivityName(),
"installed_app_store_app_type": fleet.ActivityInstalledAppStoreApp{}.ActivityName(),
"max_wait_time": seconds,
"software_status_failed": string(fleet.SoftwareInstallerFailed),
"software_status_installed": string(fleet.SoftwareInstallerInstalled),
"software_status_pending": string(fleet.SoftwareInstallerPending),
"host_id": hostID,
"ran_script_type": fleet.ActivityTypeRanScript{}.ActivityName(),
"installed_software_type": fleet.ActivityTypeInstalledSoftware{}.ActivityName(),
"uninstalled_software_type": fleet.ActivityTypeUninstalledSoftware{}.ActivityName(),
"installed_app_store_app_type": fleet.ActivityInstalledAppStoreApp{}.ActivityName(),
"max_wait_time": seconds,
"software_status_install_pending": fleet.SoftwareInstallPending,
"software_status_uninstall_pending": fleet.SoftwareUninstallPending,
})
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "build list query from named args")

View file

@ -508,7 +508,8 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
h2A := hsr.ExecutionID
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h2.ID, ScriptContents: "F", UserID: &u.ID})
require.NoError(t, err)
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{HostID: h2.ID, ExecutionID: hsr.ExecutionID, Output: "ok", ExitCode: 0})
_, _, err = ds.SetHostScriptExecutionResult(ctx,
&fleet.HostScriptResultPayload{HostID: h2.ID, ExecutionID: hsr.ExecutionID, Output: "ok", ExitCode: 0})
require.NoError(t, err)
h2F := hsr.ExecutionID
// add a pending software install request for h2

View file

@ -0,0 +1,145 @@
package tables
import (
"database/sql"
"fmt"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/reflectx"
)
func init() {
MigrationClient.AddMigration(Up_20240905200000, Down_20240905200000)
}
const placeholderUninstallScript = "# This script will be automatically updated within the next hour\nexit 1"
const placeholderUninstallScriptWindows = "# This script will be automatically updated within the next hour\nExit 1"
func Up_20240905200000(tx *sql.Tx) error {
if _, err := tx.Exec(`
ALTER TABLE software_installers
ADD COLUMN package_ids TEXT COLLATE utf8mb4_unicode_ci NOT NULL,
ADD COLUMN uninstall_script_content_id int unsigned NOT NULL,
MODIFY COLUMN uploaded_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
`); err != nil {
return fmt.Errorf("failed to alter software_installers: %w", err)
}
txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)}
// Add dummy uninstall scripts if needed -- these will be updated later by a cron job
var result []int
if err := txx.Select(&result, `SELECT 1 FROM software_installers WHERE platform IN ('linux', 'darwin')`); err != nil {
return fmt.Errorf("failed to check software installers for linux or darwin: %w", err)
}
if len(result) > 0 {
linuxScriptID, err := getOrInsertScript(txx, placeholderUninstallScript)
if err != nil {
return err
}
// Update software installers with the scripts
if _, err := tx.Exec(`UPDATE software_installers SET uninstall_script_content_id = ? WHERE platform IN ('linux', 'darwin')`,
linuxScriptID); err != nil {
return fmt.Errorf("failed to update software installers: %w", err)
}
}
if err := txx.Select(&result, `SELECT 1 FROM software_installers WHERE platform IN ('windows')`); err != nil {
return fmt.Errorf("failed to check software installers for windows: %w", err)
}
if len(result) > 0 {
windowsScriptID, err := getOrInsertScript(txx, placeholderUninstallScriptWindows)
if err != nil {
return err
}
// Update software installers with the scripts
if _, err := tx.Exec(`UPDATE software_installers SET uninstall_script_content_id = ? WHERE platform IN ('windows')`,
windowsScriptID); err != nil {
return fmt.Errorf("failed to update windows software installers: %w", err)
}
}
// Add foreign key
if _, err := tx.Exec(`
ALTER TABLE software_installers
ADD CONSTRAINT fk_uninstall_script_content_id
FOREIGN KEY (uninstall_script_content_id)
REFERENCES script_contents(id)
ON DELETE RESTRICT ON UPDATE CASCADE`); err != nil {
return fmt.Errorf("failed to add foreign key to software_installers: %w", err)
}
if _, err := tx.Exec(`
ALTER TABLE host_software_installs
ADD COLUMN uninstall_script_output TEXT COLLATE utf8mb4_unicode_ci,
ADD COLUMN uninstall_script_exit_code INT DEFAULT NULL,
ADD COLUMN uninstall TINYINT UNSIGNED NOT NULL DEFAULT 0,
ADD COLUMN status ENUM('pending_install', 'failed_install', 'installed', 'pending_uninstall', 'failed_uninstall')
GENERATED ALWAYS AS (
CASE
WHEN removed = 1 THEN NULL
WHEN post_install_script_exit_code IS NOT NULL AND
post_install_script_exit_code = 0 THEN 'installed'
WHEN post_install_script_exit_code IS NOT NULL AND
post_install_script_exit_code != 0 THEN 'failed_install'
WHEN install_script_exit_code IS NOT NULL AND
install_script_exit_code = 0 THEN 'installed'
WHEN install_script_exit_code IS NOT NULL AND
install_script_exit_code != 0 THEN 'failed_install'
WHEN pre_install_query_output IS NOT NULL AND
pre_install_query_output = '' THEN 'failed_install'
WHEN host_id IS NOT NULL AND uninstall = 0 THEN 'pending_install'
WHEN uninstall_script_exit_code IS NOT NULL AND
uninstall_script_exit_code != 0 THEN 'failed_uninstall'
WHEN uninstall_script_exit_code IS NOT NULL AND
uninstall_script_exit_code = 0 THEN NULL -- available for install again
WHEN host_id IS NOT NULL AND uninstall = 1 THEN 'pending_uninstall'
ELSE NULL -- not installed from Fleet installer or successfully uninstalled
END
) STORED NULL,
MODIFY COLUMN created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
MODIFY COLUMN updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
MODIFY COLUMN host_deleted_at TIMESTAMP(6) NULL DEFAULT NULL
`); err != nil {
return fmt.Errorf("failed to alter host_software_installs: %w", err)
}
return nil
}
func getOrInsertScript(txx sqlx.Tx, script string) (int64, error) {
var ids []int64
// check is script already exists
csum := md5ChecksumScriptContent(script)
if err := txx.Select(&ids, `SELECT id FROM script_contents WHERE md5_checksum = UNHEX(?)`, csum); err != nil {
return 0, fmt.Errorf("failed to find script contents: %w", err)
}
var scriptID int64
if len(ids) > 0 {
scriptID = ids[0]
} else {
// create new script
var result sql.Result
var err error
if result, err = txx.Exec(`INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(?), ?)`, csum,
script); err != nil {
return 0, fmt.Errorf("failed to insert script contents: %w", err)
}
scriptID, _ = result.LastInsertId()
}
return scriptID, nil
}
func Down_20240905200000(_ *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,101 @@
package tables
import (
"testing"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/reflectx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUp_20240905200000(t *testing.T) {
db := applyUpToPrev(t)
// Create host
insertHostStmt := `
INSERT INTO hosts (
hostname, uuid, platform, osquery_version, os_version, build, platform_like, code_name,
cpu_type, cpu_subtype, cpu_brand, hardware_vendor, hardware_model, hardware_version,
hardware_serial, computer_name, team_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
hostName := "Dummy Hostname"
hostUUID := "12345678-1234-1234-1234-123456789012"
hostPlatform := "darwin"
osqueryVer := "5.9.1"
osVersion := "Windows 10"
buildVersion := "10.0.19042.1234"
platformLike := "apple"
codeName := "20H2"
cpuType := "x86_64"
cpuSubtype := "x86_64"
cpuBrand := "Intel"
hwVendor := "Dell Inc."
hwModel := "OptiPlex 7090"
hwVersion := "1.0"
hwSerial := "ABCDEFGHIJ"
computerName := "DESKTOP-TEST"
hostID1 := execNoErrLastID(t, db, insertHostStmt, hostName, hostUUID, hostPlatform, osqueryVer,
osVersion, buildVersion, platformLike, codeName, cpuType, cpuSubtype, cpuBrand, hwVendor, hwModel, hwVersion, hwSerial,
computerName, nil)
dataStmts := `
INSERT INTO script_contents (id, md5_checksum, contents) VALUES
(1, 'checksum', 'script content');
INSERT INTO software_titles (id, name, source, browser) VALUES
(1, 'Foo.app', 'apps', ''),
(2, 'Go', 'deb_packages', ''),
(3, 'Microsoft Teams.exe', 'programs', '');
INSERT INTO software_installers
(id, title_id, filename, version, platform, install_script_content_id, storage_id)
VALUES
(1, 1, 'foo-installer.pkg', '1.1', 'darwin', 1, 'storage-id'),
(2, 2, 'go-installer.deb', '2.2', 'linux', 1, 'storage-id'),
(3, 3, 'teams-installer.exe', '3.3', 'windows', 1, 'storage-id');
`
_, err := db.Exec(dataStmts)
require.NoError(t, err)
tx, err := db.Begin()
require.NoError(t, err)
txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)}
scriptID, err := getOrInsertScript(txx, placeholderUninstallScript)
require.NoError(t, err)
err = tx.Commit()
require.NoError(t, err)
hsiStmt := `
INSERT INTO host_software_installs (
host_id,
execution_id,
software_installer_id,
install_script_exit_code
) VALUES (?, ?, ?, ?)`
hsi1 := execNoErrLastID(t, db, hsiStmt, hostID1, "execution-id1", 1, 0)
// Apply current migration.
applyNext(t, db)
var scriptIDs []int64
err = db.Select(&scriptIDs, "SELECT uninstall_script_content_id FROM software_installers WHERE id IN (1, 2)")
require.NoError(t, err)
require.ElementsMatch(t, []int64{scriptID, scriptID}, scriptIDs)
var windowsScript string
err = db.Get(&windowsScript, `
SELECT contents FROM script_contents sc
INNER JOIN software_installers si ON sc.id = si.uninstall_script_content_id
WHERE si.id = 3`)
require.NoError(t, err)
assert.Equal(t, placeholderUninstallScriptWindows, windowsScript)
var status string
err = db.Get(&status, "SELECT status FROM host_software_installs WHERE id = ?", hsi1)
require.NoError(t, err)
assert.Equal(t, "installed", status)
}

File diff suppressed because one or more lines are too long

View file

@ -80,7 +80,8 @@ func truncateScriptResult(output string) string {
return output
}
func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult, error) {
func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult,
string, error) {
const resultExistsStmt = `
SELECT
1
@ -105,20 +106,27 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f
const hostMDMActionsStmt = `
SELECT
CASE
WHEN lock_ref = ? THEN 'lock_ref'
WHEN unlock_ref = ? THEN 'unlock_ref'
WHEN wipe_ref = ? THEN 'wipe_ref'
WHEN lock_ref = :execution_id THEN 'lock_ref'
WHEN unlock_ref = :execution_id THEN 'unlock_ref'
WHEN wipe_ref = :execution_id THEN 'wipe_ref'
ELSE ''
END AS ref_col
END AS action
FROM
host_mdm_actions
WHERE
host_id = ?
host_id = :host_id
UNION
SELECT 'uninstall' AS action
FROM
host_software_installs
WHERE
execution_id = :execution_id AND host_id = :host_id
`
output := truncateScriptResult(result.Output)
var hsr *fleet.HostScriptResult
var action string
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
var resultExists bool
err := sqlx.GetContext(ctx, tx, &resultExists, resultExistsStmt, result.HostID, result.ExecutionID)
@ -154,15 +162,31 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f
return ctxerr.Wrap(ctx, err, "load updated host script result")
}
// look up if that script was a lock/unlock/wipe script for that host,
// look up if that script was a lock/unlock/wipe/uninstall script for that host,
// and if so update the host_mdm_actions table accordingly.
var refCol string
err = sqlx.GetContext(ctx, tx, &refCol, hostMDMActionsStmt, result.ExecutionID, result.ExecutionID, result.ExecutionID, result.HostID)
namedArgs := map[string]any{
"host_id": result.HostID,
"execution_id": result.ExecutionID,
}
stmt, args, err := sqlx.Named(hostMDMActionsStmt, namedArgs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build named query for host mdm actions")
}
err = sqlx.GetContext(ctx, tx, &action, stmt, args...)
if err != nil && !errors.Is(err, sql.ErrNoRows) { // ignore ErrNoRows, refCol will be empty
return ctxerr.Wrap(ctx, err, "lookup host script corresponding mdm action")
}
if refCol != "" {
err = updateHostLockWipeStatusFromResult(ctx, tx, result.HostID, refCol, result.ExitCode == 0)
switch action {
case "":
// do nothing
case "uninstall":
err = updateUninstallStatusFromResult(ctx, tx, result.HostID, result.ExecutionID, result.ExitCode)
if err != nil {
return ctxerr.Wrap(ctx, err, "update host uninstall action based on script result")
}
default: // lock/unlock/wipe
err = updateHostLockWipeStatusFromResult(ctx, tx, result.HostID, action, result.ExitCode == 0)
if err != nil {
return ctxerr.Wrap(ctx, err, "update host mdm action based on script result")
}
@ -171,9 +195,9 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f
return nil
})
if err != nil {
return nil, err
return nil, "", err
}
return hsr, nil
return hsr, action, nil
}
func (ds *Datastore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) {
@ -392,6 +416,25 @@ WHERE
return contents, nil
}
func (ds *Datastore) GetAnyScriptContents(ctx context.Context, id uint) ([]byte, error) {
const getStmt = `
SELECT
sc.contents
FROM
script_contents sc
WHERE
sc.id = ?
`
var contents []byte
if err := sqlx.GetContext(ctx, ds.reader(ctx), &contents, getStmt, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, notFound("Script").WithID(id)
}
return nil, ctxerr.Wrap(ctx, err, "get any script contents")
}
return contents, nil
}
func (ds *Datastore) DeleteScript(ctx context.Context, id uint) error {
_, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM scripts WHERE id = ?`, id)
if err != nil {
@ -1100,6 +1143,16 @@ func updateHostLockWipeStatusFromResult(ctx context.Context, tx sqlx.ExtContext,
return ctxerr.Wrap(ctx, err, "update host lock/wipe status from result")
}
func updateUninstallStatusFromResult(ctx context.Context, tx sqlx.ExtContext, hostID uint, executionID string, exitCode int) error {
stmt := `
UPDATE host_software_installs SET uninstall_script_exit_code = ? WHERE execution_id = ? AND host_id = ?
`
if _, err := tx.ExecContext(ctx, stmt, exitCode, executionID, hostID); err != nil {
return ctxerr.Wrap(ctx, err, "update uninstall status from result")
}
return nil
}
func (ds *Datastore) CleanupUnusedScriptContents(ctx context.Context) error {
deleteStmt := `
DELETE FROM
@ -1111,7 +1164,7 @@ WHERE
SELECT 1 FROM scripts WHERE script_content_id = script_contents.id)
AND NOT EXISTS (
SELECT 1 FROM software_installers si
WHERE script_contents.id IN (si.install_script_content_id, si.post_install_script_content_id)
WHERE script_contents.id IN (si.install_script_content_id, si.post_install_script_content_id, si.uninstall_script_content_id)
)
`
_, err := ds.writer(ctx).ExecContext(ctx, deleteStmt)

View file

@ -37,6 +37,7 @@ func TestScripts(t *testing.T) {
{"TestLockUnlockManually", testLockUnlockManually},
{"TestInsertScriptContents", testInsertScriptContents},
{"TestCleanupUnusedScriptContents", testCleanupUnusedScriptContents},
{"TestGetAnyScriptContents", testGetAnyScriptContents},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@ -87,7 +88,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
require.Equal(t, createdScript.ID, pending[0].ID)
// record a result for this execution
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
hsr, action, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: 1,
ExecutionID: createdScript.ExecutionID,
Output: "foo",
@ -96,9 +97,11 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
Timeout: 300,
})
require.NoError(t, err)
assert.Empty(t, action)
assert.NotNil(t, hsr)
// record a duplicate result for this execution, will be ignored
hsr, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
hsr, _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: 1,
ExecutionID: createdScript.ExecutionID,
Output: "foobarbaz",
@ -163,7 +166,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
strings.Repeat("j", 1000) +
strings.Repeat("k", 1000)
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
_, _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: 1,
ExecutionID: createdScript.ExecutionID,
Output: largeOutput,
@ -238,7 +241,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
unsignedScriptResult, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
unsignedScriptResult, _, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: 1,
ExecutionID: createdUnsignedScript.ExecutionID,
Output: "foo",
@ -261,6 +264,8 @@ func testScripts(t *testing.T, ds *Datastore) {
// get unknown script contents
_, err = ds.GetScriptContents(ctx, 123)
require.ErrorAs(t, err, &nfe)
_, err = ds.GetAnyScriptContents(ctx, 123)
require.ErrorAs(t, err, &nfe)
// create global scriptGlobal
scriptGlobal, err := ds.NewScript(ctx, &fleet.Script{
@ -282,6 +287,9 @@ func testScripts(t *testing.T, ds *Datastore) {
contents, err := ds.GetScriptContents(ctx, scriptGlobal.ID)
require.NoError(t, err)
require.Equal(t, "echo", string(contents))
contents, err = ds.GetAnyScriptContents(ctx, scriptGlobal.ID)
require.NoError(t, err)
require.Equal(t, "echo", string(contents))
// create team script but team does not exist
_, err = ds.NewScript(ctx, &fleet.Script{
@ -315,6 +323,9 @@ func testScripts(t *testing.T, ds *Datastore) {
contents, err = ds.GetScriptContents(ctx, scriptTeam.ID)
require.NoError(t, err)
require.Equal(t, "echo 'team'", string(contents))
contents, err = ds.GetAnyScriptContents(ctx, scriptTeam.ID)
require.NoError(t, err)
require.Equal(t, "echo 'team'", string(contents))
// try to create another team script with the same name
_, err = ds.NewScript(ctx, &fleet.Script{
@ -781,12 +792,13 @@ func testLockHostViaScript(t *testing.T, ds *Datastore) {
require.True(t, status.IsPendingLock())
// simulate a successful result for the lock script execution
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
_, action, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: s.HostID,
ExecutionID: s.ExecutionID,
ExitCode: 0,
})
require.NoError(t, err)
assert.Equal(t, "lock_ref", action)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: windowsHostID, Platform: "windows", UUID: "uuid"})
require.NoError(t, err)
@ -832,12 +844,13 @@ func testUnlockHostViaScript(t *testing.T, ds *Datastore) {
require.True(t, status.IsPendingUnlock())
// simulate a successful result for the unlock script execution
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
_, action, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: s.HostID,
ExecutionID: s.ExecutionID,
ExitCode: 0,
})
require.NoError(t, err)
assert.Equal(t, "unlock_ref", action)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: "windows", UUID: "uuid"})
require.NoError(t, err)
@ -874,12 +887,13 @@ func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) {
checkLockWipeState(t, status, true, false, false, false, true, false)
// simulate a successful result for the lock script execution
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
_, action, err := ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: hostID,
ExecutionID: status.LockScript.ExecutionID,
ExitCode: 0,
})
require.NoError(t, err)
assert.Equal(t, "lock_ref", action)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
@ -899,12 +913,13 @@ func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) {
checkLockWipeState(t, status, false, true, false, true, false, false)
// simulate a failed result for the unlock script execution
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
_, action, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: hostID,
ExecutionID: status.UnlockScript.ExecutionID,
ExitCode: -1,
})
require.NoError(t, err)
assert.Equal(t, "unlock_ref", action)
// still locked
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
@ -925,12 +940,13 @@ func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) {
checkLockWipeState(t, status, false, true, false, true, false, false)
// this time simulate a successful result for the unlock script execution
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
_, action, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: hostID,
ExecutionID: status.UnlockScript.ExecutionID,
ExitCode: 0,
})
require.NoError(t, err)
assert.Equal(t, "unlock_ref", action)
// host is now unlocked
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
@ -951,12 +967,13 @@ func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) {
checkLockWipeState(t, status, true, false, false, false, true, false)
// simulate a failed result for the lock script execution
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
_, action, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: hostID,
ExecutionID: status.LockScript.ExecutionID,
ExitCode: 2,
})
require.NoError(t, err)
assert.Equal(t, "lock_ref", action)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
@ -1009,12 +1026,13 @@ func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) {
checkLockWipeState(t, status, true, false, false, false, false, true)
// simulate a failed result for the wipe script execution
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
_, action, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: hostID,
ExecutionID: status.WipeScript.ExecutionID,
ExitCode: 1,
})
require.NoError(t, err)
assert.Equal(t, "wipe_ref", action)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
@ -1034,12 +1052,13 @@ func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) {
checkLockWipeState(t, status, true, false, false, false, false, true)
// simulate a successful result for the wipe script execution
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
_, action, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: hostID,
ExecutionID: status.WipeScript.ExecutionID,
ExitCode: 0,
})
require.NoError(t, err)
assert.Equal(t, "wipe_ref", action)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
@ -1147,6 +1166,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
// create a software install that references scripts
swi, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install-script",
UninstallScript: "uninstall-script",
PreInstallQuery: "SELECT 1",
PostInstallScript: "post-install-script",
InstallerFile: bytes.NewReader([]byte("hello")),
@ -1167,7 +1187,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
stmt := `SELECT id, HEX(md5_checksum) as md5_checksum FROM script_contents`
err = sqlx.SelectContext(ctx, ds.reader(ctx), &sc, stmt)
require.NoError(t, err)
require.Len(t, sc, 4)
require.Len(t, sc, 5)
// this should only remove the script_contents of the saved script, since the sync script is
// still "in use" by the script execution
@ -1176,15 +1196,17 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
sc = []scriptContents{}
err = sqlx.SelectContext(ctx, ds.reader(ctx), &sc, stmt)
require.NoError(t, err)
require.Len(t, sc, 3)
require.Len(t, sc, 4)
require.ElementsMatch(t, []string{
md5ChecksumScriptContent(res.ScriptContents),
md5ChecksumScriptContent("install-script"),
md5ChecksumScriptContent("post-install-script"),
md5ChecksumScriptContent("uninstall-script"),
}, []string{
sc[0].Checksum,
sc[1].Checksum,
sc[2].Checksum,
sc[3].Checksum,
})
// remove the software installer from the DB
@ -1204,6 +1226,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
swi, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
PreInstallQuery: "SELECT 1",
InstallScript: "install-script",
UninstallScript: "uninstall-script",
InstallerFile: bytes.NewReader([]byte("hello")),
StorageID: "storage1",
Filename: "file1",
@ -1219,7 +1242,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
sc = []scriptContents{}
err = sqlx.SelectContext(ctx, ds.reader(ctx), &sc, stmt)
require.NoError(t, err)
require.Len(t, sc, 2)
require.Len(t, sc, 3)
// remove the software installer from the DB
err = ds.DeleteSoftwareInstaller(ctx, swi)
@ -1233,3 +1256,15 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) {
require.Len(t, sc, 1)
require.Equal(t, md5ChecksumScriptContent(res.ScriptContents), sc[0].Checksum)
}
func testGetAnyScriptContents(t *testing.T, ds *Datastore) {
ctx := context.Background()
contents := `echo foobar;`
res, err := insertScriptContents(ctx, ds.writer(ctx), contents)
require.NoError(t, err)
id, _ := res.LastInsertId()
result, err := ds.GetAnyScriptContents(ctx, uint(id))
require.NoError(t, err)
require.Equal(t, contents, string(result))
}

View file

@ -2155,45 +2155,6 @@ 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_software_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(tblAlias, colAlias string) string {
if tblAlias != "" {
tblAlias += "."
}
if colAlias != "" {
colAlias = " AS " + colAlias
}
// the computed column assumes that all results (pre, install and post) are
// stored at once, so that if there is an exit code for the install script
// and none for the post-install, it is because there is no post-install.
return fmt.Sprintf(`
CASE
WHEN %[1]sremoved = 1 THEN NULL
WHEN %[1]spost_install_script_exit_code IS NOT NULL AND
%[1]spost_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_failed
WHEN %[1]sinstall_script_exit_code IS NOT NULL AND
%[1]sinstall_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_failed
WHEN %[1]spre_install_query_output IS NOT NULL AND
%[1]spre_install_query_output = '' THEN :software_status_failed
WHEN %[1]shost_id IS NOT NULL THEN :software_status_pending
ELSE NULL -- not installed from Fleet installer
END %[2]s `, tblAlias, colAlias)
}
func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) {
var onlySelfServiceClause string
if opts.SelfServiceOnly {
@ -2218,9 +2179,11 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id
hs.host_id = :host_id AND
s.title_id = st.id
) OR `
status := fmt.Sprintf(`COALESCE(%s, %s)`, "hsi.last_status", vppAppHostStatusNamedQuery("hvsi", "ncr", ""))
if opts.OnlyAvailableForInstall {
// Get software that has a package/VPP installer but was not installed with Fleet
softwareIsInstalledOnHostClause = ` status IS NULL AND (si.id IS NOT NULL OR vat.adam_id IS NOT NULL) AND ` + softwareIsInstalledOnHostClause
softwareIsInstalledOnHostClause = fmt.Sprintf(` %s IS NULL AND (si.id IS NOT NULL OR vat.adam_id IS NOT NULL) AND %s`, status,
softwareIsInstalledOnHostClause)
}
// this statement lists only the software that is reported as installed on
@ -2237,16 +2200,64 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id
vat.adam_id as vpp_app_adam_id,
vap.latest_version as vpp_app_version,
NULLIF(vap.icon_url, '') as vpp_app_icon_url,
COALESCE(hsi.created_at, hvsi.created_at) as last_install_installed_at,
COALESCE(hsi.execution_id, hvsi.command_uuid) as last_install_install_uuid,
-- get either the softare installer status or the vpp app status
COALESCE(%s, %s) as status
COALESCE(hsi.last_installed_at, hvsi.created_at) as last_install_installed_at,
COALESCE(hsi.last_install_execution_id, hvsi.command_uuid) as last_install_install_uuid,
hsi.last_uninstalled_at as last_uninstall_uninstalled_at,
hsi.last_uninstall_execution_id as last_uninstall_script_execution_id,
-- get either the software installer status or the vpp app status
%s as status
FROM
software_titles st
LEFT OUTER JOIN
software_installers si ON st.id = si.title_id AND si.global_or_team_id = :global_or_team_id
LEFT OUTER JOIN
host_software_installs hsi ON si.id = hsi.software_installer_id AND hsi.host_id = :host_id AND hsi.removed = 0
LEFT OUTER JOIN -- get the latest status and install/uninstall attempts (merge 3 host_software_installs rows into 1)
(
SELECT
hsi_group.host_id,
hsi_group.software_installer_id,
TIMESTAMP(GROUP_CONCAT(hsi_installed_at)) as last_installed_at,
GROUP_CONCAT(hsi_install_execution_id) as last_install_execution_id,
TIMESTAMP(GROUP_CONCAT(hsi_uninstalled_at)) as last_uninstalled_at,
GROUP_CONCAT(hsi_uninstall_execution_id) as last_uninstall_execution_id,
IF(GROUP_CONCAT(hsi_status) = '', NULL, GROUP_CONCAT(hsi_status)) as last_status
FROM (
-- get latest install/uninstall status
SELECT
host_id, software_installer_id,
NULL as hsi_installed_at, NULL as hsi_install_execution_id,
NULL as hsi_uninstalled_at, NULL as hsi_uninstall_execution_id,
-- get the status of the latest attempt; 27-1 is the length of the timestamp
SUBSTRING(MAX(CONCAT(created_at, COALESCE(status, ''))), 27) AS hsi_status
FROM host_software_installs
WHERE host_id = :host_id AND removed = 0
GROUP BY host_id, software_installer_id
UNION
-- get latest install attempt
SELECT
host_id, software_installer_id,
MAX(created_at) as hsi_installed_at,
-- get the execution_id of the latest attempt; 27-1 is the length of the timestamp
SUBSTRING(MAX(CONCAT(created_at, execution_id)), 27) AS hsi_install_execution_id,
NULL as hsi_uninstalled_at, NULL as hsi_uninstall_execution_id,
NULL as hsi_status
FROM host_software_installs
WHERE host_id = :host_id AND removed = 0 AND uninstall = 0
GROUP BY host_id, software_installer_id
UNION
-- get latest uninstall attempt
SELECT
host_id, software_installer_id,
NULL as hsi_installed_at, NULL as hsi_install_execution_id,
MAX(created_at) as hsi_uninstalled_at,
-- get the execution_id of the latest attempt; 27-1 is the length of the timestamp
SUBSTRING(MAX(CONCAT(created_at, execution_id)), 27) AS hsi_uninstall_execution_id,
NULL as hsi_status
FROM host_software_installs
WHERE host_id = :host_id AND removed = 0 AND uninstall = 1
GROUP BY host_id, software_installer_id
) as hsi_group
GROUP BY hsi_group.host_id, hsi_group.software_installer_id
) as hsi ON si.id = hsi.software_installer_id
LEFT OUTER JOIN
vpp_apps vap ON st.id = vap.title_id AND vap.platform = :host_platform
LEFT OUTER JOIN
@ -2256,13 +2267,7 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id
LEFT OUTER JOIN
nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid
WHERE
-- use the latest install attempt only
( hsi.id IS NULL OR hsi.id = (
SELECT hsi2.id
FROM host_software_installs hsi2
WHERE hsi2.host_id = hsi.host_id AND hsi2.software_installer_id = hsi.software_installer_id AND hsi2.removed = 0
ORDER BY hsi2.created_at DESC
LIMIT 1 ) ) AND
-- use the latest VPP install attempt only
( hvsi.id IS NULL OR hvsi.id = (
SELECT hvsi2.id
FROM host_vpp_software_installs hvsi2
@ -2276,8 +2281,7 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id
( %s hsi.host_id IS NOT NULL OR hvsi.host_id IS NOT NULL )
%s
%s
`, softwareInstallerHostStatusNamedQuery("hsi", ""), vppAppHostStatusNamedQuery("hvsi", "ncr", ""),
softwareIsInstalledOnHostClause, onlySelfServiceClause, onlyVulnerableClause)
`, status, softwareIsInstalledOnHostClause, onlySelfServiceClause, onlyVulnerableClause)
// this statement lists only the software that has never been installed nor
// attempted to be installed on the host, but that is available to be
@ -2296,6 +2300,8 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id
NULLIF(vap.icon_url, '') as vpp_app_icon_url,
NULL as last_install_installed_at,
NULL as last_install_install_uuid,
NULL as last_uninstall_uninstalled_at,
NULL as last_uninstall_script_execution_id,
NULL as status
FROM
software_titles st
@ -2359,6 +2365,8 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id
vpp_app_icon_url,
last_install_installed_at,
last_install_install_uuid,
last_uninstall_uninstalled_at,
last_uninstall_script_execution_id,
status
`
@ -2369,9 +2377,9 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id
namedArgs := map[string]any{
"host_id": host.ID,
"host_platform": host.FleetPlatform(),
"software_status_failed": fleet.SoftwareInstallerFailed,
"software_status_pending": fleet.SoftwareInstallerPending,
"software_status_installed": fleet.SoftwareInstallerInstalled,
"software_status_failed": fleet.SoftwareInstallFailed,
"software_status_pending": fleet.SoftwareInstallPending,
"software_status_installed": fleet.SoftwareInstalled,
"mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged,
"mdm_status_error": fleet.MDMAppleStatusError,
"mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError,
@ -2419,15 +2427,17 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id
type hostSoftware struct {
fleet.HostSoftwareWithInstaller
LastInstallInstalledAt *time.Time `db:"last_install_installed_at"`
LastInstallInstallUUID *string `db:"last_install_install_uuid"`
PackageSelfService *bool `db:"package_self_service"`
PackageName *string `db:"package_name"`
PackageVersion *string `db:"package_version"`
VPPAppSelfService *bool `db:"vpp_app_self_service"`
VPPAppAdamID *string `db:"vpp_app_adam_id"`
VPPAppVersion *string `db:"vpp_app_version"`
VPPAppIconURL *string `db:"vpp_app_icon_url"`
LastInstallInstalledAt *time.Time `db:"last_install_installed_at"`
LastInstallInstallUUID *string `db:"last_install_install_uuid"`
LastUninstallUninstalledAt *time.Time `db:"last_uninstall_uninstalled_at"`
LastUninstallScriptExecutionID *string `db:"last_uninstall_script_execution_id"`
PackageSelfService *bool `db:"package_self_service"`
PackageName *string `db:"package_name"`
PackageVersion *string `db:"package_version"`
VPPAppSelfService *bool `db:"vpp_app_self_service"`
VPPAppAdamID *string `db:"vpp_app_adam_id"`
VPPAppVersion *string `db:"vpp_app_version"`
VPPAppIconURL *string `db:"vpp_app_icon_url"`
}
var hostSoftwareList []*hostSoftware
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostSoftwareList, stmt, args...); err != nil {
@ -2461,6 +2471,16 @@ AND EXISTS (SELECT 1 FROM software s JOIN software_cve scve ON scve.software_id
hs.SoftwarePackage.LastInstall.InstalledAt = *hs.LastInstallInstalledAt
}
}
// promote the last uninstall info to the proper destination fields
if hs.LastUninstallScriptExecutionID != nil && *hs.LastUninstallScriptExecutionID != "" {
hs.SoftwarePackage.LastUninstall = &fleet.HostSoftwareUninstall{
ExecutionID: *hs.LastUninstallScriptExecutionID,
}
if hs.LastUninstallUninstalledAt != nil {
hs.SoftwarePackage.LastUninstall.UninstalledAt = *hs.LastUninstallUninstalledAt
}
}
}
// promote the VPP app id and version to the proper destination fields
@ -2668,11 +2688,8 @@ SELECT
0 as vpp_apps_count
FROM software_titles st
INNER JOIN software_installers si ON si.title_id = st.id
INNER JOIN host_software_installs hsi ON hsi.host_id = ? AND hsi.software_installer_id = si.id
WHERE hsi.removed = 0 AND
-- :software_status_installed
((hsi.post_install_script_exit_code IS NOT NULL AND hsi.post_install_script_exit_code = 0) OR
(hsi.install_script_exit_code IS NOT NULL AND hsi.install_script_exit_code = 0))
INNER JOIN host_software_installs hsi ON hsi.host_id = :host_id AND hsi.software_installer_id = si.id
WHERE hsi.removed = 0 AND hsi.status = :software_status_installed
UNION
@ -2685,12 +2702,21 @@ SELECT
1 as vpp_apps_count
FROM software_titles st
INNER JOIN vpp_apps vap ON vap.title_id = st.id
INNER JOIN host_vpp_software_installs hvsi ON hvsi.host_id = ? AND hvsi.adam_id = vap.adam_id AND hvsi.platform = vap.platform
INNER JOIN host_vpp_software_installs hvsi ON hvsi.host_id = :host_id AND hvsi.adam_id = vap.adam_id AND hvsi.platform = vap.platform
INNER JOIN nano_command_results ncr ON ncr.command_uuid = hvsi.command_uuid
WHERE hvsi.removed = 0 AND ncr.status = ?
WHERE hvsi.removed = 0 AND ncr.status = :mdm_status_acknowledged
`
selectStmt, args, err := sqlx.Named(stmt, map[string]interface{}{
"host_id": hostID,
"software_status_installed": fleet.SoftwareInstalled,
"mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build query to get installed software titles")
}
var titles []fleet.SoftwareTitle
if err := sqlx.SelectContext(ctx, qc, &titles, stmt, hostID, hostID, fleet.MDMAppleStatusAcknowledged); err != nil {
if err := sqlx.SelectContext(ctx, qc, &titles, selectStmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get installed software titles")
}
return titles, nil

View file

@ -24,14 +24,12 @@ func (ds *Datastore) ListPendingSoftwareInstalls(ctx context.Context, hostID uin
WHERE
host_id = ?
AND
install_script_exit_code IS NULL
AND
pre_install_query_output IS NULL
status = ?
ORDER BY
created_at ASC
`
var results []string
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, stmt, hostID); err != nil {
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, stmt, hostID, fleet.SoftwareInstallPending); err != nil {
return nil, ctxerr.Wrap(ctx, err, "list pending software installs")
}
return results, nil
@ -86,6 +84,11 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload
return 0, ctxerr.Wrap(ctx, err, "get or generate install script contents ID")
}
uninstallScriptID, err := ds.getOrGenerateScriptContentsID(ctx, payload.UninstallScript)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "get or generate uninstall script contents ID")
}
var postInstallScriptID *uint
if payload.PostInstallScript != "" {
sid, err := ds.getOrGenerateScriptContentsID(ctx, payload.PostInstallScript)
@ -113,15 +116,17 @@ INSERT INTO software_installers (
storage_id,
filename,
version,
package_ids,
install_script_content_id,
pre_install_query,
post_install_script_content_id,
uninstall_script_content_id,
platform,
self_service,
user_id,
user_name,
user_email
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?))`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?))`
args := []interface{}{
tid,
@ -130,9 +135,11 @@ INSERT INTO software_installers (
payload.StorageID,
payload.Filename,
payload.Version,
strings.Join(payload.PackageIDs, ","),
installScriptID,
payload.PreInstallQuery,
postInstallScriptID,
uninstallScriptID,
payload.Platform,
payload.SelfService,
payload.UserID,
@ -215,6 +222,7 @@ SELECT
si.install_script_content_id,
si.pre_install_query,
si.post_install_script_content_id,
si.uninstall_script_content_id,
si.uploaded_at,
COALESCE(st.name, '') AS software_title,
si.platform
@ -239,9 +247,10 @@ WHERE
func (ds *Datastore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) {
var scriptContentsSelect, scriptContentsFrom string
if withScriptContents {
scriptContentsSelect = ` , inst.contents AS install_script, COALESCE(pisnt.contents, '') AS post_install_script `
scriptContentsSelect = ` , inst.contents AS install_script, COALESCE(pinst.contents, '') AS post_install_script, uninst.contents AS uninstall_script `
scriptContentsFrom = ` LEFT OUTER JOIN script_contents inst ON inst.id = si.install_script_content_id
LEFT OUTER JOIN script_contents pisnt ON pisnt.id = si.post_install_script_content_id `
LEFT OUTER JOIN script_contents pinst ON pinst.id = si.post_install_script_content_id
LEFT OUTER JOIN script_contents uninst ON uninst.id = si.uninstall_script_content_id`
}
query := fmt.Sprintf(`
@ -255,6 +264,7 @@ SELECT
si.install_script_content_id,
si.pre_install_query,
si.post_install_script_content_id,
si.uninstall_script_content_id,
si.uploaded_at,
si.self_service,
COALESCE(st.name, '') AS software_title
@ -349,6 +359,41 @@ func (ds *Datastore) InsertSoftwareInstallRequest(ctx context.Context, hostID ui
return installID, ctxerr.Wrap(ctx, err, "inserting new install software request")
}
func (ds *Datastore) InsertSoftwareUninstallRequest(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint) error {
const (
insertStmt = `
INSERT INTO host_software_installs
(execution_id, host_id, software_installer_id, user_id, uninstall)
VALUES
(?, ?, ?, ?, 1)
`
hostExistsStmt = `SELECT 1 FROM hosts WHERE id = ?`
)
// we need to explicitly do this check here because we can't set a FK constraint on the schema
var hostExists bool
err := sqlx.GetContext(ctx, ds.reader(ctx), &hostExists, hostExistsStmt, hostID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return notFound("Host").WithID(hostID)
}
return ctxerr.Wrap(ctx, err, "checking if host exists")
}
var userID *uint
if ctxUser := authz.UserFromContext(ctx); ctxUser != nil {
userID = &ctxUser.ID
}
_, err = ds.writer(ctx).ExecContext(ctx, insertStmt,
executionID,
hostID,
softwareInstallerID,
userID,
)
return ctxerr.Wrap(ctx, err, "inserting new uninstall software request")
}
func (ds *Datastore) GetSoftwareInstallResults(ctx context.Context, resultsUUID string) (*fleet.HostSoftwareInstallerResult, error) {
query := fmt.Sprintf(`
SELECT
@ -359,7 +404,7 @@ SELECT
hsi.host_id AS host_id,
st.name AS software_title,
st.id AS software_title_id,
COALESCE(%s, '') AS status,
COALESCE(hsi.status, '') AS status,
si.filename AS software_package,
hsi.user_id AS user_id,
hsi.post_install_script_exit_code,
@ -375,13 +420,10 @@ FROM
JOIN software_titles st ON si.title_id = st.id
WHERE
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,
"execution_id": resultsUUID,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build named query for get software install results")
@ -404,13 +446,15 @@ func (ds *Datastore) GetSummaryHostSoftwareInstalls(ctx context.Context, install
stmt := fmt.Sprintf(`
SELECT
COALESCE(SUM( IF(status = :software_status_pending, 1, 0)), 0) AS pending,
COALESCE(SUM( IF(status = :software_status_failed, 1, 0)), 0) AS failed,
COALESCE(SUM( IF(status = :software_status_pending_install, 1, 0)), 0) AS pending_install,
COALESCE(SUM( IF(status = :software_status_failed_install, 1, 0)), 0) AS failed_install,
COALESCE(SUM( IF(status = :software_status_pending_uninstall, 1, 0)), 0) AS pending_uninstall,
COALESCE(SUM( IF(status = :software_status_failed_uninstall, 1, 0)), 0) AS failed_uninstall,
COALESCE(SUM( IF(status = :software_status_installed, 1, 0)), 0) AS installed
FROM (
SELECT
software_installer_id,
%s
status
FROM
host_software_installs hsi
WHERE
@ -424,13 +468,15 @@ WHERE
AND host_deleted_at IS NULL
AND removed = 0
GROUP BY
host_id)) s`, softwareInstallerHostStatusNamedQuery("hsi", "status"))
host_id)) s`)
query, args, err := sqlx.Named(stmt, map[string]interface{}{
"installer_id": installerID,
"software_status_pending": fleet.SoftwareInstallerPending,
"software_status_failed": fleet.SoftwareInstallerFailed,
"software_status_installed": fleet.SoftwareInstallerInstalled,
"installer_id": installerID,
"software_status_pending_install": fleet.SoftwareInstallPending,
"software_status_failed_install": fleet.SoftwareInstallFailed,
"software_status_pending_uninstall": fleet.SoftwareUninstallPending,
"software_status_failed_uninstall": fleet.SoftwareUninstallFailed,
"software_status_installed": fleet.SoftwareInstalled,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get summary host software installs: named query")
@ -445,6 +491,15 @@ WHERE
}
func (ds *Datastore) vppAppJoin(appID fleet.VPPAppID, status fleet.SoftwareInstallerStatus) (string, []interface{}, error) {
// Since VPP does not have uninstaller yet, we map the generic pending/failed statuses to the install statuses
switch status {
case fleet.SoftwarePending:
status = fleet.SoftwareInstallPending
case fleet.SoftwareFailed:
status = fleet.SoftwareInstallFailed
default:
// no change
}
stmt := fmt.Sprintf(`JOIN (
SELECT
host_id
@ -469,9 +524,9 @@ WHERE
"status": status,
"adam_id": appID.AdamID,
"platform": appID.Platform,
"software_status_installed": fleet.SoftwareInstallerInstalled,
"software_status_failed": fleet.SoftwareInstallerFailed,
"software_status_pending": fleet.SoftwareInstallerPending,
"software_status_installed": fleet.SoftwareInstalled,
"software_status_failed": fleet.SoftwareInstallFailed,
"software_status_pending": fleet.SoftwareInstallPending,
"mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged,
"mdm_status_error": fleet.MDMAppleStatusError,
"mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError,
@ -479,6 +534,21 @@ WHERE
}
func (ds *Datastore) softwareInstallerJoin(installerID uint, status fleet.SoftwareInstallerStatus) (string, []interface{}, error) {
statusFilter := "hsi.status = :status"
var status2 fleet.SoftwareInstallerStatus
switch status {
case fleet.SoftwarePending:
status = fleet.SoftwareInstallPending
status2 = fleet.SoftwareUninstallPending
case fleet.SoftwareFailed:
status = fleet.SoftwareInstallFailed
status2 = fleet.SoftwareUninstallFailed
default:
// no change
}
if status2 != "" {
statusFilter = "hsi.status IN (:status, :status2)"
}
stmt := fmt.Sprintf(`JOIN (
SELECT
host_id
@ -495,21 +565,19 @@ WHERE
AND removed = 0
GROUP BY
host_id, software_installer_id)
AND (%s) = :status) hss ON hss.host_id = h.id
`, softwareInstallerHostStatusNamedQuery("hsi", ""))
AND %s) hss ON hss.host_id = h.id
`, statusFilter)
return sqlx.Named(stmt, map[string]interface{}{
"status": status,
"installer_id": installerID,
"software_status_installed": fleet.SoftwareInstallerInstalled,
"software_status_failed": fleet.SoftwareInstallerFailed,
"software_status_pending": fleet.SoftwareInstallerPending,
"status": status,
"status2": status2,
"installer_id": installerID,
})
}
func (ds *Datastore) GetHostLastInstallData(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) {
stmt := fmt.Sprintf(`
SELECT execution_id, %s AS status
SELECT execution_id, hsi.status
FROM host_software_installs hsi
WHERE hsi.id = (
SELECT
@ -519,14 +587,11 @@ func (ds *Datastore) GetHostLastInstallData(ctx context.Context, hostID, install
software_installer_id = :installer_id AND host_id = :host_id
GROUP BY
host_id, software_installer_id)
`, softwareInstallerHostStatusNamedQuery("hsi", ""))
`)
stmt, args, err := sqlx.Named(stmt, map[string]interface{}{
"host_id": hostID,
"installer_id": installerID,
"software_status_installed": fleet.SoftwareInstallerInstalled,
"software_status_failed": fleet.SoftwareInstallerFailed,
"software_status_pending": fleet.SoftwareInstallerPending,
"host_id": hostID,
"installer_id": installerID,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "build named query to get host last install data")
@ -623,6 +688,7 @@ INSERT INTO software_installers (
filename,
version,
install_script_content_id,
uninstall_script_content_id,
pre_install_query,
post_install_script_content_id,
platform,
@ -633,12 +699,13 @@ INSERT INTO software_installers (
user_email,
url
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
(SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''),
?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?
)
ON DUPLICATE KEY UPDATE
install_script_content_id = VALUES(install_script_content_id),
uninstall_script_content_id = VALUES(uninstall_script_content_id),
post_install_script_content_id = VALUES(post_install_script_content_id),
storage_id = VALUES(storage_id),
filename = VALUES(filename),
@ -732,6 +799,12 @@ WHERE global_or_team_id = ?
}
installScriptID, _ := isRes.LastInsertId()
uisRes, err := insertScriptContents(ctx, tx, installer.UninstallScript)
if err != nil {
return ctxerr.Wrapf(ctx, err, "inserting uninstall script contents for software installer with name %q", installer.Filename)
}
uninstallScriptID, _ := uisRes.LastInsertId()
var postInstallScriptID *int64
if installer.PostInstallScript != "" {
pisRes, err := insertScriptContents(ctx, tx, installer.PostInstallScript)
@ -750,6 +823,7 @@ WHERE global_or_team_id = ?
installer.Filename,
installer.Version,
installScriptID,
uninstallScriptID,
installer.PreInstallQuery,
postInstallScriptID,
installer.Platform,
@ -805,3 +879,19 @@ func (ds *Datastore) HasSelfServiceSoftwareInstallers(ctx context.Context, hostP
}
return hasInstallers, nil
}
func (ds *Datastore) GetSoftwareTitleNameFromExecutionID(ctx context.Context, executionID string) (string, error) {
stmt := `
SELECT name
FROM software_titles st
INNER JOIN software_installers si ON si.title_id = st.id
INNER JOIN host_software_installs hsi ON hsi.software_installer_id = si.id
WHERE hsi.execution_id = ?
`
var name string
err := sqlx.GetContext(ctx, ds.reader(ctx), &name, stmt, executionID)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "get software title name from execution ID")
}
return name, nil
}

View file

@ -13,6 +13,7 @@ import (
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -118,7 +119,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host2.ID,
InstallUUID: hostInstall5,
PreInstallConditionOutput: ptr.String("output"),
PreInstallConditionOutput: ptr.String(""), // pre-install query did not return results, so install failed
})
require.NoError(t, err)
@ -211,24 +212,128 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
_, err = ds.InsertSoftwareInstallRequest(ctx, 12, si.InstallerID, false)
require.ErrorAs(t, err, &nfe)
// successful insert
host, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "macos-test" + tc,
OsqueryHostID: ptr.String("osquery-macos" + tc),
NodeKey: ptr.String("node-key-macos" + tc),
// Host with software install pending
tag := "-pending_install"
hostPendingInstall, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "macos-test" + tag + tc,
OsqueryHostID: ptr.String("osquery-macos" + tag + tc),
NodeKey: ptr.String("node-key-macos" + tag + tc),
UUID: uuid.NewString(),
Platform: "darwin",
TeamID: teamID,
})
require.NoError(t, err)
_, err = ds.InsertSoftwareInstallRequest(ctx, host.ID, si.InstallerID, false)
_, err = ds.InsertSoftwareInstallRequest(ctx, hostPendingInstall.ID, si.InstallerID, false)
require.NoError(t, err)
// list hosts with software install requests
// Host with software install failed
tag = "-failed_install"
hostFailedInstall, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "macos-test" + tag + tc,
OsqueryHostID: ptr.String("osquery-macos" + tag + tc),
NodeKey: ptr.String("node-key-macos" + tag + tc),
UUID: uuid.NewString(),
Platform: "darwin",
TeamID: teamID,
})
require.NoError(t, err)
_, err = ds.InsertSoftwareInstallRequest(ctx, hostFailedInstall.ID, si.InstallerID, false)
require.NoError(t, err)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(ctx, `
UPDATE host_software_installs SET install_script_exit_code = 1 WHERE host_id = ? AND software_installer_id = ?`,
hostFailedInstall.ID, si.InstallerID)
require.NoError(t, err)
return nil
})
// Host with software install successful
tag = "-installed"
hostInstalled, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "macos-test" + tag + tc,
OsqueryHostID: ptr.String("osquery-macos" + tag + tc),
NodeKey: ptr.String("node-key-macos" + tag + tc),
UUID: uuid.NewString(),
Platform: "darwin",
TeamID: teamID,
})
require.NoError(t, err)
_, err = ds.InsertSoftwareInstallRequest(ctx, hostInstalled.ID, si.InstallerID, false)
require.NoError(t, err)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(ctx, `
UPDATE host_software_installs SET install_script_exit_code = 0 WHERE host_id = ? AND software_installer_id = ?`,
hostInstalled.ID, si.InstallerID)
require.NoError(t, err)
return nil
})
// Host with pending uninstall
tag = "-pending_uninstall"
hostPendingUninstall, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "macos-test" + tag + tc,
OsqueryHostID: ptr.String("osquery-macos" + tag + tc),
NodeKey: ptr.String("node-key-macos" + tag + tc),
UUID: uuid.NewString(),
Platform: "darwin",
TeamID: teamID,
})
require.NoError(t, err)
err = ds.InsertSoftwareUninstallRequest(ctx, "uuid"+tag+tc, hostPendingUninstall.ID, si.InstallerID)
require.NoError(t, err)
// Host with failed uninstall
tag = "-failed_uninstall"
hostFailedUninstall, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "macos-test" + tag + tc,
OsqueryHostID: ptr.String("osquery-macos" + tag + tc),
NodeKey: ptr.String("node-key-macos" + tag + tc),
UUID: uuid.NewString(),
Platform: "darwin",
TeamID: teamID,
})
require.NoError(t, err)
err = ds.InsertSoftwareUninstallRequest(ctx, "uuid"+tag+tc, hostFailedUninstall.ID, si.InstallerID)
require.NoError(t, err)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(ctx, `
UPDATE host_software_installs SET uninstall_script_exit_code = 1 WHERE host_id = ? AND software_installer_id = ?`,
hostFailedUninstall.ID, si.InstallerID)
require.NoError(t, err)
return nil
})
// Host with successful uninstall
tag = "-uninstalled"
hostUninstalled, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "macos-test" + tag + tc,
OsqueryHostID: ptr.String("osquery-macos" + tag + tc),
NodeKey: ptr.String("node-key-macos" + tag + tc),
UUID: uuid.NewString(),
Platform: "darwin",
TeamID: teamID,
})
require.NoError(t, err)
err = ds.InsertSoftwareUninstallRequest(ctx, "uuid"+tag+tc, hostUninstalled.ID, si.InstallerID)
require.NoError(t, err)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(ctx, `
UPDATE host_software_installs SET uninstall_script_exit_code = 0 WHERE host_id = ? AND software_installer_id = ?`,
hostUninstalled.ID, si.InstallerID)
require.NoError(t, err)
return nil
})
// Uninstall request with unknown host
err = ds.InsertSoftwareUninstallRequest(ctx, "uuid"+tag+tc, 99999, si.InstallerID)
assert.ErrorContains(t, err, "Host")
userTeamFilter := fleet.TeamFilter{
User: &fleet.User{GlobalRole: ptr.String("admin")},
}
expectStatus := fleet.SoftwareInstallerPending
// list hosts with software install pending requests
expectStatus := fleet.SoftwareInstallPending
hosts, err := ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
@ -237,15 +342,98 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
require.Len(t, hosts, 1)
require.Equal(t, host.ID, hosts[0].ID)
require.Equal(t, hostPendingInstall.ID, hosts[0].ID)
// list hosts with all pending requests
expectStatus = fleet.SoftwarePending
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: &expectStatus,
TeamFilter: teamID,
})
require.NoError(t, err)
require.Len(t, hosts, 2)
assert.ElementsMatch(t, []uint{hostPendingInstall.ID, hostPendingUninstall.ID}, []uint{hosts[0].ID, hosts[1].ID})
// list hosts with software install failed requests
expectStatus = fleet.SoftwareInstallFailed
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: &expectStatus,
TeamFilter: teamID,
})
require.NoError(t, err)
require.Len(t, hosts, 1)
assert.ElementsMatch(t, []uint{hostFailedInstall.ID}, []uint{hosts[0].ID})
// list hosts with all failed requests
expectStatus = fleet.SoftwareFailed
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: &expectStatus,
TeamFilter: teamID,
})
require.NoError(t, err)
require.Len(t, hosts, 2)
assert.ElementsMatch(t, []uint{hostFailedInstall.ID, hostFailedUninstall.ID}, []uint{hosts[0].ID, hosts[1].ID})
// list hosts with software installed
expectStatus = fleet.SoftwareInstalled
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: &expectStatus,
TeamFilter: teamID,
})
require.NoError(t, err)
require.Len(t, hosts, 1)
assert.ElementsMatch(t, []uint{hostInstalled.ID}, []uint{hosts[0].ID})
// list hosts with pending software uninstall requests
expectStatus = fleet.SoftwareUninstallPending
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: &expectStatus,
TeamFilter: teamID,
})
require.NoError(t, err)
require.Len(t, hosts, 1)
assert.ElementsMatch(t, []uint{hostPendingUninstall.ID}, []uint{hosts[0].ID})
// list hosts with failed software uninstall requests
expectStatus = fleet.SoftwareUninstallFailed
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
SoftwareStatusFilter: &expectStatus,
TeamFilter: teamID,
})
require.NoError(t, err)
require.Len(t, hosts, 1)
assert.ElementsMatch(t, []uint{hostFailedUninstall.ID}, []uint{hosts[0].ID})
// list all hosts with the software title that shows up in host_software (after fleetd software query is run)
hosts, err = ds.ListHosts(ctx, userTeamFilter, fleet.HostListOptions{
ListOptions: fleet.ListOptions{PerPage: 100},
SoftwareTitleIDFilter: installerMeta.TitleID,
TeamFilter: teamID,
})
require.NoError(t, err)
assert.Empty(t, hosts)
// get software title includes status
summary, err := ds.GetSummaryHostSoftwareInstalls(ctx, installerMeta.InstallerID)
require.NoError(t, err)
require.Equal(t, fleet.SoftwareInstallerStatusSummary{
Installed: 0,
Pending: 1,
Failed: 0,
Installed: 1,
PendingInstall: 1,
FailedInstall: 1,
PendingUninstall: 1,
FailedUninstall: 1,
}, *summary)
})
}
@ -271,27 +459,27 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
}{
{
name: "pending install",
expectedStatus: fleet.SoftwareInstallerPending,
expectedStatus: fleet.SoftwareInstallPending,
postInstallScriptOutput: ptr.String("post install output"),
installScriptOutput: ptr.String("install output"),
},
{
name: "failing install post install script",
expectedStatus: fleet.SoftwareInstallerFailed,
expectedStatus: fleet.SoftwareInstallFailed,
postInstallScriptEC: ptr.Int(1),
postInstallScriptOutput: ptr.String("post install output"),
installScriptOutput: ptr.String("install output"),
},
{
name: "failing install install script",
expectedStatus: fleet.SoftwareInstallerFailed,
expectedStatus: fleet.SoftwareInstallFailed,
installScriptEC: ptr.Int(1),
postInstallScriptOutput: ptr.String("post install output"),
installScriptOutput: ptr.String("install output"),
},
{
name: "failing install pre install query",
expectedStatus: fleet.SoftwareInstallerFailed,
expectedStatus: fleet.SoftwareInstallFailed,
preInstallQueryOutput: ptr.String(""),
postInstallScriptOutput: ptr.String("post install output"),
installScriptOutput: ptr.String("install output"),
@ -812,7 +1000,7 @@ func testGetHostLastInstallData(t *testing.T, ds *Datastore) {
require.NotNil(t, host1LastInstall)
require.Equal(t, installUUID1, host1LastInstall.ExecutionID)
require.NotNil(t, host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status)
// Set result of last installation.
err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
@ -829,7 +1017,7 @@ func testGetHostLastInstallData(t *testing.T, ds *Datastore) {
require.NotNil(t, host1LastInstall)
require.Equal(t, installUUID1, host1LastInstall.ExecutionID)
require.NotNil(t, host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallerInstalled, *host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstalled, *host1LastInstall.Status)
// Install installer2.pkg on host1.
installUUID2, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID2, false)
@ -842,14 +1030,14 @@ func testGetHostLastInstallData(t *testing.T, ds *Datastore) {
require.NotNil(t, host1LastInstall)
require.Equal(t, installUUID1, host1LastInstall.ExecutionID)
require.NotNil(t, host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallerInstalled, *host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstalled, *host1LastInstall.Status)
// Last installation for installer2.pkg should be "pending".
host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID2)
require.NoError(t, err)
require.NotNil(t, host1LastInstall)
require.Equal(t, installUUID2, host1LastInstall.ExecutionID)
require.NotNil(t, host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status)
// Perform another installation of installer1.pkg.
installUUID3, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID1, false)
@ -862,7 +1050,7 @@ func testGetHostLastInstallData(t *testing.T, ds *Datastore) {
require.NotNil(t, host1LastInstall)
require.Equal(t, installUUID3, host1LastInstall.ExecutionID)
require.NotNil(t, host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status)
// Set result of last installer1.pkg installation.
err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
@ -879,7 +1067,7 @@ func testGetHostLastInstallData(t *testing.T, ds *Datastore) {
require.NotNil(t, host1LastInstall)
require.Equal(t, installUUID3, host1LastInstall.ExecutionID)
require.NotNil(t, host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallerFailed, *host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallFailed, *host1LastInstall.Status)
// No installations on host2.
host2LastInstall, err := ds.GetHostLastInstallData(ctx, host2.ID, softwareInstallerID1)

View file

@ -3323,11 +3323,19 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
}
compareResults := func(expected map[string]fleet.HostSoftwareWithInstaller, got []*fleet.HostSoftwareWithInstaller, expectAsc bool, expectOmitted ...string) {
require.Len(t, got, len(expected)-len(expectOmitted))
gotToString := func() string {
var builder strings.Builder
builder.WriteString("Got:\n")
for _, g := range got {
builder.WriteString(fmt.Sprintf("%+v\n", g))
}
return builder.String()
}
require.Len(t, got, len(expected)-len(expectOmitted), gotToString())
prev := ""
for _, g := range got {
e, ok := expected[g.Name+g.Source]
require.True(t, ok)
require.True(t, ok, "unexpected software %s%s", g.Name, g.Source)
require.Equal(t, e.Name, g.Name)
require.Equal(t, e.Source, g.Source)
if e.SoftwarePackage != nil {
@ -3341,6 +3349,10 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
require.Equal(t, e.SoftwarePackage.LastInstall.InstallUUID, g.SoftwarePackage.LastInstall.InstallUUID)
require.NotNil(t, g.SoftwarePackage.LastInstall.InstalledAt)
}
if e.SoftwarePackage.LastUninstall != nil {
assert.Equal(t, e.SoftwarePackage.LastUninstall.ExecutionID, g.SoftwarePackage.LastUninstall.ExecutionID)
assert.NotNil(t, g.SoftwarePackage.LastUninstall.UninstalledAt)
}
}
if e.AppStoreApp != nil {
@ -3410,7 +3422,8 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
var swi1Pending, swi2Installed, swi3Failed, swi4Available, swi5Tm uint
const numberOfSoftwareInstallers = 8
var swi1Pending, swi2Installed, swi3Failed, swi4Available, swi5Tm, swi6PendingUninstall, swi7FailedUninstall, swi8Uninstalled uint
var otherHostI1UUID, otherHostI2UUID string
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
// keep title id of software B, will use it to associate an installer with it
@ -3428,10 +3441,19 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
}
scriptContentID, _ := res.LastInsertId()
// create the uninstall script content (same for all installers, doesn't matter)
uninstallScript := `echo 'bar'`
resUninstall, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`,
uninstallScript, uninstallScript)
if err != nil {
return err
}
uninstallScriptContentID, _ := resUninstall.LastInsertId()
// create software titles for all but swi1Pending (will be linked to
// existing software title b)
var titleIDs []uint
for i := 0; i < 4; i++ {
for i := 0; i < numberOfSoftwareInstallers-1; i++ {
res, err := q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES (?, 'apps')`, fmt.Sprintf("i%d", i))
if err != nil {
return err
@ -3441,7 +3463,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
}
var swiIDs []uint
for i := 0; i < 5; i++ {
for i := 0; i < numberOfSoftwareInstallers; i++ {
var (
titleID uint
teamID *uint
@ -3458,10 +3480,12 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
}
res, err := q.ExecContext(ctx, `
INSERT INTO software_installers
(team_id, global_or_team_id, title_id, filename, version, install_script_content_id, storage_id, platform, self_service)
(team_id, global_or_team_id, title_id, filename, version, install_script_content_id, uninstall_script_content_id, storage_id, platform, self_service)
VALUES
(?, ?, ?, ?, ?, ?, unhex(?), ?, ?)`,
teamID, globalOrTeamID, titleID, fmt.Sprintf("installer-%d.pkg", i), fmt.Sprintf("v%d.0.0", i), scriptContentID, hex.EncodeToString([]byte("test")), "darwin", i < 2)
(?, ?, ?, ?, ?, ?, ?, unhex(?), ?, ?)`,
teamID, globalOrTeamID, titleID, fmt.Sprintf("installer-%d.pkg", i), fmt.Sprintf("v%d.0.0", i), scriptContentID,
uninstallScriptContentID,
hex.EncodeToString([]byte("test")), "darwin", i < 2)
if err != nil {
return err
}
@ -3469,7 +3493,9 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
swiIDs = append(swiIDs, uint(id))
}
// sw1Pending and swi2Installed are self-service installers
swi1Pending, swi2Installed, swi3Failed, swi4Available, swi5Tm = swiIDs[0], swiIDs[1], swiIDs[2], swiIDs[3], swiIDs[4]
swi1Pending, swi2Installed, swi3Failed, swi4Available, swi5Tm,
swi6PendingUninstall, swi7FailedUninstall, swi8Uninstalled =
swiIDs[0], swiIDs[1], swiIDs[2], swiIDs[3], swiIDs[4], swiIDs[5], swiIDs[6], swiIDs[7]
// create the results for the host
@ -3518,21 +3544,31 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
// swi5 is for another team
_ = swi5Tm
// add another installer for a different platform, should be always omitted
res, err = q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES ('windows-title', 'programs')`)
if err != nil {
return err
}
lid, _ := res.LastInsertId()
// swi6 has been installed, and is pending uninstall
_, err = q.ExecContext(ctx, `
INSERT INTO software_installers
(team_id, global_or_team_id, title_id, filename, version, install_script_content_id, storage_id, platform)
VALUES
(?, ?, ?, ?, ?, ?, unhex(?), ?)`,
nil, 0, lid, "windows-installer-6.msi", "v6.0.0", scriptContentID, hex.EncodeToString([]byte("test")), "windows")
if err != nil {
return err
}
INSERT INTO host_software_installs (execution_id, host_id, software_installer_id, pre_install_query_output, install_script_exit_code, post_install_script_exit_code)
VALUES (?, ?, ?, ?, ?, ?)`,
"uuid6-pre", host.ID, swi6PendingUninstall, "ok", 0, 0)
require.NoError(t, err)
_, err = q.ExecContext(ctx, `
INSERT INTO host_software_installs (execution_id, host_id, software_installer_id, uninstall)
VALUES (?, ?, ?, ?)`,
"uuid6", host.ID, swi6PendingUninstall, 1)
require.NoError(t, err)
// swi7 is failed uninstall
_, err = q.ExecContext(ctx, `
INSERT INTO host_software_installs (execution_id, host_id, software_installer_id, uninstall, uninstall_script_exit_code)
VALUES (?, ?, ?, ?, ?)`,
"uuid7", host.ID, swi7FailedUninstall, 1, 1)
require.NoError(t, err)
// swi8 is successful uninstall
_, err = q.ExecContext(ctx, `
INSERT INTO host_software_installs (execution_id, host_id, software_installer_id, uninstall, uninstall_script_exit_code)
VALUES (?, ?, ?, ?, ?)`,
"uuid8", host.ID, swi8Uninstalled, 1, 0)
require.NoError(t, err)
return nil
})
@ -3541,7 +3577,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
expected[byNSV[b].Name+byNSV[b].Source] = fleet.HostSoftwareWithInstaller{
Name: "b",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallerPending),
Status: expectStatus(fleet.SoftwareInstallPending),
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-0.pkg", Version: "v0.0.0", SelfService: ptr.Bool(true), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid1"}},
InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}, InstalledPaths: []string{installPaths[2]}},
@ -3550,7 +3586,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
i0 := fleet.HostSoftwareWithInstaller{
Name: "i0",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallerInstalled),
Status: expectStatus(fleet.SoftwareInstalled),
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-1.pkg", Version: "v1.0.0", SelfService: ptr.Bool(true), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid2"}},
}
expected[i0.Name+i0.Source] = i0
@ -3558,16 +3594,44 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
i1 := fleet.HostSoftwareWithInstaller{
Name: "i1",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallerFailed),
Status: expectStatus(fleet.SoftwareInstallFailed),
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid3"}},
}
expected[i1.Name+i1.Source] = i1
i4 := fleet.HostSoftwareWithInstaller{
Name: "i4",
Source: "apps",
Status: expectStatus(fleet.SoftwareUninstallPending),
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-5.pkg", Version: "v5.0.0", SelfService: ptr.Bool(false),
LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid6-pre"},
LastUninstall: &fleet.HostSoftwareUninstall{ExecutionID: "uuid6"}},
}
expected[i4.Name+i4.Source] = i4
i5 := fleet.HostSoftwareWithInstaller{
Name: "i5",
Source: "apps",
Status: expectStatus(fleet.SoftwareUninstallFailed),
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-6.pkg", Version: "v6.0.0", SelfService: ptr.Bool(false),
LastUninstall: &fleet.HostSoftwareUninstall{ExecutionID: "uuid7"}},
}
expected[i5.Name+i5.Source] = i5
i6 := fleet.HostSoftwareWithInstaller{
Name: "i6",
Source: "apps",
Status: nil,
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-7.pkg", Version: "v7.0.0", SelfService: ptr.Bool(false),
LastUninstall: &fleet.HostSoftwareUninstall{ExecutionID: "uuid8"}},
}
expected[i6.Name+i6.Source] = i6
// request without available software
opts.IncludeAvailableForInstall = false
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected))}, meta)
compareResults(expected, sw, true)
// request with available software
@ -3591,7 +3655,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
opts.ListOptions.PerPage = 20
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 8}, meta)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 1}, meta)
compareResults(expected, sw, true, i3.Name+i3.Source)
// request with available software only (attempted to install and never attempted to install)
@ -3600,6 +3664,9 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
expectedAvailableOnly[i0.Name+i0.Source] = i0
expectedAvailableOnly[i1.Name+i1.Source] = i1
expectedAvailableOnly[i2.Name+i2.Source] = i2
expectedAvailableOnly[i4.Name+i4.Source] = i4
expectedAvailableOnly[i5.Name+i5.Source] = i5
expectedAvailableOnly[i6.Name+i6.Source] = i6
opts.OnlyAvailableForInstall = true
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
@ -3613,7 +3680,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
opts.IncludeAvailableForInstall = false
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 2}, meta)
compareResults(expected, sw, false, i2.Name+i2.Source, i3.Name+i3.Source)
opts.ListOptions.OrderDirection = fleet.OrderAscending
opts.ListOptions.TestSecondaryOrderDirection = fleet.OrderAscending
@ -3642,7 +3709,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
expected[byNSV[b].Name+byNSV[b].Source] = fleet.HostSoftwareWithInstaller{
Name: "b",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallerFailed),
Status: expectStatus(fleet.SoftwareInstallFailed),
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-0.pkg", Version: "v0.0.0", SelfService: ptr.Bool(true), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid1"}},
InstalledVersions: []*fleet.HostSoftwareInstalledVersion{
{Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}, InstalledPaths: []string{installPaths[2]}},
@ -3651,7 +3718,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
expected[i1.Name+i1.Source] = fleet.HostSoftwareWithInstaller{
Name: "i1",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallerPending),
Status: expectStatus(fleet.SoftwareInstallPending),
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: "uuid4"}},
}
expectedAvailableOnly[byNSV[b].Name+byNSV[b].Source] = expected[byNSV[b].Name+byNSV[b].Source]
@ -3661,14 +3728,14 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
opts.IncludeAvailableForInstall = false
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 2}, meta)
compareResults(expected, sw, true, i2.Name+i2.Source, i3.Name+i3.Source)
// request with available software)
opts.IncludeAvailableForInstall = true
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 8}, meta)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 1}, meta)
compareResults(expected, sw, true, i3.Name+i3.Source)
// create a new host in the team, with no software
@ -3775,13 +3842,13 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
expected["vpp1apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp1",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallerInstalled),
Status: expectStatus(fleet.SoftwareInstalled),
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1, SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1CmdUUID}},
}
expected["vpp2apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp2",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallerPending),
Status: expectStatus(fleet.SoftwareInstallPending),
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp2, SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp2bCmdUUID}},
}
@ -3789,7 +3856,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
opts.ListOptions.MatchQuery = ""
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 9}, meta)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 2}, meta)
compareResults(expected, sw, true, i3.Name+i3.Source, i2.Name+i2.Source) // i3 is for team, i2 is available (excluded)
expected["vpp3apps"] = fleet.HostSoftwareWithInstaller{
@ -3805,7 +3872,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
opts.ListOptions.PerPage = 20
sw, meta, err = ds.ListHostSoftware(ctx, host, opts)
require.NoError(t, err)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: 11}, meta)
require.Equal(t, &fleet.PaginationMetadata{TotalResults: uint(len(expected)) - 1}, meta)
compareResults(expected, sw, true, i3.Name+i3.Source) // i3 is for team
// Available for install only
@ -3826,7 +3893,7 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
"vpp1apps": {
Name: "vpp1",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallerPending),
Status: expectStatus(fleet.SoftwareInstallPending),
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1, SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1TmCmdUUID}},
},
}, sw, true)
@ -3847,13 +3914,13 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
"i1apps": {
Name: "i1",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallerPending),
Status: expectStatus(fleet.SoftwareInstallPending),
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-2.pkg", Version: "v2.0.0", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: otherHostI1UUID}},
},
"i2apps": {
Name: "i2",
Source: "apps",
Status: expectStatus(fleet.SoftwareInstallerPending),
Status: expectStatus(fleet.SoftwareInstallPending),
SoftwarePackage: &fleet.SoftwarePackageOrApp{Name: "installer-3.pkg", Version: "v3.0.0", SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{InstallUUID: otherHostI2UUID}},
},
}
@ -3868,37 +3935,40 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
{
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 3}, IncludeAvailableForInstall: false},
wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 9},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 12},
},
{
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 3}, IncludeAvailableForInstall: false},
wantNames: []string{byNSV[c1].Name, byNSV[d].Name, i0.Name},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 9},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 12},
},
{
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 2, PerPage: 3}, IncludeAvailableForInstall: false},
wantNames: []string{i1.Name, "vpp1", "vpp2"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 9},
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 3, PerPage: 3},
IncludeAvailableForInstall: false},
wantNames: []string{i6.Name, "vpp1", "vpp2"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 12},
},
{
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 3, PerPage: 3}, IncludeAvailableForInstall: false},
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 4, PerPage: 3},
IncludeAvailableForInstall: false},
wantNames: []string{},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 9},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 12},
},
{
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 4}, IncludeAvailableForInstall: true},
wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name, byNSV[c1].Name},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 11},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 14},
},
{
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 4}, IncludeAvailableForInstall: true},
wantNames: []string{byNSV[d].Name, i0.Name, i1.Name, i2.Name},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 11},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 14},
},
{
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 2, PerPage: 4}, IncludeAvailableForInstall: true},
wantNames: []string{"vpp1", "vpp2", "vpp3"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 11},
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 3, PerPage: 4},
IncludeAvailableForInstall: true},
wantNames: []string{"vpp2", "vpp3"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 14},
},
{
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 3}, IncludeAvailableForInstall: true, SelfServiceOnly: true},
@ -3913,12 +3983,13 @@ func testListHostSoftware(t *testing.T, ds *Datastore) {
{
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 0, PerPage: 4}, OnlyAvailableForInstall: true},
wantNames: []string{byNSV[b].Name, "i0", "i1", "i2"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 7},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 10},
},
{
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 4}, OnlyAvailableForInstall: true},
wantNames: []string{"vpp1", "vpp2", "vpp3"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7},
opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 2, PerPage: 4},
OnlyAvailableForInstall: true},
wantNames: []string{"vpp2", "vpp3"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 10},
},
}
for _, c := range cases {
@ -4204,13 +4275,13 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) {
expected["vpp1ios_apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp1",
Source: "ios_apps",
Status: expectStatus(fleet.SoftwareInstallerInstalled),
Status: expectStatus(fleet.SoftwareInstalled),
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp1, SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp1CmdUUID}},
}
expected["vpp2ios_apps"] = fleet.HostSoftwareWithInstaller{
Name: "vpp2",
Source: "ios_apps",
Status: expectStatus(fleet.SoftwareInstallerPending),
Status: expectStatus(fleet.SoftwareInstallPending),
AppStoreApp: &fleet.SoftwarePackageOrApp{AppStoreID: vpp2, SelfService: ptr.Bool(false), LastInstall: &fleet.HostSoftwareInstall{CommandUUID: vpp2bCmdUUID}},
}
@ -4267,6 +4338,14 @@ func testSetHostSoftwareInstallResult(t *testing.T, ds *Datastore) {
}
scriptContentID, _ := res.LastInsertId()
uninstallScript := `echo 'bar'`
resUninstall, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`,
uninstallScript, uninstallScript)
if err != nil {
return err
}
uninstallScriptContentID, _ := resUninstall.LastInsertId()
res, err = q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES ('foo', 'apps')`)
if err != nil {
return err
@ -4275,10 +4354,10 @@ func testSetHostSoftwareInstallResult(t *testing.T, ds *Datastore) {
res, err = q.ExecContext(ctx, `
INSERT INTO software_installers
(title_id, filename, version, install_script_content_id, storage_id)
(title_id, filename, version, install_script_content_id, uninstall_script_content_id, storage_id)
VALUES
(?, ?, ?, ?, unhex(?))`,
titleID, "installer.pkg", "v1.0.0", scriptContentID, hex.EncodeToString([]byte("test")))
(?, ?, ?, ?, ?, unhex(?))`,
titleID, "installer.pkg", "v1.0.0", scriptContentID, uninstallScriptContentID, hex.EncodeToString([]byte("test")))
if err != nil {
return err
}

View file

@ -495,6 +495,7 @@ func CreateNamedMySQLDS(t *testing.T, name string) *Datastore {
}
func ExecAdhocSQL(tb testing.TB, ds *Datastore, fn func(q sqlx.ExtContext) error) {
tb.Helper()
err := fn(ds.primary)
require.NoError(tb, err)
}

View file

@ -101,9 +101,9 @@ WHERE
"mdm_status_acknowledged": fleet.MDMAppleStatusAcknowledged,
"mdm_status_error": fleet.MDMAppleStatusError,
"mdm_status_format_error": fleet.MDMAppleStatusCommandFormatError,
"software_status_pending": fleet.SoftwareInstallerPending,
"software_status_failed": fleet.SoftwareInstallerFailed,
"software_status_installed": fleet.SoftwareInstallerInstalled,
"software_status_pending": fleet.SoftwareInstallPending,
"software_status_failed": fleet.SoftwareInstallFailed,
"software_status_installed": fleet.SoftwareInstalled,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get summary host vpp installs: named query")
@ -519,8 +519,8 @@ WHERE
listStmt, args, err := sqlx.Named(stmt, map[string]any{
"command_uuid": commandResults.CommandUUID,
"software_status_failed": string(fleet.SoftwareInstallerFailed),
"software_status_installed": string(fleet.SoftwareInstallerInstalled),
"software_status_failed": string(fleet.SoftwareInstallFailed),
"software_status_installed": string(fleet.SoftwareInstalled),
})
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "build list query from named args")
@ -547,14 +547,14 @@ WHERE
var status string
switch commandResults.Status {
case fleet.MDMAppleStatusAcknowledged:
status = string(fleet.SoftwareInstallerInstalled)
status = string(fleet.SoftwareInstalled)
case fleet.MDMAppleStatusCommandFormatError:
case fleet.MDMAppleStatusError:
status = string(fleet.SoftwareInstallerFailed)
status = string(fleet.SoftwareInstallFailed)
default:
// This case shouldn't happen (we should only be doing this check if the command is in a
// "terminal" state, but adding it so we have a default
status = string(fleet.SoftwareInstallerPending)
status = string(fleet.SoftwareInstallPending)
}
act := &fleet.ActivityInstalledAppStoreApp{

View file

@ -99,6 +99,7 @@ var ActivityDetailsList = []ActivityDetails{
ActivityTypeResentConfigurationProfile{},
ActivityTypeInstalledSoftware{},
ActivityTypeUninstalledSoftware{},
ActivityTypeAddedSoftware{},
ActivityTypeDeletedSoftware{},
ActivityEnabledVPP{},
@ -1521,6 +1522,38 @@ func (a ActivityTypeInstalledSoftware) Documentation() (activity, details, detai
}`
}
type ActivityTypeUninstalledSoftware struct {
HostID uint `json:"host_id"`
HostDisplayName string `json:"host_display_name"`
SoftwareTitle string `json:"software_title"`
ExecutionID string `json:"script_execution_id"`
Status string `json:"status"`
}
func (a ActivityTypeUninstalledSoftware) ActivityName() string {
return "uninstalled_software"
}
func (a ActivityTypeUninstalledSoftware) HostIDs() []uint {
return []uint{a.HostID}
}
func (a ActivityTypeUninstalledSoftware) Documentation() (activity, details, detailsExample string) {
return `Generated when a software is uninstalled on a host.`,
`This activity contains the following fields:
- "host_id": ID of the host.
- "host_display_name": Display name of the host.
- "software_title": Name of the software.
- "script_execution_id": ID of the software uninstall script.
- "status": Status of the software uninstallation.`, `{
"host_id": 1,
"host_display_name": "Anna's MacBook Pro",
"software_title": "Falcon.app",
"script_execution_id": "ece8d99d-4313-446a-9af2-e152cd1bad1e",
"status": "uninstalled"
}`
}
type ActivityTypeAddedSoftware struct {
SoftwareTitle string `json:"software_title"`
SoftwarePackage string `json:"software_package"`

View file

@ -532,6 +532,11 @@ type Datastore interface {
// software installer in the host. It returns the auto-generated installation
// uuid.
InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error)
// InsertSoftwareUninstallRequest tracks a new request to uninstall the provided
// software installer on the host. executionID is the script execution ID corresponding to uninstall script
InsertSoftwareUninstallRequest(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint) error
// GetSoftwareTitleNameFromExecutionID returns the software title name associated with the provided software install execution ID.
GetSoftwareTitleNameFromExecutionID(ctx context.Context, executionID string) (string, error)
///////////////////////////////////////////////////////////////////////////////
// SoftwareStore
@ -1542,8 +1547,8 @@ type Datastore interface {
// SetHostScriptExecutionResult stores the result of a host script execution
// and returns the updated host script result record. Note that it does not
// fail if the script execution request does not exist, in this case it will
// return nil, nil.
SetHostScriptExecutionResult(ctx context.Context, result *HostScriptResultPayload) (*HostScriptResult, error)
// return nil, "", nil. action is populated if this script was an MDM action (lock/unlock/wipe/uninstall).
SetHostScriptExecutionResult(ctx context.Context, result *HostScriptResultPayload) (hsr *HostScriptResult, action string, err error)
// GetHostScriptExecutionResult returns the result of a host script
// execution. It returns the host script results even if no results have been
// received, it is the caller's responsibility to check if that was the case
@ -1563,6 +1568,10 @@ type Datastore interface {
// script.
GetScriptContents(ctx context.Context, id uint) ([]byte, error)
// GetAnyScriptContents returns the raw script contents of the corresponding
// script, regardless whether it is present in the scripts table.
GetAnyScriptContents(ctx context.Context, id uint) ([]byte, error)
// DeleteScript deletes the script identified by its id.
DeleteScript(ctx context.Context, id uint) error

View file

@ -637,6 +637,9 @@ type Service interface {
// InstallSoftwareTitle installs a software title in the given host.
InstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error
// UninstallSoftwareTitle uninstalls a software title in the given host.
UninstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error
// GetSoftwareInstallResults gets the results for a particular software install attempt.
GetSoftwareInstallResults(ctx context.Context, installUUID string) (*HostSoftwareInstallerResult, error)

View file

@ -89,10 +89,14 @@ type SoftwareInstaller struct {
InstallScript string `json:"install_script" db:"install_script"`
// InstallScriptContentID is the ID of the install script content.
InstallScriptContentID uint `json:"-" db:"install_script_content_id"`
// UninstallScriptContentID is the ID of the uninstall script content.
UninstallScriptContentID uint `json:"-" db:"uninstall_script_content_id"`
// PreInstallQuery is the query to run as a condition to installing the software package.
PreInstallQuery string `json:"pre_install_query" db:"pre_install_query"`
// PostInstallScript is the script to run after installing the software package.
PostInstallScript string `json:"post_install_script" db:"post_install_script"`
// UninstallScript is the script to run to uninstall the software package.
UninstallScript string `json:"uninstall_script" db:"uninstall_script"`
// PostInstallScriptContentID is the ID of the post-install script content.
PostInstallScriptContentID *uint `json:"-" db:"post_install_script_content_id"`
// StorageID is the unique identifier for the software package in the software installer store.
@ -117,27 +121,40 @@ func (s *SoftwareInstaller) AuthzType() string {
type SoftwareInstallerStatusSummary struct {
// Installed is the number of hosts that have the software package installed.
Installed uint `json:"installed" db:"installed"`
// Pending is the number of hosts that have the software package pending installation.
Pending uint `json:"pending" db:"pending"`
// Failed is the number of hosts that have the software package installation failed.
Failed uint `json:"failed" db:"failed"`
// PendingInstall is the number of hosts that have the software package pending installation.
PendingInstall uint `json:"pending_install" db:"pending_install"`
// FailedInstall is the number of hosts that have the software package installation failed.
FailedInstall uint `json:"failed_install" db:"failed_install"`
// PendingUninstall is the number of hosts that have the software package pending installation.
PendingUninstall uint `json:"pending_uninstall" db:"pending_uninstall"`
// FailedInstall is the number of hosts that have the software package installation failed.
FailedUninstall uint `json:"failed_uninstall" db:"failed_uninstall"`
}
// SoftwareInstallerStatus represents the status of a software installer package on a host.
type SoftwareInstallerStatus string
const (
SoftwareInstallerPending SoftwareInstallerStatus = "pending"
SoftwareInstallerFailed SoftwareInstallerStatus = "failed"
SoftwareInstallerInstalled SoftwareInstallerStatus = "installed"
SoftwareInstallPending SoftwareInstallerStatus = "pending_install"
SoftwareInstallFailed SoftwareInstallerStatus = "failed_install"
SoftwareInstalled SoftwareInstallerStatus = "installed"
SoftwareUninstallPending SoftwareInstallerStatus = "pending_uninstall"
SoftwareUninstallFailed SoftwareInstallerStatus = "failed_uninstall"
// SoftwarePending and SoftwareFailed statuses are only used as filters in the API and are not stored in the database.
SoftwarePending SoftwareInstallerStatus = "pending" // either pending_install or pending_uninstall
SoftwareFailed SoftwareInstallerStatus = "failed" // either failed_install or failed_uninstall
)
func (s SoftwareInstallerStatus) IsValid() bool {
switch s {
case
SoftwareInstallerFailed,
SoftwareInstallerInstalled,
SoftwareInstallerPending:
SoftwarePending,
SoftwareFailed,
SoftwareUninstallPending,
SoftwareUninstallFailed,
SoftwareInstallFailed,
SoftwareInstalled,
SoftwareInstallPending:
return true
default:
return false
@ -222,7 +239,7 @@ Rolled back successfully
// EnhanceOutputDetails is used to add extra boilerplate/information to the
// output fields so they're easier to consume by users.
func (h *HostSoftwareInstallerResult) EnhanceOutputDetails() {
if h.Status == SoftwareInstallerPending {
if h.Status == SoftwareInstallPending {
return
}
@ -282,6 +299,9 @@ type UploadSoftwareInstallerPayload struct {
SelfService bool
UserID uint
URL string
PackageIDs []string
UninstallScript string
Extension string
}
// DownloadSoftwareInstallerPayload is the payload for downloading a software installer.
@ -349,11 +369,12 @@ type SoftwarePackageOrApp struct {
// Name is only present for software installer packages.
Name string `json:"name,omitempty"`
Version string `json:"version"`
SelfService *bool `json:"self_service,omitempty"`
IconURL *string `json:"icon_url"`
LastInstall *HostSoftwareInstall `json:"last_install"`
PackageURL *string `json:"package_url"`
Version string `json:"version"`
SelfService *bool `json:"self_service,omitempty"`
IconURL *string `json:"icon_url"`
LastInstall *HostSoftwareInstall `json:"last_install"`
LastUninstall *HostSoftwareUninstall `json:"last_uninstall"`
PackageURL *string `json:"package_url"`
}
type SoftwarePackageSpec struct {
@ -384,6 +405,14 @@ type HostSoftwareInstall struct {
InstalledAt time.Time `json:"installed_at"`
}
// HostSoftwareUninstall represents uninstallation of software from a host with a
// Fleet software installer.
type HostSoftwareUninstall struct {
// ExecutionID is the UUID of the script execution that uninstalled the software.
ExecutionID string `json:"script_execution_id,omitempty"`
UninstalledAt time.Time `json:"uninstalled_at"`
}
// HostSoftwareInstalledVersion represents a version of software installed on a
// host.
type HostSoftwareInstalledVersion struct {
@ -417,17 +446,17 @@ type HostSoftwareInstallResultPayload struct {
func (h *HostSoftwareInstallResultPayload) Status() SoftwareInstallerStatus {
switch {
case h.PostInstallScriptExitCode != nil && *h.PostInstallScriptExitCode == 0:
return SoftwareInstallerInstalled
return SoftwareInstalled
case h.PostInstallScriptExitCode != nil && *h.PostInstallScriptExitCode != 0:
return SoftwareInstallerFailed
return SoftwareInstallFailed
case h.InstallScriptExitCode != nil && *h.InstallScriptExitCode == 0:
return SoftwareInstallerInstalled
return SoftwareInstalled
case h.InstallScriptExitCode != nil && *h.InstallScriptExitCode != 0:
return SoftwareInstallerFailed
return SoftwareInstallFailed
case h.PreInstallConditionOutput != nil && *h.PreInstallConditionOutput == "":
return SoftwareInstallerFailed
return SoftwareInstallFailed
default:
return SoftwareInstallerPending
return SoftwareInstallPending
}
}

View file

@ -89,7 +89,7 @@ func TestEnhanceOutputDetails(t *testing.T) {
{
name: "pending status",
initial: HostSoftwareInstallerResult{
Status: SoftwareInstallerPending,
Status: SoftwareInstallPending,
},
expectedPreInstallQueryOutput: nil,
expectedOutput: nil,
@ -98,7 +98,7 @@ func TestEnhanceOutputDetails(t *testing.T) {
{
name: "non-pending status with empty PreInstallQueryOutput",
initial: HostSoftwareInstallerResult{
Status: SoftwareInstallerInstalled,
Status: SoftwareInstalled,
PreInstallQueryOutput: ptr.String(""),
},
expectedPreInstallQueryOutput: ptr.String(SoftwareInstallerQueryFailCopy),
@ -108,7 +108,7 @@ func TestEnhanceOutputDetails(t *testing.T) {
{
name: "non-pending status with non-empty PreInstallQueryOutput",
initial: HostSoftwareInstallerResult{
Status: SoftwareInstallerInstalled,
Status: SoftwareInstalled,
PreInstallQueryOutput: ptr.String("Some output"),
},
expectedPreInstallQueryOutput: ptr.String(SoftwareInstallerQuerySuccessCopy),
@ -118,7 +118,7 @@ func TestEnhanceOutputDetails(t *testing.T) {
{
name: "non-pending status with nil PreInstallQueryOutput",
initial: HostSoftwareInstallerResult{
Status: SoftwareInstallerInstalled,
Status: SoftwareInstalled,
},
expectedPreInstallQueryOutput: nil,
expectedOutput: nil,
@ -127,7 +127,7 @@ func TestEnhanceOutputDetails(t *testing.T) {
{
name: "non-pending status with install scripts disabled",
initial: HostSoftwareInstallerResult{
Status: SoftwareInstallerInstalled,
Status: SoftwareInstalled,
InstallScriptExitCode: ptr.Int(-2),
Output: ptr.String(""),
},
@ -138,7 +138,7 @@ func TestEnhanceOutputDetails(t *testing.T) {
{
name: "non-pending status with failed install script",
initial: HostSoftwareInstallerResult{
Status: SoftwareInstallerFailed,
Status: SoftwareInstallFailed,
InstallScriptExitCode: ptr.Int(1),
Output: ptr.String("Some install output"),
},
@ -149,7 +149,7 @@ func TestEnhanceOutputDetails(t *testing.T) {
{
name: "non-pending status with successful install script",
initial: HostSoftwareInstallerResult{
Status: SoftwareInstallerInstalled,
Status: SoftwareInstalled,
InstallScriptExitCode: ptr.Int(0),
Output: ptr.String("Some install output"),
},
@ -160,7 +160,7 @@ func TestEnhanceOutputDetails(t *testing.T) {
{
name: "non-pending status with successful post install script",
initial: HostSoftwareInstallerResult{
Status: SoftwareInstallerInstalled,
Status: SoftwareInstalled,
InstallScriptExitCode: ptr.Int(0),
Output: ptr.String("Some install output"),
PostInstallScriptExitCode: ptr.Int(0),
@ -173,7 +173,7 @@ func TestEnhanceOutputDetails(t *testing.T) {
{
name: "non-pending status with failed post install script",
initial: HostSoftwareInstallerResult{
Status: SoftwareInstallerInstalled,
Status: SoftwareInstalled,
InstallScriptExitCode: ptr.Int(0),
Output: ptr.String("Some install output"),
PostInstallScriptExitCode: ptr.Int(1),

View file

@ -398,6 +398,10 @@ type SoftwareTitleByIDFunc func(ctx context.Context, id uint, teamID *uint, tmFi
type InsertSoftwareInstallRequestFunc func(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error)
type InsertSoftwareUninstallRequestFunc func(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint) error
type GetSoftwareTitleNameFromExecutionIDFunc func(ctx context.Context, executionID string) (string, error)
type ListSoftwareForVulnDetectionFunc func(ctx context.Context, filter fleet.VulnSoftwareFilter) ([]fleet.Software, error)
type ListSoftwareVulnerabilitiesByHostIDsSourceFunc func(ctx context.Context, hostIDs []uint, source fleet.VulnerabilitySource) (map[uint][]fleet.SoftwareVulnerability, error)
@ -982,7 +986,7 @@ type SetOrUpdateMDMAppleDeclarationFunc func(ctx context.Context, declaration *f
type NewHostScriptExecutionRequestFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error)
type SetHostScriptExecutionResultFunc func(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult, error)
type SetHostScriptExecutionResultFunc func(ctx context.Context, result *fleet.HostScriptResultPayload) (hsr *fleet.HostScriptResult, action string, err error)
type GetHostScriptExecutionResultFunc func(ctx context.Context, execID string) (*fleet.HostScriptResult, error)
@ -994,6 +998,8 @@ type ScriptFunc func(ctx context.Context, id uint) (*fleet.Script, error)
type GetScriptContentsFunc func(ctx context.Context, id uint) ([]byte, error)
type GetAnyScriptContentsFunc func(ctx context.Context, id uint) ([]byte, error)
type DeleteScriptFunc func(ctx context.Context, id uint) error
type ListScriptsFunc func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error)
@ -1638,6 +1644,12 @@ type DataStore struct {
InsertSoftwareInstallRequestFunc InsertSoftwareInstallRequestFunc
InsertSoftwareInstallRequestFuncInvoked bool
InsertSoftwareUninstallRequestFunc InsertSoftwareUninstallRequestFunc
InsertSoftwareUninstallRequestFuncInvoked bool
GetSoftwareTitleNameFromExecutionIDFunc GetSoftwareTitleNameFromExecutionIDFunc
GetSoftwareTitleNameFromExecutionIDFuncInvoked bool
ListSoftwareForVulnDetectionFunc ListSoftwareForVulnDetectionFunc
ListSoftwareForVulnDetectionFuncInvoked bool
@ -2532,6 +2544,9 @@ type DataStore struct {
GetScriptContentsFunc GetScriptContentsFunc
GetScriptContentsFuncInvoked bool
GetAnyScriptContentsFunc GetAnyScriptContentsFunc
GetAnyScriptContentsFuncInvoked bool
DeleteScriptFunc DeleteScriptFunc
DeleteScriptFuncInvoked bool
@ -3972,6 +3987,20 @@ func (s *DataStore) InsertSoftwareInstallRequest(ctx context.Context, hostID uin
return s.InsertSoftwareInstallRequestFunc(ctx, hostID, softwareInstallerID, selfService)
}
func (s *DataStore) InsertSoftwareUninstallRequest(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint) error {
s.mu.Lock()
s.InsertSoftwareUninstallRequestFuncInvoked = true
s.mu.Unlock()
return s.InsertSoftwareUninstallRequestFunc(ctx, executionID, hostID, softwareInstallerID)
}
func (s *DataStore) GetSoftwareTitleNameFromExecutionID(ctx context.Context, executionID string) (string, error) {
s.mu.Lock()
s.GetSoftwareTitleNameFromExecutionIDFuncInvoked = true
s.mu.Unlock()
return s.GetSoftwareTitleNameFromExecutionIDFunc(ctx, executionID)
}
func (s *DataStore) ListSoftwareForVulnDetection(ctx context.Context, filter fleet.VulnSoftwareFilter) ([]fleet.Software, error) {
s.mu.Lock()
s.ListSoftwareForVulnDetectionFuncInvoked = true
@ -6016,7 +6045,7 @@ func (s *DataStore) NewHostScriptExecutionRequest(ctx context.Context, request *
return s.NewHostScriptExecutionRequestFunc(ctx, request)
}
func (s *DataStore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult, error) {
func (s *DataStore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) (hsr *fleet.HostScriptResult, action string, err error) {
s.mu.Lock()
s.SetHostScriptExecutionResultFuncInvoked = true
s.mu.Unlock()
@ -6058,6 +6087,13 @@ func (s *DataStore) GetScriptContents(ctx context.Context, id uint) ([]byte, err
return s.GetScriptContentsFunc(ctx, id)
}
func (s *DataStore) GetAnyScriptContents(ctx context.Context, id uint) ([]byte, error) {
s.mu.Lock()
s.GetAnyScriptContentsFuncInvoked = true
s.mu.Unlock()
return s.GetAnyScriptContentsFunc(ctx, id)
}
func (s *DataStore) DeleteScript(ctx context.Context, id uint) error {
s.mu.Lock()
s.DeleteScriptFuncInvoked = true

View file

@ -367,7 +367,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.GET("/api/_version_/fleet/software/titles", listSoftwareTitlesEndpoint, listSoftwareTitlesRequest{})
ue.GET("/api/_version_/fleet/software/titles/{id:[0-9]+}", getSoftwareTitleEndpoint, getSoftwareTitleRequest{})
ue.POST("/api/_version_/fleet/hosts/{host_id:[0-9]+}/software/install/{software_title_id:[0-9]+}", installSoftwareTitleEndpoint, installSoftwareRequest{})
ue.POST("/api/_version_/fleet/hosts/{host_id:[0-9]+}/software/{software_title_id:[0-9]+}/install", installSoftwareTitleEndpoint,
installSoftwareRequest{})
ue.POST("/api/_version_/fleet/hosts/{host_id:[0-9]+}/software/{software_title_id:[0-9]+}/uninstall", uninstallSoftwareTitleEndpoint,
uninstallSoftwareRequest{})
// Sofware installers
ue.GET("/api/_version_/fleet/software/titles/{title_id:[0-9]+}/package", getSoftwareInstallerEndpoint, getSoftwareInstallerRequest{})
@ -375,7 +378,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
getSoftwareInstallerRequest{})
ue.POST("/api/_version_/fleet/software/package", uploadSoftwareInstallerEndpoint, uploadSoftwareInstallerRequest{})
ue.DELETE("/api/_version_/fleet/software/titles/{title_id:[0-9]+}/available_for_install", deleteSoftwareInstallerEndpoint, deleteSoftwareInstallerRequest{})
ue.GET("/api/_version_/fleet/software/install/results/{install_uuid}", getSoftwareInstallResultsEndpoint, getSoftwareInstallResultsRequest{})
ue.GET("/api/_version_/fleet/software/install/{install_uuid}/results", getSoftwareInstallResultsEndpoint,
getSoftwareInstallResultsRequest{})
ue.POST("/api/_version_/fleet/software/batch", batchSetSoftwareInstallersEndpoint, batchSetSoftwareInstallersRequest{})
// App store software

View file

@ -5906,7 +5906,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() {
case r := <-resultsCh:
r.ExecutionID = pending[0].ExecutionID
// ignoring errors in this goroutine, the HTTP request below will fail if this fails
_, err = s.ds.SetHostScriptExecutionResult(ctx, r)
_, _, err = s.ds.SetHostScriptExecutionResult(ctx, r)
if err != nil {
t.Log(err)
}
@ -9960,7 +9960,7 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
// request installation on the host
var installResp installSoftwareResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d",
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install",
host.ID, titleID), nil, http.StatusAccepted, &installResp)
// still returned by user-authenticated endpoint, now pending
@ -9974,7 +9974,7 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
require.NotNil(t, getHostSw.Software[2].SoftwarePackage)
require.Equal(t, "ruby.deb", getHostSw.Software[2].SoftwarePackage.Name)
require.NotNil(t, getHostSw.Software[2].Status)
require.Equal(t, fleet.SoftwareInstallerPending, *getHostSw.Software[2].Status)
require.Equal(t, fleet.SoftwareInstallPending, *getHostSw.Software[2].Status)
require.NotNil(t, getHostSw.Software[2].SoftwarePackage.SelfService)
require.True(t, *getHostSw.Software[2].SoftwarePackage.SelfService)
@ -9995,7 +9995,7 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() {
require.Equal(t, getDeviceSw.Software[2].Name, "ruby")
require.Len(t, getDeviceSw.Software[1].InstalledVersions, 2)
require.NotNil(t, getDeviceSw.Software[2].Status)
require.Equal(t, fleet.SoftwareInstallerPending, *getDeviceSw.Software[2].Status)
require.Equal(t, fleet.SoftwareInstallPending, *getDeviceSw.Software[2].Status)
require.NotNil(t, getDeviceSw.Software[2].SoftwarePackage)
require.Nil(t, getDeviceSw.Software[2].AppStoreApp)
require.NotNil(t, getDeviceSw.Software[2].SoftwarePackage.SelfService)
@ -10866,6 +10866,14 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestP
}
scriptContentID, _ := res.LastInsertId()
uninstallScript := fmt.Sprintf(`echo uninstall '%s'`, kind)
resUninstall, err := q.ExecContext(ctx, `INSERT INTO script_contents (md5_checksum, contents) VALUES (UNHEX(md5(?)), ?)`,
uninstallScript, uninstallScript)
if err != nil {
return err
}
uninstallScriptContentID, _ := resUninstall.LastInsertId()
res, err = q.ExecContext(ctx, `INSERT INTO software_titles (name, source) VALUES ('foo', ?)`, kind)
if err != nil {
return err
@ -10875,10 +10883,11 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestP
_, 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)
(title_id, filename, version, install_script_content_id, uninstall_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")
(?, ?, ?, ?, ?, unhex(?), ?, ?, ?)`,
titleID, fmt.Sprintf("installer.%s", kind), "v1.0.0", scriptContentID, uninstallScriptContentID,
hex.EncodeToString([]byte("test")), tm.ID, tm.ID, "foo")
return err
})
}
@ -10901,7 +10910,8 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestP
}
var resp installSoftwareResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", host.ID, softwareTitles[kind]), nil, wantStatus, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, softwareTitles[kind]), nil,
wantStatus, &resp)
}
}
}
@ -10927,7 +10937,8 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
var resp installSoftwareResponse
// non-existent host
s.DoJSON("POST", "/api/latest/fleet/hosts/1/software/install/1", nil, http.StatusNotFound, &resp)
s.DoJSON("POST", "/api/latest/fleet/hosts/1/software/1/install", nil, http.StatusNotFound, &resp)
s.DoJSON("POST", "/api/latest/fleet/hosts/1/software/1/uninstall", nil, http.StatusNotFound, &resp)
// create a host that doesn't have fleetd installed
h, err := s.ds.NewHost(context.Background(), &fleet.Host{
@ -10947,29 +10958,65 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
// request fails
resp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/1", h.ID), nil, http.StatusUnprocessableEntity, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/1/install", h.ID), nil, http.StatusUnprocessableEntity, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/1/uninstall", h.ID), nil, http.StatusUnprocessableEntity, &resp)
// host installs fleetd
setOrbitEnrollment(t, h, s.ds)
orbitKey := setOrbitEnrollment(t, h, s.ds)
h.OrbitNodeKey = &orbitKey
// 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)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/1/install", h.ID), nil, http.StatusBadRequest, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/1/uninstall", h.ID), nil, http.StatusBadRequest, &resp)
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "another install script",
PreInstallQuery: "another pre install query",
PostInstallScript: "another post install script",
UninstallScript: "another uninstall script with $PACKAGE_ID",
Filename: "ruby.deb",
Title: "ruby",
TeamID: teamID,
}
s.uploadSoftwareInstaller(payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages")
// Get title with software installer
respTitle := getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &respTitle, "team_id",
fmt.Sprintf("%d", *teamID))
require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage)
assert.Equal(t, "another install script", respTitle.SoftwareTitle.SoftwarePackage.InstallScript)
assert.Equal(t, `another uninstall script with "ruby"`, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript)
// Upload another package for another platform
payloadDummy := &fleet.UploadSoftwareInstallerPayload{
Filename: "dummy_installer.pkg",
Title: "DummyApp.app",
TeamID: teamID,
}
s.uploadSoftwareInstaller(payloadDummy, http.StatusOK, "")
pkgTitleID := getSoftwareTitleID(t, s.ds, payloadDummy.Title, "apps")
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", pkgTitleID), nil, http.StatusOK, &respTitle, "team_id",
fmt.Sprintf("%d", *teamID))
require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage)
assert.NotEmpty(t, respTitle.SoftwareTitle.SoftwarePackage.InstallScript)
assert.NotEmpty(t, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript)
assert.NotContains(t, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript, "$PACKAGE_ID")
assert.Contains(t, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript, "com.example.dummy")
// install/uninstall request fails for the wrong platform
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, pkgTitleID), nil, http.StatusBadRequest, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, pkgTitleID), nil, http.StatusBadRequest, &resp)
// delete software installer which we will not use
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", pkgTitleID), nil, http.StatusNoContent,
"team_id", fmt.Sprintf("%d", *teamID))
// 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)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titleID), nil, http.StatusAccepted, &resp)
// Get the results, should be pending
getHostSoftwareResp := getHostSoftwareResponse{}
@ -10978,16 +11025,21 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage)
require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall)
require.NotNil(t, getHostSoftwareResp.Software[0].Status)
require.Equal(t, fleet.SoftwareInstallerPending, *getHostSoftwareResp.Software[0].Status)
require.Equal(t, fleet.SoftwareInstallPending, *getHostSoftwareResp.Software[0].Status)
assert.Nil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall)
installUUID := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID
gsirr := getSoftwareInstallResultsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/results/%s", installUUID), nil, http.StatusOK, &gsirr)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/%s/results", 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)
require.Equal(t, fleet.SoftwareInstallPending, results.Status)
// Can't install/uninstall if software install is pending
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titleID), nil, http.StatusBadRequest, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, titleID), nil, http.StatusBadRequest, &resp)
// create 3 more hosts, will have statuses installed, failed and one with two
// install requests - one failed and the latest install pending
@ -10997,7 +11049,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
err = s.ds.AddHostsToTeam(context.Background(), teamID, []uint{h2.ID, h3.ID, h4.ID})
require.NoError(t, err)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", h2.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h2.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h2.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
installUUID2 := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID
@ -11009,7 +11061,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
"install_script_output": "ok"
}`, *h2.OrbitNodeKey, installUUID2)), http.StatusNoContent)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", h3.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h3.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h3.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
installUUID3 := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID
@ -11021,7 +11073,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
"install_script_output": "failed"
}`, *h3.OrbitNodeKey, installUUID3)), http.StatusNoContent)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", h4.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h4.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h4.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
installUUID4a := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID
@ -11031,7 +11083,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
"pre_install_condition_output": ""
}`, *h4.OrbitNodeKey, installUUID4a)), http.StatusNoContent)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", h4.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h4.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h4.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
installUUID4b := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID
@ -11047,9 +11099,9 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
require.Equal(t, "ruby.deb", titleResp.SoftwareTitle.SoftwarePackage.Name)
require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage.Status)
require.Equal(t, fleet.SoftwareInstallerStatusSummary{
Installed: 1,
Pending: 2,
Failed: 1,
Installed: 1,
PendingInstall: 2,
FailedInstall: 1,
}, *titleResp.SoftwareTitle.SoftwarePackage.Status)
// status is reflected in list hosts responses and counts when filtering by software title and status
@ -11159,7 +11211,101 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
assert.Nil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall)
assert.Empty(t, getHostSoftwareResp.Software[0].InstalledVersions, "Installed versions should now not exist")
// Access software install result after host is deleted
// Mark original install successful
s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "ok",
"install_script_exit_code": 0,
"install_script_output": "ok"
}`, *h.OrbitNodeKey, installUUID)), http.StatusNoContent)
// Do uninstall
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
assert.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall)
assert.Equal(t, fleet.SoftwareUninstallPending, *getHostSoftwareResp.Software[0].Status)
require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall)
uninstallExecutionID := getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall.ExecutionID
// Uninstall should show up as a pending activity
var listUpcomingAct listHostUpcomingActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", h.ID), nil, http.StatusOK, &listUpcomingAct)
require.Len(t, listUpcomingAct.Activities, 1)
assert.Equal(t, fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), listUpcomingAct.Activities[0].Type)
details := make(map[string]interface{}, 5)
require.NoError(t, json.Unmarshal(*listUpcomingAct.Activities[0].Details, &details))
assert.EqualValues(t, fleet.SoftwareUninstallPending, details["status"])
// Check that 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)))
require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage)
assert.Equal(t, "ruby.deb", titleResp.SoftwareTitle.SoftwarePackage.Name)
require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage.Status)
assert.Equal(t, fleet.SoftwareInstallerStatusSummary{
PendingInstall: 1,
FailedInstall: 1,
PendingUninstall: 1,
}, *titleResp.SoftwareTitle.SoftwarePackage.Status)
// Another install/uninstall cannot be send once an uninstall is pending
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titleID), nil, http.StatusBadRequest, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, titleID), nil, http.StatusBadRequest, &resp)
// Host sends successful uninstall result
var orbitPostScriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *h.OrbitNodeKey,
uninstallExecutionID)),
http.StatusOK, &orbitPostScriptResp)
// Check activity feed
var activitiesResp listActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", h.ID), nil, http.StatusOK, &activitiesResp, "order_key", "a.id",
"order_direction", "desc")
require.NotEmpty(t, activitiesResp.Activities)
assert.Equal(t, fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), activitiesResp.Activities[0].Type)
details = make(map[string]interface{}, 5)
require.NoError(t, json.Unmarshal(*activitiesResp.Activities[0].Details, &details))
assert.Equal(t, "uninstalled", details["status"])
// Software should be available for install again
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
assert.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall)
require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall)
assert.Nil(t, getHostSoftwareResp.Software[0].Status)
// Uninstall again, but this time with a failed result
beforeUninstall := time.Now()
// Since host_script_results does not use fine-grained timestamps yet, we adjust
beforeUninstall = beforeUninstall.Add(-time.Second)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/uninstall", h.ID, titleID), nil, http.StatusAccepted, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", h.ID), nil, http.StatusOK, &getHostSoftwareResp)
require.Len(t, getHostSoftwareResp.Software, 1)
assert.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall)
assert.Equal(t, fleet.SoftwareUninstallPending, *getHostSoftwareResp.Software[0].Status)
require.NotNil(t, getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall)
uninstallExecutionID = getHostSoftwareResp.Software[0].SoftwarePackage.LastUninstall.ExecutionID
// Host sends failed uninstall result
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 1, "output": "not ok"}`, *h.OrbitNodeKey,
uninstallExecutionID)),
http.StatusOK, &orbitPostScriptResp)
// Check activity feed
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", h.ID), nil, http.StatusOK, &activitiesResp, "order_key", "a.id",
"order_direction", "desc")
require.NotEmpty(t, activitiesResp.Activities)
assert.Equal(t, fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), activitiesResp.Activities[0].Type)
details = make(map[string]interface{}, 5)
require.NoError(t, json.Unmarshal(*activitiesResp.Activities[0].Details, &details))
assert.Equal(t, "failed", details["status"])
// Access software install/uninstall result after host is deleted
err = s.ds.DeleteHost(context.Background(), h.ID)
require.NoError(t, err)
@ -11168,12 +11314,21 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() {
require.NotNil(t, instResult.HostDeletedAt)
gsirr = getSoftwareInstallResultsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/results/%s", installUUID), nil, http.StatusOK, &gsirr)
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/%s/results", 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)
require.Equal(t, fleet.SoftwareInstalled, results.Status)
var scriptResultResp getScriptResultResponse
s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+uninstallExecutionID, nil, http.StatusOK, &scriptResultResp)
assert.Equal(t, h.ID, scriptResultResp.HostID)
assert.NotEmpty(t, scriptResultResp.ScriptContents)
require.NotNil(t, scriptResultResp.ExitCode)
assert.EqualValues(t, 1, *scriptResultResp.ExitCode)
assert.Equal(t, "not ok", scriptResultResp.Output)
assert.Less(t, beforeUninstall, scriptResultResp.CreatedAt)
}
func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
@ -11225,7 +11380,7 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
require.Equal(t, host1.ID, details.HostID)
require.Equal(t, details.SoftwareTitle, payloadSS.Title)
require.True(t, details.SelfService)
require.EqualValues(t, fleet.SoftwareInstallerPending, details.Status)
require.EqualValues(t, fleet.SoftwareInstallPending, details.Status)
installID := details.InstallUUID
// record the installation results
@ -11255,7 +11410,7 @@ func (s *integrationEnterpriseTestSuite) TestSelfServiceSoftwareInstall() {
require.Equal(t, host1.ID, details.HostID)
require.Equal(t, details.SoftwareTitle, payloadSS.Title)
require.True(t, details.SelfService)
require.EqualValues(t, fleet.SoftwareInstallerInstalled, details.Status)
require.EqualValues(t, fleet.SoftwareInstalled, details.Status)
}
func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
@ -11306,7 +11461,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
titleIDs := []uint{titleID, titleID2, titleID3}
for i := 0; i < len(installUUIDs); i++ {
resp := installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/install/%d", host.ID, titleIDs[i]), nil, http.StatusAccepted, &resp)
s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/%d/install", host.ID, titleIDs[i]), nil, http.StatusAccepted, &resp)
installUUIDs[i] = latestInstallUUID()
}
@ -11320,7 +11475,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
}
checkResults := func(want result) {
var resp getSoftwareInstallResultsResponse
s.DoJSON("GET", "/api/v1/fleet/software/install/results/"+want.InstallUUID, nil, http.StatusOK, &resp)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/install/%s/results", want.InstallUUID), nil, http.StatusOK, &resp)
assert.Equal(t, want.HostID, resp.Results.HostID)
assert.Equal(t, want.InstallUUID, resp.Results.InstallUUID)
@ -11342,7 +11497,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
checkResults(result{
HostID: host.ID,
InstallUUID: installUUIDs[0],
Status: fleet.SoftwareInstallerFailed,
Status: fleet.SoftwareInstallFailed,
PreInstallQueryOutput: ptr.String(fleet.SoftwareInstallerQuerySuccessCopy),
Output: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerInstallFailCopy, "failed")),
})
@ -11352,7 +11507,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
InstallUUID: installUUIDs[0],
Status: string(fleet.SoftwareInstallerFailed),
Status: string(fleet.SoftwareInstallFailed),
}
s.lastActivityMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
@ -11366,7 +11521,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
checkResults(result{
HostID: host.ID,
InstallUUID: installUUIDs[1],
Status: fleet.SoftwareInstallerFailed,
Status: fleet.SoftwareInstallFailed,
PreInstallQueryOutput: ptr.String(fleet.SoftwareInstallerQueryFailCopy),
})
wantAct = fleet.ActivityTypeInstalledSoftware{
@ -11375,7 +11530,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
SoftwareTitle: payload2.Title,
SoftwarePackage: payload2.Filename,
InstallUUID: installUUIDs[1],
Status: string(fleet.SoftwareInstallerFailed),
Status: string(fleet.SoftwareInstallFailed),
}
s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
@ -11393,7 +11548,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
checkResults(result{
HostID: host.ID,
InstallUUID: installUUIDs[2],
Status: fleet.SoftwareInstallerInstalled,
Status: fleet.SoftwareInstalled,
PreInstallQueryOutput: ptr.String(fleet.SoftwareInstallerQuerySuccessCopy),
Output: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerInstallSuccessCopy, "success")),
PostInstallScriptOutput: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerPostInstallSuccessCopy, "ok")),
@ -11404,7 +11559,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() {
SoftwareTitle: payload3.Title,
SoftwarePackage: payload3.Filename,
InstallUUID: installUUIDs[2],
Status: string(fleet.SoftwareInstallerInstalled),
Status: string(fleet.SoftwareInstalled),
}
lastActID := s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
@ -11559,6 +11714,7 @@ func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(
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))
require.NoError(t, w.WriteField("uninstall_script", payload.UninstallScript))
if payload.SelfService {
require.NoError(t, w.WriteField("self_service", "true"))
}
@ -12900,7 +13056,8 @@ func (s *integrationEnterpriseTestSuite) TestVPPAppsWithoutMDM() {
}, &team.ID)
require.NoError(t, err)
r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", orbitHost.ID, app.TitleID), &installSoftwareRequest{}, http.StatusUnprocessableEntity)
r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", orbitHost.ID, app.TitleID), &installSoftwareRequest{},
http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(r.Body), "Couldn't install. MDM is turned off. Please make sure that MDM is turned on to install App Store apps.")
}
@ -13258,12 +13415,12 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
require.NotNil(t, host1LastInstall)
require.NotEmpty(t, host1LastInstall.ExecutionID)
require.NotNil(t, host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status)
prevExecutionID := host1LastInstall.ExecutionID
// Request a manual installation on the host for the same installer, which should fail.
var installResp installSoftwareResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d",
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install",
host1Team1.ID, dummyInstallerPkgTitleID), nil, http.StatusBadRequest, &installResp)
// Submit same results as before, which should not trigger a installation because the policy is already failing.
@ -13282,7 +13439,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
require.NotNil(t, host1LastInstall)
require.Equal(t, prevExecutionID, host1LastInstall.ExecutionID)
require.NotNil(t, host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status)
// Submit same results but policy1Team1 now passes,
// and then submit again but policy1Team1 fails.
@ -13311,7 +13468,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
require.NotNil(t, host1LastInstall)
require.Equal(t, prevExecutionID, host1LastInstall.ExecutionID)
require.NotNil(t, host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallPending, *host1LastInstall.Status)
// host2Team1 is failing policy2Team1 and policy3Team1 policies.
distributedResp = submitDistributedQueryResultsResponse{}
@ -13328,7 +13485,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
require.NotNil(t, host2LastInstall)
require.NotEmpty(t, host2LastInstall.ExecutionID)
require.NotNil(t, host2LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallerPending, *host2LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallPending, *host2LastInstall.Status)
// Associate fleet-osquery.msi to policy4Team2.
mtplr = modifyTeamPolicyResponse{}
@ -13352,7 +13509,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
require.NotNil(t, host3LastInstall)
require.NotEmpty(t, host3LastInstall.ExecutionID)
require.NotNil(t, host3LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallerPending, *host3LastInstall.Status)
require.Equal(t, fleet.SoftwareInstallPending, *host3LastInstall.Status)
host3LastInstallDetails, err := s.ds.GetSoftwareInstallDetails(ctx, host3LastInstall.ExecutionID)
require.NoError(t, err)
// Even if fleet-osquery.msi was uploaded as Self-service, it was installed by Fleet, so
@ -13454,8 +13611,8 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
"software_package": "%s",
"self_service": false,
"install_uuid": "%s",
"status": "failed"
}`, host2Team1.ID, host2Team1.DisplayName(), "ruby", "ruby.deb", host2LastInstall.ExecutionID), 0)
"status": "%s"
}`, host2Team1.ID, host2Team1.DisplayName(), "ruby", "ruby.deb", host2LastInstall.ExecutionID, fleet.SoftwareInstallFailed), 0)
// Check that the activity item generated for ruby.deb installation has a null user,
// but has name and email set.
@ -13491,8 +13648,9 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers
"software_package": "%s",
"self_service": false,
"install_uuid": "%s",
"status": "failed"
}`, host3Team2.ID, host3Team2.DisplayName(), "Fleet osquery", "fleet-osquery.msi", host3LastInstall.ExecutionID), 0)
"status": "%s"
}`, host3Team2.ID, host3Team2.DisplayName(), "Fleet osquery", "fleet-osquery.msi", host3LastInstall.ExecutionID,
fleet.SoftwareInstallFailed), 0)
// Check that the activity item generated for fleet-osquery.msi installation has the admin user set as author.
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {

View file

@ -10707,7 +10707,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
// attempt to install a VPP app on the non-MDM enrolled host
installResp := installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", orbitHost.ID, macOSTitleID), &installSoftwareRequest{},
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", orbitHost.ID, macOSTitleID), &installSoftwareRequest{},
http.StatusBadRequest, &installResp)
// Disable all teams token
@ -10738,7 +10738,8 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", validToken.Token.ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{}}, http.StatusOK, &resPatchVPP)
// Attempt to install non-existent app
r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, 99999), &installSoftwareRequest{}, http.StatusBadRequest)
r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, 99999), &installSoftwareRequest{},
http.StatusBadRequest)
require.Contains(t, extractServerErrorText(r.Body), "Couldn't install software. Software title is not available for install. Please add software package or App Store app to install.")
// Add app 1 as self-service
@ -10757,7 +10758,8 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
// Trigger install to the host
installResp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, errTitleID), &installSoftwareRequest{}, http.StatusAccepted, &installResp)
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, errTitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d", vppRes.Token.ID), &deleteVPPTokenRequest{}, http.StatusNoContent)
@ -10802,14 +10804,14 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
errApp.Name,
errApp.AdamID,
failedCmdUUID,
fleet.SoftwareInstallerFailed,
fleet.SoftwareInstallFailed,
),
0,
)
// Trigger install to the host
installResp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, macOSTitleID), &installSoftwareRequest{},
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", mdmHost.ID, macOSTitleID), &installSoftwareRequest{},
http.StatusAccepted, &installResp)
countResp = countHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "software_status", "pending", "team_id",
@ -10849,7 +10851,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
addedApp.Name,
addedApp.AdamID,
cmdUUID,
fleet.SoftwareInstallerInstalled,
fleet.SoftwareInstalled,
),
0,
)
@ -10868,12 +10870,12 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
require.Empty(t, got1.AppStoreApp.Name) // Name is only present for installer packages
require.Equal(t, got1.AppStoreApp.Version, addedApp.LatestVersion)
require.NotNil(t, got1.Status)
require.Equal(t, *got1.Status, fleet.SoftwareInstallerInstalled)
require.Equal(t, *got1.Status, fleet.SoftwareInstalled)
require.Equal(t, got1.AppStoreApp.LastInstall.CommandUUID, cmdUUID)
require.NotNil(t, got1.AppStoreApp.LastInstall.InstalledAt)
require.Equal(t, got2.Name, "App 2")
require.NotNil(t, got2.Status)
require.Equal(t, *got2.Status, fleet.SoftwareInstallerFailed)
require.Equal(t, *got2.Status, fleet.SoftwareInstallFailed)
require.NotNil(t, got2.AppStoreApp)
require.Equal(t, got2.AppStoreApp.AppStoreID, errApp.AdamID)
require.Equal(t, got2.AppStoreApp.IconURL, ptr.String(errApp.IconURL))
@ -10895,7 +10897,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
require.Empty(t, got1.AppStoreApp.Name)
require.Equal(t, got1.AppStoreApp.Version, addedApp.LatestVersion)
require.NotNil(t, got1.Status)
require.Equal(t, *got1.Status, fleet.SoftwareInstallerInstalled)
require.Equal(t, *got1.Status, fleet.SoftwareInstalled)
require.Equal(t, got1.AppStoreApp.LastInstall.CommandUUID, cmdUUID)
require.NotNil(t, got1.AppStoreApp.LastInstall.InstalledAt)
@ -10988,7 +10990,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
&fleetSelfServiceSoftwareInstallRequest{}, http.StatusAccepted, &ssInstallResp)
} else {
installResp = installSoftwareResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", installHost.ID, titleID),
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", installHost.ID, titleID),
&installSoftwareRequest{}, http.StatusAccepted, &installResp)
}
countResp = countHostsResponse{}
@ -11026,7 +11028,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
app.Name,
app.AdamID,
cmdUUID,
fleet.SoftwareInstallerPending,
fleet.SoftwareInstallPending,
install.deviceToken != "",
),
string(*hostActivitiesResp.Activities[0].Details),
@ -11054,7 +11056,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
app.Name,
app.AdamID,
cmdUUID,
fleet.SoftwareInstallerInstalled,
fleet.SoftwareInstalled,
install.deviceToken != "",
),
0,
@ -11074,7 +11076,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
require.Equal(t, got1.AppStoreApp.IconURL, ptr.String(app.IconURL))
require.Empty(t, got1.AppStoreApp.Name) // Name is only present for installer packages
require.Equal(t, got1.AppStoreApp.Version, app.LatestVersion)
require.Equal(t, *got1.Status, fleet.SoftwareInstallerInstalled)
require.Equal(t, *got1.Status, fleet.SoftwareInstalled)
require.Equal(t, got1.AppStoreApp.LastInstall.CommandUUID, cmdUUID)
require.NotNil(t, got1.AppStoreApp.LastInstall.InstalledAt)
foundInstalledApp = true

View file

@ -685,7 +685,7 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host
// always use the authenticated host's ID as host_id
result.HostID = host.ID
hsr, err := svc.ds.SetHostScriptExecutionResult(ctx, result)
hsr, action, err := svc.ds.SetHostScriptExecutionResult(ctx, result)
if err != nil {
return ctxerr.Wrap(ctx, err, "save host script result")
}
@ -707,20 +707,47 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host
scriptName = scr.Name
}
// TODO(sarah): We may need to special case lock/unlock script results here?
if err := svc.NewActivity(
ctx,
user,
fleet.ActivityTypeRanScript{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
ScriptExecutionID: hsr.ExecutionID,
ScriptName: scriptName,
Async: !hsr.SyncRequest,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for script execution request")
switch action {
case "uninstall":
// Get software title from execution ID
softwareTitleName, err := svc.ds.GetSoftwareTitleNameFromExecutionID(ctx, hsr.ExecutionID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get software title from execution ID")
}
activityStatus := "failed"
if hsr.ExitCode != nil && *hsr.ExitCode == 0 {
activityStatus = "uninstalled"
}
if err := svc.NewActivity(
ctx,
user,
fleet.ActivityTypeUninstalledSoftware{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
SoftwareTitle: softwareTitleName,
ExecutionID: hsr.ExecutionID,
Status: activityStatus,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for script execution request")
}
default:
// TODO(sarah): We may need to special case lock/unlock script results here?
if err := svc.NewActivity(
ctx,
user,
fleet.ActivityTypeRanScript{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
ScriptExecutionID: hsr.ExecutionID,
ScriptName: scriptName,
Async: !hsr.SyncRequest,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for script execution request")
}
}
}
return nil
}
@ -992,7 +1019,7 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f
return ctxerr.Wrap(ctx, err, "save host software installation result")
}
if status := result.Status(); status != fleet.SoftwareInstallerPending {
if status := result.Status(); status != fleet.SoftwareInstallPending {
hsi, err := svc.ds.GetSoftwareInstallResults(ctx, result.InstallUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get host software installation result information")

View file

@ -1713,7 +1713,7 @@ func (svc *Service) processSoftwareForNewlyFailingPolicies(
}
// hostLastInstall.Status == nil can happen when a software is installed by Fleet and later removed.
if hostLastInstall != nil && hostLastInstall.Status != nil &&
*hostLastInstall.Status == fleet.SoftwareInstallerPending {
*hostLastInstall.Status == fleet.SoftwareInstallPending {
// There's a pending install for this host and installer,
// thus we do not queue another install request.
level.Debug(svc.logger).Log(

View file

@ -349,16 +349,17 @@ type getScriptResultRequest struct {
}
type getScriptResultResponse struct {
ScriptContents string `json:"script_contents"`
ScriptID *uint `json:"script_id"`
ExitCode *int64 `json:"exit_code"`
Output string `json:"output"`
Message string `json:"message"`
HostName string `json:"hostname"`
HostTimeout bool `json:"host_timeout"`
HostID uint `json:"host_id"`
ExecutionID string `json:"execution_id"`
Runtime int `json:"runtime"`
ScriptContents string `json:"script_contents"`
ScriptID *uint `json:"script_id"`
ExitCode *int64 `json:"exit_code"`
Output string `json:"output"`
Message string `json:"message"`
HostName string `json:"hostname"`
HostTimeout bool `json:"host_timeout"`
HostID uint `json:"host_id"`
ExecutionID string `json:"execution_id"`
Runtime int `json:"runtime"`
CreatedAt time.Time `json:"created_at"`
Err error `json:"error,omitempty"`
}
@ -388,6 +389,7 @@ func getScriptResultEndpoint(ctx context.Context, request interface{}, svc fleet
HostID: scriptResult.HostID,
ExecutionID: scriptResult.ExecutionID,
Runtime: scriptResult.Runtime,
CreatedAt: scriptResult.CreatedAt,
}, nil
}

View file

@ -29,6 +29,7 @@ type uploadSoftwareInstallerRequest struct {
PreInstallQuery string
PostInstallScript string
SelfService bool
UninstallScript string
}
type uploadSoftwareInstallerResponse struct {
@ -96,6 +97,11 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http
decoded.InstallScript = val[0]
}
val, ok = r.MultipartForm.Value["uninstall_script"]
if ok && len(val) > 0 {
decoded.UninstallScript = val[0]
}
val, ok = r.MultipartForm.Value["pre_install_query"]
if ok && len(val) > 0 {
decoded.PreInstallQuery = val[0]
@ -136,6 +142,7 @@ func uploadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s
InstallerFile: ff,
Filename: req.File.Filename,
SelfService: req.SelfService,
UninstallScript: req.UninstallScript,
}
if err := svc.UploadSoftwareInstaller(ctx, payload); err != nil {
@ -332,6 +339,28 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
return fleet.ErrMissingLicense
}
type uninstallSoftwareRequest struct {
HostID uint `url:"host_id"`
SoftwareTitleID uint `url:"software_title_id"`
}
func uninstallSoftwareTitleEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*uninstallSoftwareRequest)
err := svc.UninstallSoftwareTitle(ctx, req.HostID, req.SoftwareTitleID)
if err != nil {
return installSoftwareResponse{Err: err}, nil
}
return installSoftwareResponse{}, nil
}
func (svc *Service) UninstallSoftwareTitle(ctx context.Context, _ uint, _ uint) error {
// skipauth: No authorization check needed due to implementation returning only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
type getSoftwareInstallResultsRequest struct {
InstallUUID string `url:"install_uuid"`
}