Skip to content

Commit 1f4ad17

Browse files
committed
Can run install scripts now.
1 parent c591b49 commit 1f4ad17

File tree

22 files changed

+496
-78
lines changed

22 files changed

+496
-78
lines changed

ee/server/service/software_installers.go

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
"github.com/fleetdm/fleet/v4/pkg/file"
1919
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
20+
"github.com/fleetdm/fleet/v4/server/authz"
2021
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
2122
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
2223
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
@@ -415,11 +416,12 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
415416
if err != nil {
416417
return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID)
417418
}
418-
if lastInstallRequest != nil && lastInstallRequest.Status != nil && *lastInstallRequest.Status == fleet.SoftwareInstallPending {
419+
if lastInstallRequest != nil && lastInstallRequest.Status != nil &&
420+
(*lastInstallRequest.Status == fleet.SoftwareInstallPending || *lastInstallRequest.Status == fleet.SoftwareUninstallPending) {
419421
return &fleet.BadRequestError{
420-
Message: "Couldn't install software. Host has a pending install request.",
422+
Message: "Could not install software. Host has a pending install/uninstall request.",
421423
InternalErr: ctxerr.WrapWithData(
422-
ctx, err, "host already has a pending install for this installer",
424+
ctx, err, "host already has a pending install/uninstall for this installer",
423425
map[string]any{
424426
"host_id": host.ID,
425427
"software_installer_id": installer.InstallerID,
@@ -594,6 +596,151 @@ func (svc *Service) installSoftwareTitleUsingInstaller(ctx context.Context, host
594596
return ctxerr.Wrap(ctx, err, "inserting software install request")
595597
}
596598

599+
func (svc *Service) UninstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error {
600+
// First check if scripts are disabled globally. If so, no need for further processing.
601+
cfg, err := svc.ds.AppConfig(ctx)
602+
if err != nil {
603+
svc.authz.SkipAuthorization(ctx)
604+
return err
605+
}
606+
607+
if cfg.ServerSettings.ScriptsDisabled {
608+
svc.authz.SkipAuthorization(ctx)
609+
return fleet.NewUserMessageError(errors.New(fleet.RunScriptScriptsDisabledGloballyErrMsg), http.StatusForbidden)
610+
}
611+
612+
// we need to use ds.Host because ds.HostLite doesn't return the orbit node key
613+
host, err := svc.ds.Host(ctx, hostID)
614+
if err != nil {
615+
// if error is because the host does not exist, check first if the user
616+
// had access to install/uninstall software (to prevent leaking valid host ids).
617+
if fleet.IsNotFound(err) {
618+
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionWrite); err != nil {
619+
return err
620+
}
621+
}
622+
svc.authz.SkipAuthorization(ctx)
623+
return ctxerr.Wrap(ctx, err, "get host")
624+
}
625+
626+
if host.OrbitNodeKey == nil || *host.OrbitNodeKey == "" {
627+
// fleetd is required to install software so if the host is enrolled via plain osquery we return an error
628+
svc.authz.SkipAuthorization(ctx)
629+
return fleet.NewUserMessageError(errors.New("host does not have fleetd installed"), http.StatusUnprocessableEntity)
630+
}
631+
632+
// If scripts are disabled (according to the last detail query), we return an error.
633+
// host.ScriptsEnabled may be nil for older orbit versions.
634+
if host.ScriptsEnabled != nil && !*host.ScriptsEnabled {
635+
svc.authz.SkipAuthorization(ctx)
636+
return fleet.NewUserMessageError(errors.New(fleet.RunScriptsOrbitDisabledErrMsg), http.StatusUnprocessableEntity)
637+
}
638+
639+
// authorize with the host's team
640+
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: host.TeamID}, fleet.ActionWrite); err != nil {
641+
return err
642+
}
643+
644+
installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false)
645+
if err != nil {
646+
if fleet.IsNotFound(err) {
647+
return &fleet.BadRequestError{
648+
Message: "Could not uninstall software. Software title is not available for uninstall. Please add software package to install/uninstall.",
649+
InternalErr: ctxerr.WrapWithData(
650+
ctx, err, "couldn't find an installer for software title",
651+
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
652+
),
653+
}
654+
}
655+
return ctxerr.Wrap(ctx, err, "finding software installer for title")
656+
}
657+
658+
lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID)
659+
if err != nil {
660+
return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID)
661+
}
662+
if lastInstallRequest != nil && lastInstallRequest.Status != nil &&
663+
(*lastInstallRequest.Status == fleet.SoftwareInstallPending || *lastInstallRequest.Status == fleet.SoftwareUninstallPending) {
664+
return &fleet.BadRequestError{
665+
Message: "Could not uninstall software. Host has a pending install/uninstall request.",
666+
InternalErr: ctxerr.WrapWithData(
667+
ctx, err, "host already has a pending install/uninstall for this installer",
668+
map[string]any{
669+
"host_id": host.ID,
670+
"software_installer_id": installer.InstallerID,
671+
"team_id": host.TeamID,
672+
"title_id": softwareTitleID,
673+
},
674+
),
675+
}
676+
}
677+
678+
// Validate platform
679+
ext := filepath.Ext(installer.Name)
680+
requiredPlatform := packageExtensionToPlatform(ext)
681+
if requiredPlatform == "" {
682+
// this should never happen
683+
return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext)
684+
}
685+
686+
if host.FleetPlatform() != requiredPlatform {
687+
return &fleet.BadRequestError{
688+
Message: fmt.Sprintf("Package (%s) can be uninstalled only on %s hosts.", ext, requiredPlatform),
689+
InternalErr: ctxerr.NewWithData(
690+
ctx, "invalid host platform for requested uninstall",
691+
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": installer.TitleID},
692+
),
693+
}
694+
}
695+
696+
// Get the uninstall script and use the standard script infrastructure to run it.
697+
contents, err := svc.ds.GetAnyScriptContents(ctx, installer.UninstallScriptContentID)
698+
if err != nil {
699+
if fleet.IsNotFound(err) {
700+
return fleet.NewInvalidArgumentError("software_title_id", `No uninstall script exists for the provided "software_title_id".`).
701+
WithStatus(http.StatusNotFound)
702+
}
703+
return err
704+
}
705+
706+
var teamID uint
707+
if host.TeamID != nil {
708+
teamID = *host.TeamID
709+
}
710+
// create the script execution request, the host will be notified of the
711+
// script execution request via the orbit config's Notifications mechanism.
712+
request := fleet.HostScriptRequestPayload{
713+
HostID: host.ID,
714+
ScriptContents: string(contents),
715+
ScriptContentID: installer.UninstallScriptContentID,
716+
TeamID: teamID,
717+
}
718+
if ctxUser := authz.UserFromContext(ctx); ctxUser != nil {
719+
request.UserID = &ctxUser.ID
720+
}
721+
scriptResult, err := svc.ds.NewHostScriptExecutionRequest(ctx, &request)
722+
if err != nil {
723+
return ctxerr.Wrap(ctx, err, "create script execution request")
724+
}
725+
726+
// Update the host software installs table with the uninstall request
727+
if err = svc.insertSoftwareUninstallRequest(ctx, scriptResult.ExecutionID, host, installer); err != nil {
728+
return err
729+
}
730+
731+
// TODO: Add host activity -- pending uninstall request (upcoming)
732+
733+
return nil
734+
}
735+
736+
func (svc *Service) insertSoftwareUninstallRequest(ctx context.Context, executionID string, host *fleet.Host,
737+
installer *fleet.SoftwareInstaller) error {
738+
if err := svc.ds.InsertSoftwareUninstallRequest(ctx, executionID, host.ID, installer.InstallerID); err != nil {
739+
return ctxerr.Wrap(ctx, err, "inserting software uninstall request")
740+
}
741+
return nil
742+
}
743+
597744
func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID string) (*fleet.HostSoftwareInstallerResult, error) {
598745
// Basic auth check
599746
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {

frontend/components/ActivityDetails/InstallDetails/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export const INSTALL_DETAILS_STATUS_ICONS: Record<
1010
installed: "success-outline",
1111
failed: "error-outline",
1212
failed_install: "error-outline",
13+
pending_uninstall: "pending-outline",
14+
failed_uninstall: "error-outline",
1315
} as const;
1416

1517
const INSTALL_DETAILS_STATUS_PREDICATES: Record<
@@ -21,6 +23,8 @@ const INSTALL_DETAILS_STATUS_PREDICATES: Record<
2123
installed: "installed",
2224
failed: "failed to install",
2325
failed_install: "failed to install",
26+
pending_uninstall: "is uninstalling or will uninstall",
27+
failed_uninstall: "failed to uninstall",
2428
} as const;
2529

