diff --git a/changes/20320-uninstall-packages b/changes/20320-uninstall-packages new file mode 100644 index 0000000000..89ab892841 --- /dev/null +++ b/changes/20320-uninstall-packages @@ -0,0 +1 @@ +* Implement the ability to use Fleet to uninstall packages from hosts. \ No newline at end of file diff --git a/codecov.yml b/codecov.yml index f91f1aae40..cac1d5c0d1 100644 --- a/codecov.yml +++ b/codecov.yml @@ -25,3 +25,4 @@ flag_management: ignore: - "server/mock" + - "server/fleet/activities.go" # mostly contains code for documentation -- not interesting for tests diff --git a/docs/Contributing/Audit-logs.md b/docs/Contributing/Audit-logs.md index b2fe63528b..c4baf17b74 100644 --- a/docs/Contributing/Audit-logs.md +++ b/docs/Contributing/Audit-logs.md @@ -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. diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 281d1b05f9..abdaaf8aa0 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -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\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") diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go new file mode 100644 index 0000000000..6abd95085e --- /dev/null +++ b/ee/server/service/software_installers_test.go @@ -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 +} diff --git a/frontend/__mocks__/scriptMock.ts b/frontend/__mocks__/scriptMock.ts index bb57b11857..ff8d635122 100644 --- a/frontend/__mocks__/scriptMock.ts +++ b/frontend/__mocks__/scriptMock.ts @@ -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 = ( diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index 9ef0b14e9a..fb2fc313ba 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -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, }, }; diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx index 61c2d5f04f..42ed1d6dd3 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetails/SoftwareInstallDetails.tsx @@ -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 (
Fleet {getInstallDetailsStatusPredicate(status)} {software_title}{" "} ({software_package}) on {formattedHost} - {status === "pending" ? " when it comes online" : ""}. + {status === "pending_install" ? " when it comes online" : ""} + {displayTimeStamp}.
); @@ -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 && ( diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal.tsx new file mode 100644 index 0000000000..dfb4ef13c9 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/SoftwareUninstallDetailsModal.tsx @@ -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 ? ( + {host_display_name} + ) : ( + "the host" + ); + + const isPending = isPendingStatus(status); + const displayTimeStamp = + !isPending && timestamp + ? ` (${formatDistanceToNow(new Date(timestamp), { + includeSeconds: true, + addSuffix: true, + })})` + : ""; + return ( +
+ + + Fleet {getInstallDetailsStatusPredicate(status)} {software_title}{" "} + from {formattedHost} + {isPending ? " when it comes online" : ""} + {displayTimeStamp}. + +
+ ); +}; + +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( + ["uninstallResult", details.script_execution_id], + () => { + return scriptsAPI.getScriptResult(script_execution_id); + }, + { ...DEFAULT_USE_QUERY_OPTIONS } + ); + + if (isLoading) { + return ; + } else if (isError) { + return ; + } else if (!scriptResult) { + // FIXME: Find a better solution for this. + return ; + } + + status = status === "failed" ? "failed_uninstall" : status; + + return ( + <> + + {!isPendingStatus(status) && scriptResult && ( + <> +
+ Uninstall script content: + +
+ +
+ Uninstall script output: + +
+ + )} + + ); + }; + + return ( + + <> +
+ +
+
+ +
+ +
+ ); +}; + +export default SoftwareUninstallDetailsModal; diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/_styles.scss b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/_styles.scss new file mode 100644 index 0000000000..ca0ad4a2c1 --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/_styles.scss @@ -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; + } + } +} diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/index.ts b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/index.ts new file mode 100644 index 0000000000..c57d50fe8d --- /dev/null +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareUninstallDetailsModal/index.ts @@ -0,0 +1 @@ +export { default } from "./SoftwareUninstallDetailsModal"; diff --git a/frontend/components/ActivityDetails/InstallDetails/constants.ts b/frontend/components/ActivityDetails/InstallDetails/constants.ts index c4c1ae8cb7..255b12e5fd 100644 --- a/frontend/components/ActivityDetails/InstallDetails/constants.ts +++ b/frontend/components/ActivityDetails/InstallDetails/constants.ts @@ -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 ); }; diff --git a/frontend/components/buttons/RevealButton/RevealButton.tsx b/frontend/components/buttons/RevealButton/RevealButton.tsx index c1db68f9d9..c54d5a0010 100644 --- a/frontend/components/buttons/RevealButton/RevealButton.tsx +++ b/frontend/components/buttons/RevealButton/RevealButton.tsx @@ -13,6 +13,7 @@ export interface IRevealButtonProps { autofocus?: boolean; disabled?: boolean; tooltipContent?: React.ReactNode; + disabledTooltipContent?: React.ReactNode; onClick?: | ((value?: any) => void) | ((evt: React.MouseEvent) => 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 ? ( - {text} - ) : ( - text - ); + const buttonText = + tooltipContent && !disabled ? ( + {text} + ) : ( + text + ); return ( <> @@ -61,7 +64,7 @@ const RevealButton = ({ ); }; - return ( + const 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 ? ( An end user diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx index 1c6a31e9d7..0229550957 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx @@ -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 +
+ 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.
- 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; } diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts index 04bf2d18d4..986497d160 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.ts @@ -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, }; }; diff --git a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx index 5d6524e8dc..26476a8e89 100644 --- a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx +++ b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx @@ -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)}{" "} + + +); + +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)}{" "} + + + ); +}; 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 ( +
+ + Software will be installed only if the{" "} + + + } + /> + + + +
+ ); + }; + return (
setShowAdvancedOptions(!showAdvancedOptions)} + disabled={!selectedPackage} + disabledTooltipContent={ + selectedPackage + ? "Choose a file to modify advanced options." + : undefined + } /> - {showAdvancedOptions && ( -
- - Software will be installed only if the{" "} - - - } - /> - - Fleet will run this script on hosts to install software. Use the -
- $INSTALLER_PATH variable to point to the installer. - - } - isFormField - /> - -
- )} + {showAdvancedOptions && !!selectedPackage && renderAdvancedOptions()}
); }; diff --git a/frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx b/frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx index cf3802b3f6..3fe7922fbb 100644 --- a/frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx @@ -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({ software: null, - installScript: "", preInstallQuery: undefined, + installScript: "", postInstallScript: undefined, + uninstallScript: undefined, selfService: false, }); const [formValidation, setFormValidation] = useState({ @@ -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 = ({