Skip to content

Commit

Permalink
feat: create automatic install policies for fleet-maintained apps (#2…
Browse files Browse the repository at this point in the history
…4298)

> Related issue: #22077

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [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 tests
- [x] Manual QA for all new/changed functionality
  • Loading branch information
jahzielv authored Dec 4, 2024
2 parents 53e1970 + ddf5e1d commit f0e3a57
Show file tree
Hide file tree
Showing 45 changed files with 1,155 additions and 158 deletions.
1 change: 1 addition & 0 deletions changes/feat-ui-creat-policies-fleet-apps-title-details
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Adds functionality for creating an automatic install policy for Fleet-maintained apps
34 changes: 21 additions & 13 deletions ee/server/service/maintained_apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
Expand All @@ -26,19 +27,19 @@ func (svc *Service) AddFleetMaintainedApp(
appID uint,
installScript, preInstallQuery, postInstallScript, uninstallScript string,
selfService bool,
) error {
) (titleID uint, err error) {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
return err
return 0, err
}

vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
return 0, fleet.ErrNoContext
}

app, err := svc.ds.GetMaintainedAppByID(ctx, appID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting maintained app by id")
return 0, ctxerr.Wrap(ctx, err, "getting maintained app by id")
}

// Download installer from the URL
Expand All @@ -50,13 +51,13 @@ func (svc *Service) AddFleetMaintainedApp(
client := fleethttp.NewClient(fleethttp.WithTimeout(timeout))
installerTFR, filename, err := maintainedapps.DownloadInstaller(ctx, app.InstallerURL, client)
if err != nil {
return ctxerr.Wrap(ctx, err, "downloading app installer")
return 0, ctxerr.Wrap(ctx, err, "downloading app installer")
}
defer installerTFR.Close()

extension, err := maintainedapps.ExtensionForBundleIdentifier(app.BundleIdentifier)
if err != nil {
return ctxerr.Errorf(ctx, "getting extension from bundle identifier %q", app.BundleIdentifier)
return 0, ctxerr.Errorf(ctx, "getting extension from bundle identifier %q", app.BundleIdentifier)
}

// Validate the bytes we got are what we expected, if homebrew supports
Expand All @@ -68,11 +69,11 @@ func (svc *Service) AddFleetMaintainedApp(
gotHash := hex.EncodeToString(h.Sum(nil))

if gotHash != app.SHA256 {
return ctxerr.New(ctx, "mismatch in maintained app SHA256 hash")
return 0, ctxerr.New(ctx, "mismatch in maintained app SHA256 hash")
}

if err := installerTFR.Rewind(); err != nil {
return ctxerr.Wrap(ctx, err, "rewind installer reader")
return 0, ctxerr.Wrap(ctx, err, "rewind installer reader")
}
}

Expand Down Expand Up @@ -120,20 +121,20 @@ func (svc *Service) AddFleetMaintainedApp(
// Create record in software installers table
_, err = svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload)
if err != nil {
return ctxerr.Wrap(ctx, err, "setting downloaded installer")
return 0, ctxerr.Wrap(ctx, err, "setting downloaded installer")
}

// Save in S3
if err := svc.storeSoftware(ctx, payload); err != nil {
return ctxerr.Wrap(ctx, err, "upload maintained app installer to S3")
return 0, ctxerr.Wrap(ctx, err, "upload maintained app installer to S3")
}

// Create activity
var teamName *string
if payload.TeamID != nil && *payload.TeamID != 0 {
t, err := svc.ds.Team(ctx, *payload.TeamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting team")
return 0, ctxerr.Wrap(ctx, err, "getting team")
}
teamName = &t.Name
}
Expand All @@ -145,10 +146,17 @@ func (svc *Service) AddFleetMaintainedApp(
TeamID: payload.TeamID,
SelfService: payload.SelfService,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for added software")
return 0, ctxerr.Wrap(ctx, err, "creating activity for added software")
}

return nil
// Use the writer for this query; we need the software installer that might have just been
// created above
titleId, err := svc.ds.GetSoftwareTitleIDByMaintainedAppID(ctxdb.RequirePrimary(ctx, true), app.ID, payload.TeamID)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "getting software title id by app id")
}

return titleId, nil
}

func (svc *Service) ListFleetMaintainedApps(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) {
Expand Down
4 changes: 4 additions & 0 deletions frontend/__mocks__/softwareMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
pending_uninstall: 1,
failed_uninstall: 1,
},
automatic_install_policies: [],
last_install: null,
last_uninstall: null,
package_url: "",
};