2630
export const getInstallDetailsStatusPredicate = (

frontend/interfaces/software.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ export const SOFTWARE_INSTALL_STATUSES = [
200200
"installed",
201201
"pending",
202202
"pending_install",
203+
"pending_uninstall",
204+
"failed_uninstall",
203205
] as const;
204206

205207
/*
@@ -290,6 +292,8 @@ const INSTALL_STATUS_PREDICATES: Record<SoftwareInstallStatus, string> = {
290292
installed: "installed",
291293
pending: "told Fleet to install",
292294
pending_install: "told Fleet to install",
295+
pending_uninstall: "told Fleet to uninstall",
296+
failed_uninstall: "failed to uninstall",
293297
} as const;
294298

295299
export const getInstallStatusPredicate = (status: string | undefined) => {
@@ -308,6 +312,8 @@ export const INSTALL_STATUS_ICONS: Record<SoftwareInstallStatus, IconNames> = {
308312
installed: "success-outline",
309313
failed: "error-outline",
310314
failed_install: "error-outline",
315+
pending_uninstall: "pending-outline",
316+
failed_uninstall: "error-outline",
311317
} as const;
312318

313319
type IHostSoftwarePackageWithLastInstall = IHostSoftwarePackage & {

frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ const STATUS_DISPLAY_OPTIONS: Record<
113113
iconName: "pending-outline",
114114
tooltip: "Fleet will install software when these hosts come online.",
115115
},
116+
pending_uninstall: {
117+
displayName: "Pending",
118+
iconName: "pending-outline",
119+
tooltip: "Fleet will uninstall software when these hosts come online.",
120+
},
116121
failed: {
117122
displayName: "Failed",
118123
iconName: "error",
@@ -129,6 +134,11 @@ const STATUS_DISPLAY_OPTIONS: Record<
129134
iconName: "error",
130135
tooltip: "Fleet failed to install software on these hosts.",
131136
},
137+
failed_uninstall: {
138+
displayName: "Failed",
139+
iconName: "error",
140+
tooltip: "Fleet failed to uninstall software on these hosts.",
141+
},
132142
};
133143

134144
interface IPackageStatusCountProps {

frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record<
5050
displayText: "Pending",
5151
tooltip: () => "Fleet will install software when the host comes online.",
5252
},
53+
pending_uninstall: {
54+
iconName: "pending-outline",
55+
displayText: "Pending",
56+
tooltip: () => "Fleet will uninstall software when the host comes online.",
57+
},
5358
failed: {
5459
iconName: "error",
5560
displayText: "Failed",
@@ -71,6 +76,16 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record<
7176
</>
7277
),
7378
},
79+
failed_uninstall: {
80+
iconName: "error",
81+
displayText: "Failed",
82+
tooltip: ({ lastInstalledAt: lastInstall }) => (
83+
<>
84+
Fleet failed to install software ({dateAgo(lastInstall as string)} ago).
85+
Select <b>Actions &gt; Software details</b> to see more.
86+
</>
87+
),
88+
},
7489
avaiableForInstall: {
7590
iconName: "install",
7691
displayText: "Available for install",

frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ const STATUS_CONFIG: Record<SoftwareInstallStatus, IStatusDisplayConfig> = {
3838
displayText: "Install in progress...",
3939
tooltip: () => "Software installation in progress...",
4040
},
41+
pending_uninstall: {
42+
iconName: "pending-outline",
43+
displayText: "Uninstall in progress...",
44+
tooltip: () => "Software uninstallation in progress...",
45+
},
4146
failed: {
4247
iconName: "error",
4348
displayText: "Failed",
@@ -60,6 +65,17 @@ const STATUS_CONFIG: Record<SoftwareInstallStatus, IStatusDisplayConfig> = {
6065
</>
6166
),
6267
},
68+
failed_uninstall: {
69+
iconName: "error",
70+
displayText: "Failed",
71+
tooltip: ({ lastInstalledAt = "" }) => (
72+
<>
73+
Software failed to install
74+
{lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : ""}. Select{" "}
75+
<b>Retry</b> to install again, or contact your IT department.
76+
</>
77+
),
78+
},
6379
};
6480

6581
interface IInstallerInfoProps {

pkg/file/scripts/uninstall_pkg.sh

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ do
1010
done
1111

1212
# Loop through each pkg_id and remove receipts
13-
1413
for pkg_id in "${pkg_ids[@]}"
1514
do
1615
pkgutil --forget $pkg_id

server/authz/policy.rego

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -677,15 +677,15 @@ allow {
677677
# Host software installs
678678
##
679679

680-
# Global admins and maintainers can write (install) software on hosts (not
680+
# Global admins and maintainers can write (install/uninstall) software on hosts (not
681681
# gitops as this is not something that relates to fleetctl apply).
682682
allow {
683683
object.type == "host_software_installer_result"
684684
subject.global_role == [admin, maintainer][_]
685685
action == write
686686
}
687687

688-
# Team admin and maintainers can write (install) software on hosts for their
688+
# Team admin and maintainers can write (install/uninstall) software on hosts for their
689689
# teams (not gitops as this is not something that relates to fleetctl apply).
690690
allow {
691691
object.type == "host_software_installer_result"
@@ -937,7 +937,7 @@ allow {
937937
action == write
938938
}
939939

940-
# Global admins, maintainers, observer_plus and observers can read scripts.
940+
# Global admins, maintainers, observer_plus and observers can read script results, including software uninstall results.
941941
allow {
942942
object.type == "host_script_result"
943943
subject.global_role == [admin, maintainer, observer, observer_plus][_]
@@ -953,7 +953,7 @@ allow {
953953
action == write
954954
}
955955

956-
# Team admins, maintainers, observer_plus and observers can read scripts for their teams.
956+
# Team admins, maintainers, observer_plus and observers can read script results for their teams, including software uninstall results.
957957
allow {
958958
object.type == "host_script_result"
959959
not is_null(object.team_id)

server/datastore/mysql/activities_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,8 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
506506
h2A := hsr.ExecutionID
507507
hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h2.ID, ScriptContents: "F", UserID: &u.ID})
508508
require.NoError(t, err)
509-
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{HostID: h2.ID, ExecutionID: hsr.ExecutionID, Output: "ok", ExitCode: 0})
509+
_, _, err = ds.SetHostScriptExecutionResult(ctx,
510+
&fleet.HostScriptResultPayload{HostID: h2.ID, ExecutionID: hsr.ExecutionID, Output: "ok", ExitCode: 0})
510511
require.NoError(t, err)
511512
h2F := hsr.ExecutionID
512513
// add a pending software install request for h2

server/datastore/mysql/migrations/tables/20240903155740_UninstallPackages.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ ADD CONSTRAINT fk_uninstall_script_content_id
6767
}
6868

6969
if _, err := tx.Exec(`
70-
ALTER TABLE host_software_installs
70+
ALTER TABLE host_software_installs
7171
ADD COLUMN uninstall_script_output TEXT COLLATE utf8mb4_unicode_ci,
7272
ADD COLUMN uninstall_script_exit_code INT DEFAULT NULL,
7373
ADD COLUMN uninstall TINYINT UNSIGNED NOT NULL DEFAULT 0,
@@ -95,6 +95,9 @@ CASE
9595
WHEN uninstall_script_exit_code IS NOT NULL AND
9696
uninstall_script_exit_code != 0 THEN 'failed_uninstall'
9797
98+
WHEN uninstall_script_exit_code IS NOT NULL AND
99+
uninstall_script_exit_code = 0 THEN NULL -- available for install again
100+
98101
WHEN host_id IS NOT NULL AND uninstall = 1 THEN 'pending_uninstall'
99102
100103
ELSE NULL -- not installed from Fleet installer or successfully uninstalled

0 commit comments

Comments
 (0)