mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
feat: create automatic install policies for fleet-maintained apps (#24298)
> 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
This commit is contained in:
commit
f0e3a5758f
45 changed files with 1155 additions and 158 deletions
1
changes/feat-ui-creat-policies-fleet-apps-title-details
Normal file
1
changes/feat-ui-creat-policies-fleet-apps-title-details
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Adds functionality for creating an automatic install policy for Fleet-maintained apps
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,12 +121,12 @@ 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
|
||||
|
|
@ -133,7 +134,7 @@ func (svc *Service) AddFleetMaintainedApp(
|
|||
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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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`}>
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -65,6 +104,7 @@ interface ISoftwareNameCellProps {
|
|||
router?: InjectedRouter;
|
||||
hasPackage?: boolean;
|
||||
isSelfService?: boolean;
|
||||
installType?: "manual" | "automatic";
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +115,7 @@ const SoftwareNameCell = ({
|
|||
router,
|
||||
hasPackage = false,
|
||||
isSelfService = false,
|
||||
installType,
|
||||
iconUrl,
|
||||
}: ISoftwareNameCellProps) => {
|
||||
// NO path or router means it's not clickable. return
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
41
frontend/components/Tag/Tag.tsx
Normal file
41
frontend/components/Tag/Tag.tsx
Normal file
|
|
@ -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
frontend/components/Tag/_styles.scss
Normal file
27
frontend/components/Tag/_styles.scss
Normal file
|
|
@ -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
frontend/components/Tag/index.ts
Normal file
1
frontend/components/Tag/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./Tag";
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export interface IRadioProps {
|
|||
className?: string;
|
||||
disabled?: boolean;
|
||||
tooltip?: React.ReactNode;
|
||||
helpText?: React.ReactNode;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
|
|
@ -28,35 +29,39 @@ const Radio = ({
|
|||
disabled,
|
||||
label,
|
||||
tooltip,
|
||||
helpText,
|
||||
testId,
|
||||
onChange,
|
||||
}: IRadioProps): JSX.Element => {
|
||||
const wrapperClasses = classnames(baseClass, className, {
|
||||
[`disabled`]: disabled,
|
||||
[`${baseClass}__disabled`]: disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<label htmlFor={id} className={wrapperClasses} data-testid={testId}>
|
||||
<span className={`${baseClass}__input`}>
|
||||
<input
|
||||
type="radio"
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
value={value}
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
<span className={`${baseClass}__control`} />
|
||||
</span>
|
||||
<span className={`${baseClass}__label`}>
|
||||
{tooltip ? (
|
||||
<TooltipWrapper tipContent={tooltip}>{label}</TooltipWrapper>
|
||||
) : (
|
||||
<>{label}</>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<div className={wrapperClasses} data-testid={testId}>
|
||||
<label htmlFor={id} className={`${baseClass}__radio-control`}>
|
||||
<span className={`${baseClass}__input`}>
|
||||
<input
|
||||
type="radio"
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
value={value}
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
<span className={`${baseClass}__control-button`} />
|
||||
</span>
|
||||
<span className={`${baseClass}__label`}>
|
||||
{tooltip ? (
|
||||
<TooltipWrapper tipContent={tooltip}>{label}</TooltipWrapper>
|
||||
) : (
|
||||
<>{label}</>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{helpText && <div className={`${baseClass}__help-text`}>{helpText}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@
|
|||
|
||||
.radio {
|
||||
font-size: $x-small;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
// this includes the control button and the radio label text
|
||||
&__radio-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: flex;
|
||||
|
|
@ -15,7 +19,7 @@
|
|||
height: 0;
|
||||
position: absolute;
|
||||
|
||||
& + .radio__control::before {
|
||||
& + .radio__control-button::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
width: 10px;
|
||||
|
|
@ -29,17 +33,17 @@
|
|||
transform: scale(0);
|
||||
}
|
||||
|
||||
&:checked + .radio__control::before {
|
||||
&:checked + .radio__control-button::before {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
&:focus + .radio__control {
|
||||
&:focus + .radio__control-button {
|
||||
border-color: $core-vibrant-blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__control {
|
||||
&__control-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 16px;
|
||||
|
|
@ -53,4 +57,20 @@
|
|||
margin-left: $pad-small;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__help-text {
|
||||
color: $ui-fleet-black-75;
|
||||
margin-top: $pad-xxsmall;
|
||||
margin-left: calc(20px + #{$pad-small});
|
||||
}
|
||||
|
||||
&__disabled {
|
||||
.radio__label {
|
||||
color: $ui-fleet-black-50;
|
||||
}
|
||||
|
||||
.radio__help-text {
|
||||
color: $ui-fleet-black-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
frontend/components/icons/AutomaticSelfService.tsx
Normal file
32
frontend/components/icons/AutomaticSelfService.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from "react";
|
||||
import { COLORS, Colors } from "styles/var/colors";
|
||||
import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
|
||||
|
||||
interface IAutomaticSelfServiceProps {
|
||||
size?: IconSizes;
|
||||
color?: Colors;
|
||||
}
|
||||
|
||||
const AutomaticSelfService = ({
|
||||
size = "medium",
|
||||
color = "ui-fleet-black-75",
|
||||
}: IAutomaticSelfServiceProps) => {
|
||||
return (
|
||||
<svg
|
||||
width={ICON_SIZES[size]}
|
||||
height={ICON_SIZES[size]}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.89811 2.40002C6.44011 2.40052 5.04192 2.97992 4.01092 4.01092C2.97948 5.04237 2.40002 6.4413 2.40002 7.89998C2.40002 8.45227 1.95231 8.89998 1.40002 8.89998C0.84774 8.89998 0.400024 8.45227 0.400024 7.89998C0.400024 5.91087 1.1902 4.00323 2.59671 2.59671C4.00323 1.1902 5.91087 0.400024 7.89998 0.400024H7.90374V0.400032C9.94996 0.407729 11.9151 1.19512 13.4 2.5999L13.4 1.4C13.4 0.847715 13.8478 0.400011 14.4001 0.400024C14.9523 0.400038 15.4 0.847765 15.4 1.40005L15.3999 5.01114C15.3999 5.56341 14.9522 6.01111 14.3999 6.01111L10.7889 6.01111C10.2367 6.01111 9.78894 5.5634 9.78894 5.01111C9.78894 4.45883 10.2367 4.01111 10.7889 4.01111H11.981C10.8713 2.98224 9.4144 2.4062 7.89811 2.40002ZM1.39997 9.78889L5.01106 9.7887C5.56334 9.78867 6.01108 10.2364 6.01111 10.7886C6.01114 11.3409 5.56345 11.7887 5.01117 11.7887L3.81877 11.7888C4.56096 12.4769 5.45849 12.9626 6.42532 13.2106C6.96028 13.3478 7.2827 13.8927 7.14546 14.4277C7.00821 14.9627 6.46329 15.2851 5.92833 15.1478C4.6159 14.8112 3.39922 14.1454 2.40002 13.2002V14.3998C2.40002 14.9521 1.95231 15.3998 1.40002 15.3998C0.84774 15.3998 0.400024 14.9521 0.400024 14.3998V10.7889C0.400024 10.2366 0.847706 9.78892 1.39997 9.78889ZM11.4072 10.3145C11.4072 9.92002 11.727 9.60022 12.1215 9.60022C12.516 9.60022 12.8358 9.92002 12.8358 10.3145C12.8358 10.709 12.516 11.0288 12.1215 11.0288C11.727 11.0288 11.4072 10.709 11.4072 10.3145ZM14.8358 10.3145C14.8358 10.8753 14.6657 11.3964 14.3743 11.829C14.4689 11.9058 14.5599 11.9879 14.6469 12.0748C15.3166 12.7446 15.6929 13.653 15.6929 14.6002C15.6929 15.1525 15.2452 15.6002 14.6929 15.6002C14.1406 15.6002 13.6929 15.1525 13.6929 14.6002C13.6929 14.1835 13.5273 13.7838 13.2326 13.4891C12.9379 13.1944 12.5382 13.0288 12.1215 13.0288C11.7047 13.0288 11.305 13.1944 11.0103 13.4891C10.7156 13.7838 10.55 14.1835 10.55 14.6002C10.55 15.1525 10.1023 15.6002 9.55005 15.6002C8.99776 15.6002 8.55005 15.1525 8.55005 14.6002C8.55005 13.653 8.92632 12.7446 9.5961 12.0748C9.68307 11.9879 9.77406 11.9058 9.86864 11.829C9.57726 11.3964 9.40719 10.8753 9.40719 10.3145C9.40719 8.81545 10.6224 7.60022 12.1215 7.60022C13.6205 7.60022 14.8358 8.81545 14.8358 10.3145Z"
|
||||
fill={COLORS[color]}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutomaticSelfService;
|
||||
30
frontend/components/icons/User.tsx
Normal file
30
frontend/components/icons/User.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import React from "react";
|
||||
|
||||
import { COLORS, Colors } from "styles/var/colors";
|
||||
import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
|
||||
|
||||
interface IUserProps {
|
||||
size?: IconSizes;
|
||||
color?: Colors;
|
||||
}
|
||||
|
||||
const User = ({ size = "medium", color = "ui-fleet-black-75" }: IUserProps) => {
|
||||
return (
|
||||
<svg
|
||||
width={ICON_SIZES[size]}
|
||||
height={ICON_SIZES[size]}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.75358 8.66669C2.75358 5.401 5.40094 2.75364 8.66663 2.75364C11.9323 2.75364 14.5797 5.401 14.5797 8.66669C14.5797 9.967 14.1599 11.1693 13.4486 12.1454C13.1902 11.5533 12.8216 11.0087 12.356 10.543C12.0931 10.2802 11.8051 10.0483 11.4976 9.84964C12.1159 9.17002 12.4928 8.26688 12.4928 7.27572C12.4928 5.16263 10.7798 3.44964 8.66671 3.44964C6.55362 3.44964 4.84062 5.16263 4.84062 7.27572C4.84062 8.26688 5.21751 9.17002 5.83579 9.84964C5.5283 10.0483 5.2403 10.2802 4.97746 10.543C4.51174 11.0087 4.14317 11.5534 3.88476 12.1456C3.17335 11.1694 2.75358 9.96706 2.75358 8.66669ZM5.57994 13.7111C6.47843 14.2621 7.53543 14.5797 8.66663 14.5797C9.79788 14.5797 10.8549 14.2621 11.7535 13.711C11.6459 13.0742 11.343 12.4814 10.8803 12.0187C10.2932 11.4316 9.49695 11.1018 8.66671 11.1018C7.83647 11.1018 7.04023 11.4316 6.45316 12.0187C5.99039 12.4815 5.68747 13.0742 5.57994 13.7111ZM8.66663 0.666687C4.24835 0.666687 0.666626 4.24841 0.666626 8.66669C0.666626 13.085 4.24835 16.6667 8.66663 16.6667C13.0849 16.6667 16.6666 13.085 16.6666 8.66669C16.6666 4.24841 13.0849 0.666687 8.66663 0.666687ZM6.92758 7.27572C6.92758 6.31523 7.70621 5.53659 8.66671 5.53659C9.62721 5.53659 10.4058 6.31523 10.4058 7.27572C10.4058 8.23622 9.62721 9.01485 8.66671 9.01485C7.70621 9.01485 6.92758 8.23622 6.92758 7.27572Z"
|
||||
fill={COLORS[color]}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default User;
|
||||
|
|
@ -64,6 +64,8 @@ import Refresh from "./Refresh";
|
|||
import Install from "./Install";
|
||||
import InstallSelfService from "./InstallSelfService";
|
||||
import Settings from "./Settings";
|
||||
import AutomaticSelfService from "./AutomaticSelfService";
|
||||
import User from "./User";
|
||||
|
||||
// a mapping of the usable names of icons to the icon source.
|
||||
export const ICON_MAP = {
|
||||
|
|
@ -134,6 +136,8 @@ export const ICON_MAP = {
|
|||
install: Install,
|
||||
"install-self-service": InstallSelfService,
|
||||
settings: Settings,
|
||||
"automatic-self-service": AutomaticSelfService,
|
||||
user: User,
|
||||
};
|
||||
|
||||
export type IconNames = keyof typeof ICON_MAP;
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ export interface IPolicyFormData {
|
|||
team_id?: number | null;
|
||||
id?: number;
|
||||
calendar_events_enabled?: boolean;
|
||||
// undefined from GET/LIST when not set, null for PATCH to unset
|
||||
software_title_id?: number | null;
|
||||
// null for PATCH to unset - note asymmetry with GET/LIST - see IPolicy.run_script
|
||||
script_id?: number | null;
|
||||
|
|
|
|||
|
|
@ -55,8 +55,16 @@ export interface ISoftwareTitleVersion {
|
|||
hosts_count?: number;
|
||||
}
|
||||
|
||||
export interface ISoftwarePackagePolicy {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ISoftwarePackage {
|
||||
name: string;
|
||||
last_install: string | null;
|
||||
last_uninstall: string | null;
|
||||
package_url: string;
|
||||
version: string;
|
||||
uploaded_at: string;
|
||||
install_script: string;
|
||||
|
|
@ -71,6 +79,7 @@ export interface ISoftwarePackage {
|
|||
pending_uninstall: number;
|
||||
failed_uninstall: number;
|
||||
};
|
||||
automatic_install_policies?: ISoftwarePackagePolicy[];
|
||||
install_during_setup?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Checkbox from "components/forms/fields/Checkbox";
|
|||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import RevealButton from "components/buttons/RevealButton";
|
||||
import Button from "components/buttons/Button";
|
||||
import Radio from "components/forms/fields/Radio";
|
||||
|
||||
import AdvancedOptionsFields from "pages/SoftwarePage/components/AdvancedOptionsFields";
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ export interface IFleetMaintainedAppFormData {
|
|||
preInstallQuery?: string;
|
||||
postInstallScript?: string;
|
||||
uninstallScript?: string;
|
||||
installType: string;
|
||||
}
|
||||
|
||||
export interface IFormValidation {
|
||||
|
|
@ -51,6 +53,7 @@ const FleetAppDetailsForm = ({
|
|||
installScript: defaultInstallScript,
|
||||
postInstallScript: defaultPostInstallScript,
|
||||
uninstallScript: defaultUninstallScript,
|
||||
installType: "manual",
|
||||
});
|
||||
const [formValidation, setFormValidation] = useState<IFormValidation>({
|
||||
isValid: true,
|
||||
|
|
@ -87,6 +90,11 @@ const FleetAppDetailsForm = ({
|
|||
setFormValidation(generateFormValidation(newData));
|
||||
};
|
||||
|
||||
const onChangeInstallType = (value: string) => {
|
||||
const newData = { ...formData, installType: value };
|
||||
setFormData(newData);
|
||||
};
|
||||
|
||||
const onSubmitForm = (evt: React.FormEvent<HTMLFormElement>) => {
|
||||
evt.preventDefault();
|
||||
onSubmit(formData);
|
||||
|
|
@ -96,6 +104,38 @@ const FleetAppDetailsForm = ({
|
|||
|
||||
return (
|
||||
<form className={baseClass} onSubmit={onSubmitForm}>
|
||||
<fieldset>
|
||||
<legend>Install</legend>
|
||||
<div className={`${baseClass}__radio-inputs`}>
|
||||
<Radio
|
||||
checked={formData.installType === "manual"}
|
||||
id="manual"
|
||||
value="manual"
|
||||
name="install-type"
|
||||
label="Manual"
|
||||
onChange={onChangeInstallType}
|
||||
helpText="Manually install on Host details page for each host."
|
||||
/>
|
||||
<Radio
|
||||
checked={formData.installType === "automatic"}
|
||||
id="automatic"
|
||||
value="automatic"
|
||||
name="install-type"
|
||||
label="Automatic"
|
||||
onChange={onChangeInstallType}
|
||||
helpText={
|
||||
<>
|
||||
Automatically install on each host that's{" "}
|
||||
<TooltipWrapper tipContent="If the host already has any version of this software, it won't be installed.">
|
||||
missing this software.
|
||||
</TooltipWrapper>{" "}
|
||||
Policy that triggers install can be customized after software is
|
||||
added.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<Checkbox
|
||||
value={formData.selfService}
|
||||
onChange={onToggleSelfServiceCheckbox}
|
||||
|
|
|
|||
|
|
@ -16,4 +16,22 @@
|
|||
flex-direction: row;
|
||||
gap: $pad-large;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
legend {
|
||||
margin-bottom: $pad-small;
|
||||
font-size: $x-small;
|
||||
font-weight: $bold;
|
||||
}
|
||||
}
|
||||
|
||||
&__radio-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-small;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import PATHS from "router/paths";
|
|||
import { buildQueryStringFromParams } from "utilities/url";
|
||||
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
||||
import softwareAPI from "services/entities/software";
|
||||
import teamPoliciesAPI from "services/entities/team_policies";
|
||||
import { QueryContext } from "context/query";
|
||||
import { AppContext } from "context/app";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
|
@ -29,6 +30,12 @@ import FleetAppDetailsForm from "./FleetAppDetailsForm";
|
|||
import { IFleetMaintainedAppFormData } from "./FleetAppDetailsForm/FleetAppDetailsForm";
|
||||
import AddFleetAppSoftwareModal from "./AddFleetAppSoftwareModal";
|
||||
|
||||
import {
|
||||
getFleetAppPolicyDescription,
|
||||
getFleetAppPolicyName,
|
||||
getFleetAppPolicyQuery,
|
||||
} from "./helpers";
|
||||
|
||||
const baseClass = "fleet-maintained-app-details-page";
|
||||
|
||||
interface ISoftwareSummaryProps {
|
||||
|
|
@ -103,7 +110,7 @@ const FleetMaintainedAppDetailsPage = ({
|
|||
setShowAddFleetAppSoftwareModal,
|
||||
] = useState(false);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
const { data: fleetApp, isLoading, isError } = useQuery(
|
||||
["fleet-maintained-app", appId],
|
||||
() => softwareAPI.getFleetMainainedApp(appId),
|
||||
{
|
||||
|
|
@ -131,25 +138,75 @@ const FleetMaintainedAppDetailsPage = ({
|
|||
|
||||
setShowAddFleetAppSoftwareModal(true);
|
||||
|
||||
const { installType } = formData;
|
||||
let titleId: number | undefined;
|
||||
try {
|
||||
await softwareAPI.addFleetMaintainedApp(parseInt(teamId, 10), {
|
||||
...formData,
|
||||
appId,
|
||||
});
|
||||
const res = await softwareAPI.addFleetMaintainedApp(
|
||||
parseInt(teamId, 10),
|
||||
{
|
||||
...formData,
|
||||
appId,
|
||||
}
|
||||
);
|
||||
titleId = res.software_title_id;
|
||||
|
||||
// for manual install we redirect only on a successful software add.
|
||||
if (installType === "manual") {
|
||||
router.push(
|
||||
`${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({
|
||||
team_id: teamId,
|
||||
available_for_install: true,
|
||||
})}`
|
||||
);
|
||||
renderFlash(
|
||||
"success",
|
||||
<>
|
||||
<b>{fleetApp?.name}</b> successfully added.
|
||||
</>
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// quick exit if there was an error adding the software. Skip the policy
|
||||
// creation.
|
||||
renderFlash("error", getErrorReason(error));
|
||||
setShowAddFleetAppSoftwareModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the install type is automatic we now need to create the new policy.
|
||||
if (installType === "automatic" && fleetApp) {
|
||||
try {
|
||||
await teamPoliciesAPI.create({
|
||||
name: getFleetAppPolicyName(fleetApp.name),
|
||||
description: getFleetAppPolicyDescription(fleetApp.name),
|
||||
query: getFleetAppPolicyQuery(fleetApp.name),
|
||||
team_id: parseInt(teamId, 10),
|
||||
software_title_id: titleId,
|
||||
platform: "darwin",
|
||||
});
|
||||
|
||||
renderFlash(
|
||||
"success",
|
||||
<>
|
||||
<b>{fleetApp?.name}</b> successfully added.
|
||||
</>
|
||||
);
|
||||
} catch (e) {
|
||||
renderFlash(
|
||||
"error",
|
||||
"Couldn't add automatic install policy. Software is successfuly added. To try again delete software and add it again.",
|
||||
{ persistOnPageChange: true }
|
||||
);
|
||||
}
|
||||
|
||||
// for automatic install we redirect on both a successful and error policy
|
||||
// add because the software was already successfuly added.
|
||||
router.push(
|
||||
`${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({
|
||||
team_id: teamId,
|
||||
available_for_install: true,
|
||||
})}`
|
||||
);
|
||||
renderFlash(
|
||||
"success",
|
||||
<>
|
||||
<b>{data?.name}</b> successfully added.
|
||||
</>
|
||||
);
|
||||
} catch (error) {
|
||||
renderFlash("error", getErrorReason(error)); // TODO: handle error messages
|
||||
}
|
||||
|
||||
setShowAddFleetAppSoftwareModal(false);
|
||||
|
|
@ -168,7 +225,7 @@ const FleetMaintainedAppDetailsPage = ({
|
|||
return <DataError />;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
if (fleetApp) {
|
||||
return (
|
||||
<>
|
||||
<BackLink
|
||||
|
|
@ -176,18 +233,18 @@ const FleetMaintainedAppDetailsPage = ({
|
|||
path={backToAddSoftwareUrl}
|
||||
className={`${baseClass}__back-to-add-software`}
|
||||
/>
|
||||
<h1>{data.name}</h1>
|
||||
<h1>{fleetApp.name}</h1>
|
||||
<div className={`${baseClass}__page-content`}>
|
||||
<FleetAppSummary
|
||||
name={data.name}
|
||||
platform={data.platform}
|
||||
version={data.version}
|
||||
name={fleetApp.name}
|
||||
platform={fleetApp.platform}
|
||||
version={fleetApp.version}
|
||||
/>
|
||||
<FleetAppDetailsForm
|
||||
showSchemaButton={!isSidePanelOpen}
|
||||
defaultInstallScript={data.install_script}
|
||||
defaultPostInstallScript={data.post_install_script}
|
||||
defaultUninstallScript={data.uninstall_script}
|
||||
defaultInstallScript={fleetApp.install_script}
|
||||
defaultPostInstallScript={fleetApp.post_install_script}
|
||||
defaultUninstallScript={fleetApp.uninstall_script}
|
||||
onClickShowSchema={() => setSidePanelOpen(true)}
|
||||
onCancel={onCancel}
|
||||
onSubmit={onSubmit}
|
||||
|
|
@ -205,7 +262,7 @@ const FleetMaintainedAppDetailsPage = ({
|
|||
<MainContent className={baseClass}>
|
||||
<>{renderContent()}</>
|
||||
</MainContent>
|
||||
{isPremiumTier && data && isSidePanelOpen && (
|
||||
{isPremiumTier && fleetApp && isSidePanelOpen && (
|
||||
<SidePanelContent className={`${baseClass}__side-panel`}>
|
||||
<QuerySidePanel
|
||||
key="query-side-panel"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import fleetAppData from "../../../../../../server/mdm/maintainedapps/apps.json";
|
||||
|
||||
const NameToIdentifierMap: Record<string, string> = {
|
||||
"1Password": "1password",
|
||||
"Adobe Acrobat Reader": "adobe-acrobat-reader",
|
||||
"Box Drive": "box-drive",
|
||||
Brave: "brave-browser",
|
||||
"Cloudflare WARP": "cloudflare-warp",
|
||||
"Docker Desktop": "docker",
|
||||
Figma: "figma",
|
||||
"Mozilla Firefox": "firefox",
|
||||
"Google Chrome": "google-chrome",
|
||||
"Microsoft Edge": "microsoft-edge",
|
||||
"Microsoft Excel": "microsoft-excel",
|
||||
"Microsoft Teams": "microsoft-teams",
|
||||
"Microsoft Word": "microsoft-word",
|
||||
Notion: "notion",
|
||||
Postman: "postman",
|
||||
Slack: "slack",
|
||||
TeamViewer: "teamviewer",
|
||||
"Microsoft Visual Studio Code": "visual-studio-code",
|
||||
WhatsApp: "whatsapp",
|
||||
Zoom: "zoom",
|
||||
};
|
||||
|
||||
const getFleetAppData = (name: string) => {
|
||||
const appId = NameToIdentifierMap[name]; // TODO: need a better matching mechanism here
|
||||
return fleetAppData.find((app) => app.identifier === appId);
|
||||
};
|
||||
|
||||
export const getFleetAppPolicyName = (appName: string) => {
|
||||
return `[Install software] ${appName}`;
|
||||
};
|
||||
|
||||
export const getFleetAppPolicyDescription = (appName: string) => {
|
||||
return `Policy triggers automatic install of ${appName} on each host that's missing this software.`;
|
||||
};
|
||||
|
||||
export const getFleetAppPolicyQuery = (name: string) => {
|
||||
return getFleetAppData(name)?.automatic_policy_query;
|
||||
};
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import React from "react";
|
||||
|
||||
import { ISoftwarePackagePolicy } from "interfaces/software";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import { Link } from "react-router";
|
||||
|
||||
const baseClass = "automatic-install-modal";
|
||||
|
||||
interface IPoliciesListItemProps {
|
||||
teamId: number;
|
||||
policy: ISoftwarePackagePolicy;
|
||||
}
|
||||
|
||||
const PoliciesListItem = ({ teamId, policy }: IPoliciesListItemProps) => {
|
||||
return (
|
||||
<li key={policy.id} className={`${baseClass}__list-item`}>
|
||||
<Link to={`/policies/${policy.id}?team_id=${teamId}`}>{policy.name}</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
interface IPoliciesListProps {
|
||||
teamId: number;
|
||||
policies: ISoftwarePackagePolicy[];
|
||||
}
|
||||
|
||||
const PoliciesList = ({ teamId, policies }: IPoliciesListProps) => {
|
||||
return (
|
||||
<ul className={`${baseClass}__list`}>
|
||||
{policies.map((policy) => (
|
||||
<PoliciesListItem key={policy.id} teamId={teamId} policy={policy} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
interface IAutomaticInstallModalProps {
|
||||
teamId: number;
|
||||
policies: ISoftwarePackagePolicy[];
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const AutomaticInstallModal = ({
|
||||
teamId,
|
||||
policies,
|
||||
onExit,
|
||||
}: IAutomaticInstallModalProps) => {
|
||||
const description =
|
||||
policies.length > 1 ? (
|
||||
<>
|
||||
Software will be installed when hosts fail any of these policies.{" "}
|
||||
<CustomLink
|
||||
newTab
|
||||
text="Learn more"
|
||||
url="https://fleetdm.com/learn-more-about/policy-automation-install-software"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Software will be installed when hosts fail this policy.{" "}
|
||||
<CustomLink
|
||||
newTab
|
||||
text="Learn more"
|
||||
url="https://fleetdm.com/learn-more-about/policy-automation-install-software"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={baseClass}
|
||||
title="Automatic install"
|
||||
onExit={onExit}
|
||||
width="large"
|
||||
>
|
||||
<>
|
||||
<p className={`${baseClass}__description`}>{description}</p>
|
||||
<PoliciesList teamId={teamId} policies={policies} />
|
||||
<div className="modal-cta-wrap">
|
||||
<Button variant="brand" onClick={onExit}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutomaticInstallModal;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
.automatic-install-modal {
|
||||
|
||||
&__description {
|
||||
margin: 0 0 $pad-large
|
||||
}
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
border-radius: $border-radius-medium;
|
||||
}
|
||||
|
||||
&__list-item {
|
||||
border-bottom: 1px solid $ui-fleet-black-10;
|
||||
padding: $pad-small $pad-large;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./AutomaticInstallModal";
|
||||
|
|
@ -67,7 +67,7 @@ const DeleteSoftwareModal = ({
|
|||
</p>
|
||||
<p>
|
||||
Installs or uninstalls currently running on a host will still
|
||||
complete, but results won’t appear in Fleet.
|
||||
complete, but results won't appear in Fleet.
|
||||
</p>
|
||||
<p>You cannot undo this action.</p>
|
||||
<div className="modal-cta-wrap">
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import ActionsDropdown from "components/ActionsDropdown";
|
|||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import DataSet from "components/DataSet";
|
||||
import Icon from "components/Icon";
|
||||
import Tag from "components/Tag";
|
||||
|
||||
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
|
||||
import endpoints from "utilities/endpoints";
|
||||
|
|
@ -34,6 +35,7 @@ import {
|
|||
SOFTWARE_PACKAGE_DROPDOWN_OPTIONS,
|
||||
downloadFile,
|
||||
} from "./helpers";
|
||||
import AutomaticInstallModal from "../AutomaticInstallModal";
|
||||
|
||||
const baseClass = "software-package-card";
|
||||
|
||||
|
|
@ -267,6 +269,9 @@ const SoftwarePackageCard = ({
|
|||
|
||||
const [showEditSoftwareModal, setShowEditSoftwareModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showAutomaticInstallModal, setShowAutomaticInstallModal] = useState(
|
||||
false
|
||||
);
|
||||
|
||||
const onEditSoftwareClick = () => {
|
||||
setShowEditSoftwareModal(true);
|
||||
|
|
@ -342,16 +347,22 @@ const SoftwarePackageCard = ({
|
|||
</div>
|
||||
</div>
|
||||
<div className={`${baseClass}__actions-wrapper`}>
|
||||
{isSelfService && (
|
||||
<div className={`${baseClass}__self-service-badge`}>
|
||||
<Icon
|
||||
name="install-self-service"
|
||||
size="small"
|
||||
color="ui-fleet-black-75"
|
||||
/>
|
||||
Self-service
|
||||
</div>
|
||||
)}
|
||||
{softwarePackage?.automatic_install_policies &&
|
||||
softwarePackage?.automatic_install_policies.length > 0 && (
|
||||
<TooltipWrapper
|
||||
showArrow
|
||||
position="top"
|
||||
tipContent="Click to see policy that triggers automatic install."
|
||||
underline={false}
|
||||
>
|
||||
<Tag
|
||||
icon="refresh"
|
||||
text="Automatic install"
|
||||
onClick={() => setShowAutomaticInstallModal(true)}
|
||||
/>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{isSelfService && <Tag icon="user" text="Self-service" />}
|
||||
{showActions && (
|
||||
<SoftwareActionsDropdown
|
||||
isSoftwarePackage={!!softwarePackage}
|
||||
|
|
@ -401,6 +412,15 @@ const SoftwarePackageCard = ({
|
|||
onSuccess={onDeleteSuccess}
|
||||
/>
|
||||
)}
|
||||
{showAutomaticInstallModal &&
|
||||
softwarePackage?.automatic_install_policies &&
|
||||
softwarePackage?.automatic_install_policies.length > 0 && (
|
||||
<AutomaticInstallModal
|
||||
teamId={teamId}
|
||||
policies={softwarePackage.automatic_install_policies}
|
||||
onExit={() => setShowAutomaticInstallModal(false)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -87,21 +87,6 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
&__self-service-badge {
|
||||
display: flex;
|
||||
height: 18px;
|
||||
padding: 3px 6px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
background: $ui-off-white;
|
||||
color: $ui-fleet-black-75;
|
||||
font-size: $xx-small;
|
||||
font-weight: $bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
@include button-dropdown;
|
||||
color: $core-fleet-black;
|
||||
|
|
|
|||
|
|
@ -7,11 +7,8 @@ import { RouteComponentProps } from "react-router";
|
|||
import { AxiosError } from "axios";
|
||||
|
||||
import paths from "router/paths";
|
||||
|
||||
import useTeamIdParam from "hooks/useTeamIdParam";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
|
||||
import {
|
||||
ISoftwareTitleDetails,
|
||||
formatSoftwareType,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ describe("SoftwareTitleDetailsPage helpers", () => {
|
|||
},
|
||||
install_script: "echo foo",
|
||||
icon_url: "https://example.com/icon.png",
|
||||
automatic_install_policies: [],
|
||||
last_install: null,
|
||||
last_uninstall: null,
|
||||
package_url: "",
|
||||
},
|
||||
app_store_app: null,
|
||||
source: "apps",
|
||||
|
|
|
|||
|
|
@ -70,10 +70,19 @@ const getSoftwareNameCellData = (
|
|||
const { software_package, app_store_app } = softwareTitle;
|
||||
let hasPackage = false;
|
||||
let isSelfService = false;
|
||||
let installType: "manual" | "automatic" | undefined;
|
||||
let iconUrl: string | null = null;
|
||||
if (software_package) {
|
||||
hasPackage = true;
|
||||
isSelfService = software_package.self_service;
|
||||
if (
|
||||
software_package.automatic_install_policies &&
|
||||
software_package.automatic_install_policies.length > 0
|
||||
) {
|
||||
installType = "automatic";
|
||||
} else {
|
||||
installType = "manual";
|
||||
}
|
||||
} else if (app_store_app) {
|
||||
hasPackage = true;
|
||||
isSelfService = app_store_app.self_service;
|
||||
|
|
@ -88,6 +97,7 @@ const getSoftwareNameCellData = (
|
|||
path: softwareTitleDetailsPath,
|
||||
hasPackage: hasPackage && !isAllTeams,
|
||||
isSelfService,
|
||||
installType,
|
||||
iconUrl,
|
||||
};
|
||||
};
|
||||
|
|
@ -117,6 +127,7 @@ const generateTableHeaders = (
|
|||
router={router}
|
||||
hasPackage={nameCellData.hasPackage}
|
||||
isSelfService={nameCellData.isSelfService}
|
||||
installType={nameCellData.installType}
|
||||
iconUrl={nameCellData.iconUrl ?? undefined}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export default {
|
|||
resolution,
|
||||
platform,
|
||||
critical,
|
||||
software_title_id,
|
||||
// note absence of automations-related fields, which are only set by the UI via update
|
||||
} = data;
|
||||
const { TEAMS } = endpoints;
|
||||
|
|
@ -76,6 +77,7 @@ export default {
|
|||
resolution,
|
||||
platform,
|
||||
critical,
|
||||
software_title_id,
|
||||
});
|
||||
},
|
||||
update: (id: number, data: IPolicyFormData) => {
|
||||
|
|
|
|||
|
|
@ -166,3 +166,30 @@ WHERE NOT EXISTS (
|
|||
|
||||
return avail, meta, nil
|
||||
}
|
||||
|
||||
// GetSoftwareTitleIDByAppID returns the software title ID related to a given fleet library app ID.
|
||||
func (ds *Datastore) GetSoftwareTitleIDByMaintainedAppID(ctx context.Context, appID uint, teamID *uint) (uint, error) {
|
||||
stmt := `
|
||||
SELECT
|
||||
st.id
|
||||
FROM software_titles st
|
||||
JOIN software_installers si ON si.title_id = st.id
|
||||
JOIN fleet_library_apps fla ON fla.id = si.fleet_library_app_id
|
||||
WHERE fla.id = ? AND si.global_or_team_id = ?`
|
||||
|
||||
var globalOrTeamID uint
|
||||
if teamID != nil {
|
||||
globalOrTeamID = *teamID
|
||||
}
|
||||
|
||||
var titleID uint
|
||||
if err := sqlx.GetContext(ctx, ds.reader(ctx), &titleID, stmt, appID, globalOrTeamID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, ctxerr.Wrap(ctx, notFound("SoftwareInstaller"), "no matching software installer found")
|
||||
}
|
||||
|
||||
return 0, ctxerr.Wrap(ctx, err, "getting software title id by app id")
|
||||
}
|
||||
|
||||
return titleID, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package mysql
|
|||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
|
|
@ -24,6 +25,7 @@ func TestMaintainedApps(t *testing.T) {
|
|||
{"IngestWithBrew", testIngestWithBrew},
|
||||
{"ListAvailableApps", testListAvailableApps},
|
||||
{"GetMaintainedAppByID", testGetMaintainedAppByID},
|
||||
{"GetSoftwareTitleIdByAppID", testGetSoftwareTitleIdByAppID},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
|
@ -377,3 +379,85 @@ func testGetMaintainedAppByID(t *testing.T, ds *Datastore) {
|
|||
|
||||
require.Equal(t, expApp, gotApp)
|
||||
}
|
||||
|
||||
func testGetSoftwareTitleIdByAppID(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Maintained app doesn't exist, should get not found error
|
||||
_, err := ds.GetSoftwareTitleIDByMaintainedAppID(ctx, 99, nil)
|
||||
require.Error(t, err)
|
||||
require.True(t, fleet.IsNotFound(err))
|
||||
|
||||
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
||||
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
app, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
||||
Name: "foo",
|
||||
Token: "token",
|
||||
Version: "1.0.0",
|
||||
Platform: "darwin",
|
||||
InstallerURL: "https://example.com/foo.zip",
|
||||
SHA256: "sha",
|
||||
BundleIdentifier: "bundle",
|
||||
InstallScript: "install",
|
||||
UninstallScript: "uninstall",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Valid maintained app ID, but no installer yet so we should get not found error
|
||||
_, err = ds.GetSoftwareTitleIDByMaintainedAppID(ctx, app.ID, nil)
|
||||
require.Error(t, err)
|
||||
require.True(t, fleet.IsNotFound(err))
|
||||
|
||||
// create a software installer for team and for no team
|
||||
installer, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
installerTm1ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "hello",
|
||||
PreInstallQuery: "SELECT 1",
|
||||
PostInstallScript: "world",
|
||||
InstallerFile: installer,
|
||||
StorageID: "storage1",
|
||||
Filename: "file1",
|
||||
Title: "file1",
|
||||
Version: "1.0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: &team1.ID,
|
||||
FleetLibraryAppID: &app.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "hello",
|
||||
PreInstallQuery: "SELECT 1",
|
||||
PostInstallScript: "world",
|
||||
InstallerFile: installer,
|
||||
StorageID: "storage1",
|
||||
Filename: "file1",
|
||||
Title: "file1",
|
||||
Version: "1.0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: nil,
|
||||
FleetLibraryAppID: &app.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// get the software installer metadata as we will need the associated software title id.
|
||||
installer1, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerTm1ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, installer1.TitleID)
|
||||
|
||||
stID, err := ds.GetSoftwareTitleIDByMaintainedAppID(ctx, app.ID, &team1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, *installer1.TitleID, stID)
|
||||
|
||||
stNoTmID, err := ds.GetSoftwareTitleIDByMaintainedAppID(ctx, app.ID, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, *installer1.TitleID, stNoTmID)
|
||||
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -465,7 +465,7 @@ func getInheritedPoliciesForTeam(ctx context.Context, q sqlx.QueryerContext, tea
|
|||
var args []interface{}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
SELECT
|
||||
` + policyCols + `,
|
||||
COALESCE(u.name, '<deleted>') AS author_name,
|
||||
COALESCE(u.email, '') AS author_email,
|
||||
|
|
@ -705,7 +705,7 @@ func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, op
|
|||
var args []interface{}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
SELECT
|
||||
` + policyCols + `,
|
||||
COALESCE(u.name, '<deleted>') AS author_name,
|
||||
COALESCE(u.email, '') AS author_email,
|
||||
|
|
@ -1473,15 +1473,15 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error {
|
|||
p.id as policy_id,
|
||||
t.id AS inherited_team_id,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM policy_membership pm
|
||||
INNER JOIN hosts h ON pm.host_id = h.id
|
||||
SELECT COUNT(*)
|
||||
FROM policy_membership pm
|
||||
INNER JOIN hosts h ON pm.host_id = h.id
|
||||
WHERE pm.policy_id = p.id AND pm.passes = true AND h.team_id = t.id
|
||||
) AS passing_host_count,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM policy_membership pm
|
||||
INNER JOIN hosts h ON pm.host_id = h.id
|
||||
SELECT COUNT(*)
|
||||
FROM policy_membership pm
|
||||
INNER JOIN hosts h ON pm.host_id = h.id
|
||||
WHERE pm.policy_id = p.id AND pm.passes = false AND h.team_id = t.id
|
||||
) AS failing_host_count
|
||||
FROM policies p
|
||||
|
|
@ -1555,12 +1555,12 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error {
|
|||
SELECT
|
||||
p.id,
|
||||
NULL AS inherited_team_id, -- using NULL to represent global scope
|
||||
COALESCE(SUM(IF(pm.passes IS NULL, 0, pm.passes = 1)), 0),
|
||||
COALESCE(SUM(IF(pm.passes IS NULL, 0, pm.passes = 1)), 0),
|
||||
COALESCE(SUM(IF(pm.passes IS NULL, 0, pm.passes = 0)), 0)
|
||||
FROM policies p
|
||||
LEFT JOIN policy_membership pm ON p.id = pm.policy_id
|
||||
GROUP BY p.id
|
||||
ON DUPLICATE KEY UPDATE
|
||||
ON DUPLICATE KEY UPDATE
|
||||
updated_at = NOW(),
|
||||
passing_host_count = VALUES(passing_host_count),
|
||||
failing_host_count = VALUES(failing_host_count);
|
||||
|
|
@ -1622,7 +1622,7 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships(
|
|||
hostID *uint,
|
||||
) ([]fleet.HostPolicyMembershipData, error) {
|
||||
query := `
|
||||
SELECT
|
||||
SELECT
|
||||
COALESCE(sh.email, '') AS email,
|
||||
COALESCE(pm.passing, 1) AS passing,
|
||||
COALESCE(pm.failing_policy_ids, '') AS failing_policy_ids,
|
||||
|
|
@ -1640,7 +1640,7 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships(
|
|||
SELECT host_id, MIN(email) AS email
|
||||
FROM host_emails
|
||||
JOIN hosts ON host_emails.host_id=hosts.id
|
||||
WHERE email LIKE CONCAT('%@', ?) AND team_id = ?
|
||||
WHERE email LIKE CONCAT('%@', ?) AND team_id = ?
|
||||
GROUP BY host_id
|
||||
) sh ON h.id = sh.host_id
|
||||
LEFT JOIN host_display_names hdn ON h.id = hdn.host_id
|
||||
|
|
@ -1663,3 +1663,41 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships(
|
|||
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
// GetPoliciesBySoftwareTitleID returns the policies that are associated with a set of software titles.
|
||||
func (ds *Datastore) getPoliciesBySoftwareTitleIDs(
|
||||
ctx context.Context,
|
||||
softwareTitleIDs []uint,
|
||||
teamID *uint,
|
||||
) ([]fleet.AutomaticInstallPolicy, error) {
|
||||
if len(softwareTitleIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
p.id AS id,
|
||||
p.name AS name,
|
||||
st.id AS software_title_id
|
||||
FROM policies p
|
||||
JOIN software_installers si ON p.software_installer_id = si.id
|
||||
JOIN software_titles st ON si.title_id = st.id
|
||||
WHERE st.id IN (?) AND p.team_id = ?
|
||||
`
|
||||
|
||||
var tmID uint
|
||||
if teamID != nil {
|
||||
tmID = *teamID
|
||||
}
|
||||
|
||||
query, args, err := sqlx.In(query, softwareTitleIDs, tmID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "build select get policies by software id query")
|
||||
}
|
||||
|
||||
var policies []fleet.AutomaticInstallPolicy
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &policies, query, args...); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get policies by software installer id")
|
||||
}
|
||||
return policies, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ func TestPolicies(t *testing.T) {
|
|||
{"TestPoliciesNewGlobalPolicyWithScript", testNewGlobalPolicyWithScript},
|
||||
{"TestPoliciesTeamPoliciesWithScript", testTeamPoliciesWithScript},
|
||||
{"TeamPoliciesNoTeam", testTeamPoliciesNoTeam},
|
||||
{"TestPoliciesBySoftwareTitleID", testPoliciesBySoftwareTitleID},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
|
@ -4999,3 +5000,165 @@ func testTeamPoliciesNoTeam(t *testing.T, ds *Datastore) {
|
|||
require.Equal(t, "SELECT 0;", host5PolicyQueries[strconv.FormatUint(uint64(policy0NoTeam.ID), 10)])
|
||||
require.Equal(t, "SELECT 3;", host5PolicyQueries[strconv.FormatUint(uint64(policy3NoTeam.ID), 10)])
|
||||
}
|
||||
|
||||
func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
||||
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
||||
require.NoError(t, err)
|
||||
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
policy1 := newTestPolicy(t, ds, user1, "policy 1", "darwin", &team1.ID)
|
||||
policy2 := newTestPolicy(t, ds, user1, "policy 2", "darwin", &team2.ID)
|
||||
|
||||
// Get policies for an invalid title ID
|
||||
policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{999}, &team1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, policies)
|
||||
|
||||
installer, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Associate an installer to policy 1 on team 1.
|
||||
installer1ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "hello",
|
||||
PreInstallQuery: "SELECT 1",
|
||||
PostInstallScript: "world",
|
||||
InstallerFile: installer,
|
||||
StorageID: "storage1",
|
||||
Filename: "file1",
|
||||
Title: "file1",
|
||||
Version: "1.0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: &team1.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
policy1.SoftwareInstallerID = ptr.Uint(installer1ID)
|
||||
err = ds.SavePolicy(context.Background(), policy1, false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Associate an installer to policy 2 on team 2.
|
||||
installer2ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "hello",
|
||||
PreInstallQuery: "SELECT 1",
|
||||
PostInstallScript: "world",
|
||||
InstallerFile: installer,
|
||||
StorageID: "storage2",
|
||||
Filename: "file2",
|
||||
Title: "file2",
|
||||
Version: "1.0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: &team2.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
policy2.SoftwareInstallerID = ptr.Uint(installer2ID)
|
||||
err = ds.SavePolicy(context.Background(), policy2, false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// get the software installer metadata as we will need the associated software title ids.
|
||||
installer1, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer1ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, installer1.TitleID)
|
||||
installer2, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer2ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, installer2.TitleID)
|
||||
|
||||
// software title 1 should have policy 1 when filtering by team 1
|
||||
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer1.TitleID}, &team1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, policies, 1)
|
||||
require.Equal(t, policy1.ID, policies[0].ID)
|
||||
require.Equal(t, policy1.Name, policies[0].Name)
|
||||
|
||||
// software title 1 should not have any policies when filtering by team 2
|
||||
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer1.TitleID}, &team2.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, policies, 0)
|
||||
|
||||
// software title 2 should have policy 2 when filtering by team 2
|
||||
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer2.TitleID}, &team2.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, policies, 1)
|
||||
require.Equal(t, policy2.ID, policies[0].ID)
|
||||
require.Equal(t, policy2.Name, policies[0].Name)
|
||||
|
||||
// software title 2 should not have any policies when filtering by team 1
|
||||
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer2.TitleID}, &team1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, policies, 0)
|
||||
|
||||
// software title 2 should not have any policies when filtering by no team
|
||||
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer2.TitleID}, nil)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, policies, 0)
|
||||
|
||||
// Associate a couple of installers to policy 3 on no team.
|
||||
installer3ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "hello noteam",
|
||||
PreInstallQuery: "SELECT 1 from noteam",
|
||||
PostInstallScript: "world",
|
||||
InstallerFile: installer,
|
||||
StorageID: "storage3noteam",
|
||||
Filename: "file3noteam",
|
||||
Title: "file3noteam",
|
||||
Version: "1.0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: nil,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
installer4ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{
|
||||
InstallScript: "hello noteam",
|
||||
PreInstallQuery: "SELECT 1 from noteam",
|
||||
PostInstallScript: "world",
|
||||
InstallerFile: installer,
|
||||
StorageID: "storage4noteam",
|
||||
Filename: "file4noteam",
|
||||
Title: "file4noteam",
|
||||
Version: "1.0",
|
||||
Source: "apps",
|
||||
UserID: user1.ID,
|
||||
TeamID: nil,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
policy3 := newTestPolicy(t, ds, user1, "policy 3", "darwin", ptr.Uint(0))
|
||||
policy3.SoftwareInstallerID = ptr.Uint(installer3ID)
|
||||
err = ds.SavePolicy(context.Background(), policy3, false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
policy4 := newTestPolicy(t, ds, user1, "policy 4", "darwin", ptr.Uint(0))
|
||||
policy4.SoftwareInstallerID = ptr.Uint(installer4ID)
|
||||
err = ds.SavePolicy(context.Background(), policy4, false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
installer3, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer3ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, installer3.TitleID)
|
||||
|
||||
installer4, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer4ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, installer3.TitleID)
|
||||
|
||||
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer3.TitleID, *installer4.TitleID}, nil)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, policies, 2)
|
||||
expected := map[uint]fleet.AutomaticInstallPolicy{
|
||||
policy3.ID: {ID: policy3.ID, Name: policy3.Name, TitleID: *installer3.TitleID},
|
||||
policy4.ID: {ID: policy4.ID, Name: policy4.Name, TitleID: *installer4.TitleID},
|
||||
}
|
||||
|
||||
for _, got := range policies {
|
||||
require.Equal(t, expected[got.ID], got)
|
||||
}
|
||||
|
||||
// "No team" titles should not have any policies when filtering by team 1
|
||||
policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer3.TitleID, *installer4.TitleID}, ptr.Uint(1))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, policies, 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -405,6 +405,12 @@ WHERE
|
|||
return nil, ctxerr.Wrap(ctx, err, "get software installer metadata")
|
||||
}
|
||||
|
||||
policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{titleID}, teamID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get policies by software title ID")
|
||||
}
|
||||
dest.AutomaticInstallPolicies = policies
|
||||
|
||||
return &dest, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ func (ds *Datastore) ListSoftwareTitles(
|
|||
if title.PackageVersion != nil {
|
||||
version = *title.PackageVersion
|
||||
}
|
||||
|
||||
title.SoftwarePackage = &fleet.SoftwarePackageOrApp{
|
||||
Name: *title.PackageName,
|
||||
Version: version,
|
||||
|
|
@ -179,6 +180,18 @@ func (ds *Datastore) ListSoftwareTitles(
|
|||
titleIndex[title.ID] = i
|
||||
}
|
||||
|
||||
// Grab the automatic install policies, if any exist
|
||||
policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, titleIDs, opt.TeamID)
|
||||
if err != nil {
|
||||
return nil, 0, nil, ctxerr.Wrap(ctx, err, "batch getting policies by software title IDs")
|
||||
}
|
||||
|
||||
for _, p := range policies {
|
||||
if i, ok := titleIndex[p.TitleID]; ok {
|
||||
softwareList[i].SoftwarePackage.AutomaticInstallPolicies = append(softwareList[i].SoftwarePackage.AutomaticInstallPolicies, p)
|
||||
}
|
||||
}
|
||||
|
||||
// we grab matching versions separately and build the desired object in
|
||||
// the application logic. This is because we need to support MySQL 5.7
|
||||
// and there's no good way to do an aggregation that builds a structure
|
||||
|
|
|
|||
|
|
@ -1865,6 +1865,9 @@ type Datastore interface {
|
|||
|
||||
// CleanUpMDMManagedCertificates removes all managed certificates that are not associated with any host+profile.
|
||||
CleanUpMDMManagedCertificates(ctx context.Context) error
|
||||
|
||||
// GetSoftwareTitleIDByMaintainedAppID returns the software title ID for the given app ID.
|
||||
GetSoftwareTitleIDByMaintainedAppID(ctx context.Context, appID uint, teamID *uint) (uint, error)
|
||||
}
|
||||
|
||||
// MDMAppleStore wraps nanomdm's storage and adds methods to deal with
|
||||
|
|
|
|||
|
|
@ -1162,7 +1162,7 @@ type Service interface {
|
|||
// Fleet-maintained apps
|
||||
|
||||
// AddFleetMaintainedApp adds a Fleet-maintained app to the given team.
|
||||
AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) error
|
||||
AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) (uint, error)
|
||||
// ListFleetMaintainedApps lists Fleet-maintained apps available to a specific team
|
||||
ListFleetMaintainedApps(ctx context.Context, teamID uint, opts ListOptions) ([]MaintainedApp, *PaginationMetadata, error)
|
||||
// GetFleetMaintainedApp returns a Fleet-maintained app by ID
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ type SoftwareInstaller struct {
|
|||
URL string `json:"url" db:"url"`
|
||||
// FleetLibraryAppID is the related Fleet-maintained app for this installer (if not nil).
|
||||
FleetLibraryAppID *uint `json:"-" db:"fleet_library_app_id"`
|
||||
// AutomaticInstallPolicies is the list of policies that trigger automatic
|
||||
// installation of this software.
|
||||
AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies" db:"-"`
|
||||
}
|
||||
|
||||
// SoftwarePackageResponse is the response type used when applying software by batch.
|
||||
|
|
@ -414,6 +417,12 @@ type HostSoftwareWithInstaller struct {
|
|||
AppStoreApp *SoftwarePackageOrApp `json:"app_store_app"`
|
||||
}
|
||||
|
||||
type AutomaticInstallPolicy struct {
|
||||
ID uint `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
TitleID uint `json:"-" db:"software_title_id"`
|
||||
}
|
||||
|
||||
// SoftwarePackageOrApp provides information about a software installer
|
||||
// package or a VPP app.
|
||||
type SoftwarePackageOrApp struct {
|
||||
|
|
@ -421,6 +430,9 @@ type SoftwarePackageOrApp struct {
|
|||
AppStoreID string `json:"app_store_id,omitempty"`
|
||||
// Name is only present for software installer packages.
|
||||
Name string `json:"name,omitempty"`
|
||||
// AutomaticInstallPolicies is only present for Fleet maintained apps
|
||||
// installed automatically with a policy.
|
||||
AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies"`
|
||||
|
||||
Version string `json:"version"`
|
||||
SelfService *bool `json:"self_service,omitempty"`
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
{
|
||||
"identifier": "1password",
|
||||
"bundle_identifier": "com.1password.1password",
|
||||
"installer_format": "zip:app"
|
||||
"installer_format": "zip:app",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.1password.1password';"
|
||||
},
|
||||
{
|
||||
"identifier": "adobe-acrobat-reader",
|
||||
"bundle_identifier": "com.adobe.Reader",
|
||||
"installer_format": "dmg:pkg"
|
||||
"installer_format": "dmg:pkg",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.adobe.Reader';"
|
||||
},
|
||||
{
|
||||
"identifier": "box-drive",
|
||||
|
|
@ -20,92 +22,110 @@
|
|||
"(cd /Users/$LOGGED_IN_USER; defaults delete com.box.desktop)",
|
||||
"echo \"${LOGGED_IN_USER} ALL = (root) NOPASSWD: /Library/Application\\ Support/Box/uninstall_box_drive_r\" >> /etc/sudoers.d/box_uninstall"
|
||||
],
|
||||
"post_uninstall_scripts": ["rm /etc/sudoers.d/box_uninstall"]
|
||||
"post_uninstall_scripts": ["rm /etc/sudoers.d/box_uninstall"],
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.box.desktop';"
|
||||
},
|
||||
{
|
||||
"identifier": "brave-browser",
|
||||
"bundle_identifier": "com.brave.Browser",
|
||||
"installer_format": "dmg:app"
|
||||
"installer_format": "dmg:app",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.brave.Browser';"
|
||||
},
|
||||
{
|
||||
"identifier": "cloudflare-warp",
|
||||
"bundle_identifier": "com.cloudflare.1dot1dot1dot1.macos",
|
||||
"installer_format": "pkg",
|
||||
"post_uninstall_scripts": ["/Applications/Cloudflare\\ WARP.app/Contents/Resources/uninstall.sh"]
|
||||
"post_uninstall_scripts": ["/Applications/Cloudflare\\ WARP.app/Contents/Resources/uninstall.sh"],
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.cloudflare.1dot1dot1dot1.macos';"
|
||||
},
|
||||
{
|
||||
"identifier": "docker",
|
||||
"bundle_identifier": "com.docker.docker",
|
||||
"installer_format": "dmg:app"
|
||||
"installer_format": "dmg:app",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.docker.docker';"
|
||||
},
|
||||
{
|
||||
"identifier": "figma",
|
||||
"bundle_identifier": "com.figma.Desktop",
|
||||
"installer_format": "zip:app"
|
||||
"installer_format": "zip:app",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.figma.Desktop';"
|
||||
},
|
||||
{
|
||||
"identifier": "firefox",
|
||||
"bundle_identifier": "org.mozilla.firefox",
|
||||
"installer_format": "dmg:app"
|
||||
"installer_format": "dmg:app",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'org.mozilla.firefox';"
|
||||
},
|
||||
{
|
||||
"identifier": "google-chrome",
|
||||
"bundle_identifier": "com.google.Chrome",
|
||||
"installer_format": "dmg:app"
|
||||
"installer_format": "dmg:app",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.google.Chrome';"
|
||||
},
|
||||
{
|
||||
"identifier": "microsoft-edge",
|
||||
"bundle_identifier": "com.microsoft.edgemac",
|
||||
"installer_format": "pkg"
|
||||
"installer_format": "pkg",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.edgemac';"
|
||||
},
|
||||
{
|
||||
"identifier": "microsoft-excel",
|
||||
"bundle_identifier": "com.microsoft.Excel",
|
||||
"installer_format": "pkg"
|
||||
"installer_format": "pkg",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.Excel';"
|
||||
},
|
||||
{
|
||||
"identifier": "microsoft-teams",
|
||||
"bundle_identifier": "com.microsoft.teams2",
|
||||
"installer_format": "pkg"
|
||||
"installer_format": "pkg",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.teams2';"
|
||||
},
|
||||
{
|
||||
"identifier": "microsoft-word",
|
||||
"bundle_identifier": "com.microsoft.Word",
|
||||
"installer_format": "pkg"
|
||||
"installer_format": "pkg",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.Word';"
|
||||
},
|
||||
{
|
||||
"identifier": "notion",
|
||||
"bundle_identifier": "notion.id",
|
||||
"installer_format": "dmg:app"
|
||||
"installer_format": "dmg:app",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'notion.id';"
|
||||
},
|
||||
{
|
||||
"identifier": "postman",
|
||||
"bundle_identifier": "com.postmanlabs.mac",
|
||||
"installer_format": "zip:app"
|
||||
"installer_format": "zip:app",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.postmanlabs.mac';"
|
||||
},
|
||||
{
|
||||
"identifier": "slack",
|
||||
"bundle_identifier": "com.tinyspeck.slackmacgap",
|
||||
"installer_format": "dmg:app"
|
||||
"installer_format": "dmg:app",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.tinyspeck.slackmacgap';"
|
||||
},
|
||||
{
|
||||
"identifier": "teamviewer",
|
||||
"bundle_identifier": "com.teamviewer.TeamViewer",
|
||||
"installer_format": "pkg"
|
||||
"installer_format": "pkg",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.teamviewer.TeamViewer';"
|
||||
},
|
||||
{
|
||||
"identifier": "visual-studio-code",
|
||||
"bundle_identifier": "com.microsoft.VSCode",
|
||||
"installer_format": "zip:app"
|
||||
"installer_format": "zip:app",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.VSCode';"
|
||||
},
|
||||
{
|
||||
"identifier": "whatsapp",
|
||||
"bundle_identifier": "net.whatsapp.WhatsApp",
|
||||
"installer_format": "zip:app"
|
||||
"installer_format": "zip:app",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'net.whatsapp.WhatsApp';"
|
||||
},
|
||||
{
|
||||
"identifier": "zoom-for-it-admins",
|
||||
"bundle_identifier": "us.zoom.xos",
|
||||
"installer_format": "pkg"
|
||||
"installer_format": "pkg",
|
||||
"automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'us.zoom.xos';"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1167,6 +1167,8 @@ type GetHostMDMCertificateProfileFunc func(ctx context.Context, hostUUID string,
|
|||
|
||||
type CleanUpMDMManagedCertificatesFunc func(ctx context.Context) error
|
||||
|
||||
type GetSoftwareTitleIDByMaintainedAppIDFunc func(ctx context.Context, appID uint, teamID *uint) (uint, error)
|
||||
|
||||
type DataStore struct {
|
||||
HealthCheckFunc HealthCheckFunc
|
||||
HealthCheckFuncInvoked bool
|
||||
|
|
@ -2887,6 +2889,9 @@ type DataStore struct {
|
|||
CleanUpMDMManagedCertificatesFunc CleanUpMDMManagedCertificatesFunc
|
||||
CleanUpMDMManagedCertificatesFuncInvoked bool
|
||||
|
||||
GetSoftwareTitleIDByMaintainedAppIDFunc GetSoftwareTitleIDByMaintainedAppIDFunc
|
||||
GetSoftwareTitleIDByMaintainedAppIDFuncInvoked bool
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
|
|
@ -6900,3 +6905,10 @@ func (s *DataStore) CleanUpMDMManagedCertificates(ctx context.Context) error {
|
|||
s.mu.Unlock()
|
||||
return s.CleanUpMDMManagedCertificatesFunc(ctx)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetSoftwareTitleIDByMaintainedAppID(ctx context.Context, appID uint, teamID *uint) (uint, error) {
|
||||
s.mu.Lock()
|
||||
s.GetSoftwareTitleIDByMaintainedAppIDFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetSoftwareTitleIDByMaintainedAppIDFunc(ctx, appID, teamID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15132,7 +15132,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
|
|||
installerBytes := []byte("abc")
|
||||
|
||||
// Mock server to serve the "installers"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
installerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/badinstaller":
|
||||
_, _ = w.Write([]byte("badinstaller"))
|
||||
|
|
@ -15143,7 +15143,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
|
|||
_, _ = w.Write(installerBytes)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer installerServer.Close()
|
||||
|
||||
getSoftwareInstallerIDByMAppID := func(mappID uint) uint {
|
||||
var id uint
|
||||
|
|
@ -15166,11 +15166,11 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
|
|||
_, err := h.Write(installerBytes)
|
||||
require.NoError(t, err)
|
||||
spoofedSHA := hex.EncodeToString(h.Sum(nil))
|
||||
_, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET sha256 = ?, installer_url = ?", spoofedSHA, srv.URL+"/installer.zip")
|
||||
_, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET sha256 = ?, installer_url = ?", spoofedSHA, installerServer.URL+"/installer.zip")
|
||||
require.NoError(t, err)
|
||||
_, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET installer_url = ? WHERE id = 2", srv.URL+"/badinstaller")
|
||||
_, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET installer_url = ? WHERE id = 2", installerServer.URL+"/badinstaller")
|
||||
require.NoError(t, err)
|
||||
_, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET installer_url = ? WHERE id = 3", srv.URL+"/timeout")
|
||||
_, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET installer_url = ? WHERE id = 3", installerServer.URL+"/timeout")
|
||||
return err
|
||||
})
|
||||
|
||||
|
|
@ -15352,6 +15352,103 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
|
|||
postinstall, err = s.ds.GetAnyScriptContents(ctx, *i.PostInstallScriptContentID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, req.PostInstallScript, string(postinstall))
|
||||
|
||||
// ===========================================================================================
|
||||
// Adding an automatically installed FMA
|
||||
// ===========================================================================================
|
||||
|
||||
// Add another FMA
|
||||
req = &addFleetMaintainedAppRequest{
|
||||
AppID: 5,
|
||||
SelfService: false,
|
||||
PreInstallQuery: "SELECT 1",
|
||||
InstallScript: "echo foo",
|
||||
PostInstallScript: "echo done",
|
||||
TeamID: ptr.Uint(0),
|
||||
}
|
||||
|
||||
addMAResp = addFleetMaintainedAppResponse{}
|
||||
s.DoJSON("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusOK, &addMAResp)
|
||||
require.NoError(t, addMAResp.Err)
|
||||
require.NotEmpty(t, addMAResp.SoftwareTitleID)
|
||||
|
||||
// Add the automatic install policy
|
||||
tpParams := teamPolicyRequest{
|
||||
Name: "[Install software]",
|
||||
Query: "select * from osquery;",
|
||||
Description: "Some description",
|
||||
Platform: "darwin",
|
||||
SoftwareTitleID: &addMAResp.SoftwareTitleID,
|
||||
}
|
||||
tpResp := teamPolicyResponse{}
|
||||
s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusOK, &tpResp)
|
||||
require.NotNil(t, tpResp.Policy)
|
||||
require.NotEmpty(t, tpResp.Policy.ID)
|
||||
|
||||
// List software titles; we should see the policy on the software title object
|
||||
|
||||
resp = listSoftwareTitlesResponse{}
|
||||
s.DoJSON(
|
||||
"GET", "/api/latest/fleet/software/titles",
|
||||
listSoftwareTitlesRequest{},
|
||||
http.StatusOK, &resp,
|
||||
"per_page", "2",
|
||||
"order_key", "id",
|
||||
"order_direction", "desc",
|
||||
"available_for_install", "true",
|
||||
"team_id", "0",
|
||||
)
|
||||
|
||||
require.Len(t, resp.SoftwareTitles, 2)
|
||||
// most recently added FMA should have 1 automatic install policy
|
||||
st := resp.SoftwareTitles[0] // sorted by ID above
|
||||
require.NotNil(t, st.SoftwarePackage)
|
||||
require.Len(t, st.SoftwarePackage.AutomaticInstallPolicies, 1)
|
||||
gotPolicy := st.SoftwarePackage.AutomaticInstallPolicies[0]
|
||||
require.Equal(t, tpResp.Policy.Name, gotPolicy.Name)
|
||||
require.Equal(t, tpResp.Policy.ID, gotPolicy.ID)
|
||||
|
||||
// First FMA added doesn't have automatic install policies
|
||||
st = resp.SoftwareTitles[1] // sorted by ID above
|
||||
require.NotNil(t, st.SoftwarePackage)
|
||||
require.Empty(t, st.SoftwarePackage.AutomaticInstallPolicies)
|
||||
|
||||
// Get the specific app that we set to be installed automatically
|
||||
var titleResp getSoftwareTitleResponse
|
||||
s.DoJSON(
|
||||
"GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addMAResp.SoftwareTitleID),
|
||||
getSoftwareTitleRequest{},
|
||||
http.StatusOK, &titleResp,
|
||||
"team_id", "0",
|
||||
)
|
||||
require.NotNil(t, titleResp.SoftwareTitle)
|
||||
swTitle := titleResp.SoftwareTitle
|
||||
require.NotNil(t, swTitle.SoftwarePackage)
|
||||
require.Len(t, swTitle.SoftwarePackage.AutomaticInstallPolicies, 1)
|
||||
gotPolicy = swTitle.SoftwarePackage.AutomaticInstallPolicies[0]
|
||||
require.Equal(t, tpResp.Policy.Name, gotPolicy.Name)
|
||||
require.Equal(t, tpResp.Policy.ID, gotPolicy.ID)
|
||||
|
||||
// Policy should appear in the list of policies
|
||||
var listPolResp listTeamPoliciesResponse
|
||||
s.DoJSON(
|
||||
"GET", "/api/latest/fleet/teams/0/policies",
|
||||
listTeamPoliciesRequest{},
|
||||
http.StatusOK, &listPolResp,
|
||||
"page", "0",
|
||||
)
|
||||
|
||||
require.Len(t, listPolResp.Policies, 1)
|
||||
policies := listPolResp.Policies
|
||||
require.Equal(t, tpResp.Policy.Name, policies[0].Name)
|
||||
require.Equal(t, tpResp.Policy.ID, policies[0].ID)
|
||||
require.Equal(t, tpResp.Policy.Description, policies[0].Description)
|
||||
require.Equal(t, tpResp.Policy.Query, policies[0].Query)
|
||||
require.Equal(t, "darwin", policies[0].Platform)
|
||||
require.False(t, policies[0].Critical)
|
||||
require.NotNil(t, policies[0].InstallSoftware)
|
||||
require.Equal(t, tpResp.Policy.InstallSoftware.Name, policies[0].InstallSoftware.Name)
|
||||
require.Equal(t, tpResp.Policy.InstallSoftware.SoftwareTitleID, policies[0].InstallSoftware.SoftwareTitleID)
|
||||
}
|
||||
|
||||
func (s *integrationEnterpriseTestSuite) TestWindowsMigrateMDMNotEnabled() {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ type addFleetMaintainedAppRequest struct {
|
|||
}
|
||||
|
||||
type addFleetMaintainedAppResponse struct {
|
||||
Err error `json:"error,omitempty"`
|
||||
SoftwareTitleID uint `json:"software_title_id,omitempty"`
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r addFleetMaintainedAppResponse) error() error { return r.Err }
|
||||
|
|
@ -29,7 +30,7 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc
|
|||
req := request.(*addFleetMaintainedAppRequest)
|
||||
ctx, cancel := context.WithTimeout(ctx, maintainedapps.InstallerTimeout)
|
||||
defer cancel()
|
||||
err := svc.AddFleetMaintainedApp(
|
||||
titleId, err := svc.AddFleetMaintainedApp(
|
||||
ctx,
|
||||
req.TeamID,
|
||||
req.AppID,
|
||||
|
|
@ -46,15 +47,15 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc
|
|||
|
||||
return &addFleetMaintainedAppResponse{Err: err}, nil
|
||||
}
|
||||
return &addFleetMaintainedAppResponse{}, nil
|
||||
return &addFleetMaintainedAppResponse{SoftwareTitleID: titleId}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) error {
|
||||
func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) (uint, error) {
|
||||
// skipauth: No authorization check needed due to implementation returning
|
||||
// only license error.
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
|
||||
return fleet.ErrMissingLicense
|
||||
return 0, fleet.ErrMissingLicense
|
||||
}
|
||||
|
||||
type listFleetMaintainedAppsRequest struct {
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint
|
|||
return nil, ctxerr.Wrap(ctx, err, "checked using a global admin")
|
||||
}
|
||||
|
||||
return nil, fleet.NewPermissionError("Error: You don’t have permission to view specified software. It is installed on hosts that belong to team you don’t have permissions to view.")
|
||||
return nil, fleet.NewPermissionError("Error: You don't have permission to view specified software. It is installed on hosts that belong to team you don't have permissions to view.")
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting software title by id")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,11 @@ func (ts *withServer) commonTearDownTest(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `DELETE FROM policies;`)
|
||||
return err
|
||||
})
|
||||
|
||||
// Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above).
|
||||
mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`)
|
||||
|
|
|
|||
Loading…
Reference in a new issue