export const createMockSoftwarePackage = (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,71 @@
import React from "react";
import { InjectedRouter } from "react-router";
import ReactTooltip from "react-tooltip";

import { uniqueId } from "lodash";

import { ISoftwarePackage } from "interfaces/software";

import Icon from "components/Icon";
import { IconNames } from "components/icons";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";

import LinkCell from "../LinkCell";

const baseClass = "software-name-cell";

type InstallType =
| "manual"
| "selfService"
| "automatic"
| "automaticSelfService";

interface installIconConfig {
iconName: IconNames;
tooltip: JSX.Element;
}

const installIconMap: Record<InstallType, installIconConfig> = {
manual: {
iconName: "install",
tooltip: <>Software can be installed on Host details page.</>,
},
selfService: {
iconName: "user",
tooltip: (
<>
End users can install from <b>Fleet Desktop {">"} Self-service</b>.
</>
),
},
automatic: {
iconName: "refresh",
tooltip: <>Software will be automatically installed on each host.</>,
},
automaticSelfService: {
iconName: "automatic-self-service",
tooltip: (
<>
Software will be automatically installed on each host. End users can
reinstall from <b>Fleet Desktop {">"} Self-service</b>.
</>
),
},
};

interface IInstallIconWithTooltipProps {
isSelfService: boolean;
installType?: "manual" | "automatic";
}

const InstallIconWithTooltip = ({
isSelfService,
}: {
isSelfService: ISoftwarePackage["self_service"];
}) => {
installType,
}: IInstallIconWithTooltipProps) => {
let iconType: InstallType = "manual";
if (installType === "automatic") {
iconType = isSelfService ? "automaticSelfService" : "automatic";
} else if (isSelfService) {
iconType = "selfService";
}

const tooltipId = uniqueId();
return (
<div className={`${baseClass}__install-icon-with-tooltip`}>
Expand All @@ -27,8 +75,9 @@ const InstallIconWithTooltip = ({
data-for={tooltipId}
>
<Icon
name={isSelfService ? "install-self-service" : "install"}
name={installIconMap[iconType].iconName}
className={`${baseClass}__install-icon`}
color="ui-fleet-black-50"
/>
</div>
<ReactTooltip
Expand All @@ -40,17 +89,7 @@ const InstallIconWithTooltip = ({
data-html
>
<span className={`${baseClass}__install-tooltip-text`}>
{isSelfService ? (
<>
End users can install from <b>Fleet Desktop {">"} Self-service</b>
.
</>
) : (
<>
Install manually on <b>Host details</b> page or automatically with
policy automations.
</>
)}
{installIconMap[iconType].tooltip}
</span>
</ReactTooltip>
</div>
Expand All @@ -65,6 +104,7 @@ interface ISoftwareNameCellProps {
router?: InjectedRouter;
hasPackage?: boolean;
isSelfService?: boolean;
installType?: "manual" | "automatic";
iconUrl?: string;
}

Expand All @@ -75,6 +115,7 @@ const SoftwareNameCell = ({
router,
hasPackage = false,
isSelfService = false,
installType,
iconUrl,
}: ISoftwareNameCellProps) => {
// NO path or router means it's not clickable. return
Expand Down Expand Up @@ -104,7 +145,10 @@ const SoftwareNameCell = ({
<SoftwareIcon name={name} source={source} url={iconUrl} />
<span className="software-name">{name}</span>
{hasPackage && (
<InstallIconWithTooltip isSelfService={isSelfService} />
<InstallIconWithTooltip
isSelfService={isSelfService}
installType={installType}
/>
)}
</>
}
Expand Down
41 changes: 41 additions & 0 deletions frontend/components/Tag/Tag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from "react";
import classnames from "classnames";

import Icon from "components/Icon";
import { IconNames } from "components/icons";

const baseClass = "tag";

interface ITagProps {
icon: IconNames;
text: string;
className?: string;
onClick?: () => void;
}

const Tag = ({ icon, text, className, onClick }: ITagProps) => {
const classNames = classnames(
baseClass,
className,
onClick && `${baseClass}__clickable-tag`
);

const content = (
<>
<Icon name={icon} size="small" color="ui-fleet-black-75" />
<span className={`${baseClass}__text`}>{text}</span>
</>
);

return onClick ? (
// use a button element so that the tag can be focused and clicked
// with the keyboard
<button className={classNames} onClick={onClick}>
{content}
</button>
) : (
<div className={classNames}>{content}</div>
);
};

export default Tag;
27 changes: 27 additions & 0 deletions frontend/components/Tag/_styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
.tag {
display: flex;
height: 18px;
padding: 3px 6px;
align-items: center;
gap: $pad-xsmall;
border-radius: $border-radius;
border: 1px solid $ui-fleet-black-10;
color: $ui-fleet-black-75;
font-size: $xx-small;
font-weight: $bold;
white-space: nowrap;

// styles to override the default <button> element styles for the tag
// when it is clickable
&__clickable-tag {
background: none;
cursor: pointer;
outline: inherit;
box-sizing: inherit;

&:focus {
// this is defined in the Button component styles
@include button-focus-outline;
}
}
}
1 change: 1 addition & 0 deletions frontend/components/Tag/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./Tag";
2 changes: 1 addition & 1 deletion frontend/components/buttons/Button/_styles.scss
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
$base-class: "button";

@mixin button-focus-outline($offset: 2px) {
outline-color: #d9d9fe;
outline-color: $core-focused-outline;
outline-offset: $offset;
outline-style: solid;
outline-width: 2px;
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/forms/fields/Radio/Radio.tests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe("Radio - component", () => {

// Also adds a disabled class to the componet
const radioComponent = screen.getByTestId("radio-input");
expect(radioComponent).toHaveClass("disabled");
expect(radioComponent).toHaveClass("radio__disabled");
});

it("render a tooltip from the tooltip prop", async () => {
Expand Down
Loading

0 comments on commit f0e3a57

Please sign in to comment.