Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

20404: Edit packages feature #21812

Merged
merged 58 commits into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
d7cbe2e
Start of work
RachelElysia Aug 30, 2024
1aa091a
Updates to FileUploader to include FileDetails if fileDetails is prov…
RachelElysia Sep 3, 2024
4add471
Add global activity for edit software plus tests for add, edit, delet…
RachelElysia Sep 3, 2024
5b236f6
Work towards API call
RachelElysia Sep 3, 2024
b7296b2
More minor changes
RachelElysia Sep 4, 2024
759d607
Proof of concept of save modal, need work on uploader refactor
RachelElysia Sep 6, 2024
bbac147
Updates to FileUploader to include FileDetails if fileDetails is prov…
RachelElysia Sep 3, 2024
2a79f6d
Proof of concept of save modal, need work on uploader refactor
RachelElysia Sep 6, 2024
05ce2f7
Updates to have FileDetails separate component, move confirm save cha…
RachelElysia Sep 7, 2024
902c933
Fix merge problems
RachelElysia Sep 7, 2024
e4b9039
Start backend build for Edit Software
iansltx Sep 6, 2024
9d7b6b0
Fix types
iansltx Sep 7, 2024
48addad
Add audit logs docs, start building uninstall script editing
iansltx Sep 7, 2024
b62f536
Finish uninstall script handling, start building software package upl…
iansltx Sep 7, 2024
a9824fd
Cleanup on activities, software installer handling
iansltx Sep 8, 2024
762be90
Finish installer validation, add installer file persistence
iansltx Sep 9, 2024
91334c6
Wire up cancellation of pending installs
iansltx Sep 9, 2024
8111717
Typo fix
iansltx Sep 9, 2024
f5b288d
Fix request type, move endpoint under correct heading
iansltx Sep 9, 2024
7577ccd
Switch endpoint to /:id/package
iansltx Sep 9, 2024
115acda
Switch response to installer rather than entire title
iansltx Sep 9, 2024
28f9269
Hydrate updated installer statuses
iansltx Sep 10, 2024
7438e77
Implement install count clearing
iansltx Sep 10, 2024
d1a4648
Implement installer update on save
iansltx Sep 10, 2024
3a4b190
Remove no-longer-relevant TODOs
iansltx Sep 10, 2024
914f3ed
Fix lint issues
iansltx Sep 10, 2024
c7433e8
Fix API docs generation
iansltx Sep 10, 2024
93d44ef
Require team ID
iansltx Sep 10, 2024
457608a
Map uninstall script on request
iansltx Sep 10, 2024
8fa3455
Use installer metadata from DB where we can rather than file-based meta
iansltx Sep 10, 2024
da21623
Hook up UI to API endpoints
lukeheath Sep 10, 2024
9a551b7
Fix cancel pending installs query
iansltx Sep 11, 2024
0810842
Use full payload for software edits
iansltx Sep 11, 2024
7c218c2
Switch to from-DB existing installer metadata for Extension, clean up…
iansltx Sep 11, 2024
4c800e0
Use package IDs list from database when needed for uninstall script c…
iansltx Sep 11, 2024
43a2e10
Add cancellation of pending uninstalls
iansltx Sep 11, 2024
42da057
Add godoc for new data store methods, add documentation on which fiel…
iansltx Sep 11, 2024
b1f773f
Clarify software title ID parameter, pull into software installer upd…
iansltx Sep 11, 2024
8e65a9e
Wire up install script changes correctly
iansltx Sep 12, 2024
685f90e
Fix file upload for software edit, touch uploaded_at when a new packa…
iansltx Sep 12, 2024
81aff9a
Lint fix
iansltx Sep 12, 2024
f1a0df9
Rename AddPackage(Form|AdvancedOptions)
Sep 12, 2024
1b35fbf
Delete stray/duplicate route
iansltx Sep 13, 2024
1d0a8c6
Remove redundant source check (extension check is tighter)
iansltx Sep 13, 2024
fa7621d
Typo fix
iansltx Sep 13, 2024
de4bbff
Remove uninstall script commented validation (excluded in IPackageFor…
iansltx Sep 13, 2024
c76a5f7
Reorder imports
iansltx Sep 13, 2024
8f4a305
Bail if we get nil pointers where we don't expect them
iansltx Sep 13, 2024
a2365e7
Switch to map[string]bool for dirty check
iansltx Sep 13, 2024
154036b
Merge installer update side effects into a single data store method, …
iansltx Sep 13, 2024
9a653ad
Finish frontend work for Edit Software
iansltx Sep 14, 2024
ee90a01
add changes file
iansltx Sep 14, 2024
e90352b
Fix dirty check for simpler update
iansltx Sep 16, 2024
fdd6f2d
We don't need to skip for a blank script here
iansltx Sep 16, 2024
78d2b83
Merge branch 'main' into 20404-edit-software-fe
iansltx Sep 16, 2024
f14e9ee
Revert "We don't need to skip for a blank script here"
iansltx Sep 16, 2024
bd7097a
Ensure multipart array values aren't stacked on top of each other
iansltx Sep 16, 2024
f4d36cc
Handle CodeQL issue
iansltx Sep 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/20404-edit-software
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Software installer packages, self-service flag, scripts, pre-install query, and self-service availability can now be edited in-place rather than needing to be deleted and re-added.
23 changes: 23 additions & 0 deletions docs/Contributing/Audit-logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,29 @@ This activity contains the following fields:
}
```

## edited_software

Generated when a software installer is updated in Fleet.

This activity contains the following fields:
- "software_title": Name of the software.
- "software_package": Filename of the installer. `null` if the installer package was not modified.
- "team_name": Name of the team on which this software was updated. `null` if it was updated on no team.
- "team_id": The ID of the team on which this software was updated. `null` if it was updated on no team.
- "self_service": Whether the software is available for installation by the end user.

#### Example

```json
{
"software_title": "Falcon.app",
"software_package": "FalconSensor-6.44.pkg",
"team_name": "Workstations",
"team_id": 123,
"self_service": true
}
```

## deleted_software

Generated when a software installer is deleted from Fleet.
Expand Down
222 changes: 222 additions & 0 deletions ee/server/service/software_installers.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,228 @@ func preProcessUninstallScript(payload *fleet.UploadSoftwareInstallerPayload) {
payload.UninstallScript = packageIDRegex.ReplaceAllString(payload.UninstallScript, fmt.Sprintf("%s${suffix}", packageID))
}

func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: payload.TeamID}, fleet.ActionWrite); err != nil {
return nil, err
}

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

if payload.TeamID == nil {
return nil, &fleet.BadRequestError{Message: "team_id is required; enter 0 for no team"}
}

var teamName *string
if *payload.TeamID != 0 {
t, err := svc.ds.Team(ctx, *payload.TeamID)
if err != nil {
return nil, err
}
teamName = &t.Name
}

// get software by ID, fail if it does not exist or does not have an existing installer
software, err := svc.ds.SoftwareTitleByID(ctx, payload.TitleID, payload.TeamID, fleet.TeamFilter{
User: vc.User,
IncludeObserver: true,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software title by id")
}

// TODO when we start supporting multiple installers per title X team, need to rework how we determine installer to edit
if software.SoftwareInstallersCount != 1 {
return nil, &fleet.BadRequestError{
Message: "There are no software installers defined yet for this title and team. Please add an installer instead of attempting to edit.",
}
}

existingInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID, true)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting existing installer")
}

if payload.SelfService == nil && payload.InstallerFile == nil && payload.PreInstallQuery == nil &&
payload.InstallScript == nil && payload.PostInstallScript == nil && payload.UninstallScript == nil {
return existingInstaller, nil // no payload, noop
}

payload.InstallerID = existingInstaller.InstallerID
dirty := make(map[string]bool)

if payload.SelfService != nil && *payload.SelfService != existingInstaller.SelfService {
dirty["SelfService"] = true
}

activity := fleet.ActivityTypeEditedSoftware{
SoftwareTitle: existingInstaller.SoftwareTitle,
TeamName: teamName,
TeamID: payload.TeamID,
SelfService: existingInstaller.SelfService,
}

var payloadForNewInstallerFile *fleet.UploadSoftwareInstallerPayload
if payload.InstallerFile != nil {
payloadForNewInstallerFile = &fleet.UploadSoftwareInstallerPayload{
InstallerFile: payload.InstallerFile,
Filename: payload.Filename,
}

newInstallerExtension, err := svc.addMetadataToSoftwarePayload(ctx, payloadForNewInstallerFile)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "extracting updated installer metadata")
}

if newInstallerExtension != existingInstaller.Extension {
return nil, &fleet.BadRequestError{
Message: "The selected package is for a different file type.",
InternalErr: ctxerr.Wrap(ctx, err, "installer extension mismatch"),
}
}

if payloadForNewInstallerFile.Title != software.Name {
return nil, &fleet.BadRequestError{
Message: "The selected package is for different software.",
InternalErr: ctxerr.Wrap(ctx, err, "installer software title mismatch"),
}
}

if payloadForNewInstallerFile.StorageID != existingInstaller.StorageID {
activity.SoftwarePackage = &payload.Filename
payload.StorageID = payloadForNewInstallerFile.StorageID
payload.Filename = payloadForNewInstallerFile.Filename
payload.Version = payloadForNewInstallerFile.Version
payload.PackageIDs = payloadForNewInstallerFile.PackageIDs

dirty["Package"] = true
} else { // noop if uploaded installer is identical to previous installer
payloadForNewInstallerFile = nil
payload.InstallerFile = nil
}
}

if payload.InstallerFile == nil { // fill in existing existingInstaller data to payload
payload.StorageID = existingInstaller.StorageID
payload.Filename = existingInstaller.Name
payload.Version = existingInstaller.Version
payload.PackageIDs = existingInstaller.PackageIDs()
}

// default pre-install query is blank, so blanking out the query doesn't have a semantic meaning we have to take care of
if payload.PreInstallQuery != nil && *payload.PreInstallQuery != existingInstaller.PreInstallQuery {
dirty["PreInstallQuery"] = true
}

if payload.InstallScript != nil {
installScript := file.Dos2UnixNewlines(*payload.InstallScript)
if installScript == "" {
installScript = file.GetInstallScript(existingInstaller.Extension)
}

if installScript != existingInstaller.InstallScript {
dirty["InstallScript"] = true
payload.InstallScript = &installScript
}
}

if payload.PostInstallScript != nil {
postInstallScript := file.Dos2UnixNewlines(*payload.PostInstallScript)
if postInstallScript != existingInstaller.PostInstallScript {
dirty["PostInstallScript"] = true
payload.PostInstallScript = &postInstallScript
}
}

if payload.UninstallScript != nil {
uninstallScript := file.Dos2UnixNewlines(*payload.UninstallScript)
if uninstallScript == "" { // extension can't change on an edit so we can generate off of the existing file
uninstallScript = file.GetUninstallScript(existingInstaller.Extension)
}

payloadForUninstallScript := &fleet.UploadSoftwareInstallerPayload{
Extension: existingInstaller.Extension,
UninstallScript: uninstallScript,
PackageIDs: existingInstaller.PackageIDs(),
}
if payloadForNewInstallerFile != nil {
payloadForUninstallScript.PackageIDs = payloadForNewInstallerFile.PackageIDs
}

preProcessUninstallScript(payloadForUninstallScript)
if payloadForUninstallScript.UninstallScript != existingInstaller.UninstallScript {
uninstallScript = payloadForUninstallScript.UninstallScript
dirty["UninstallScript"] = true
payload.UninstallScript = &uninstallScript
}
}

// persist changes starting here, now that we've done all the validation/diffing we can
if len(dirty) > 0 {
if len(dirty) == 1 && dirty["SelfService"] == true { // only self-service changed; use lighter update function
if err := svc.ds.UpdateInstallerSelfServiceFlag(ctx, *payload.SelfService, existingInstaller.InstallerID); err != nil {
iansltx marked this conversation as resolved.
Show resolved Hide resolved
return nil, ctxerr.Wrap(ctx, err, "updating installer self service flag")
}
} else {
if payloadForNewInstallerFile != nil {
if err := svc.storeSoftware(ctx, payloadForNewInstallerFile); err != nil {
return nil, ctxerr.Wrap(ctx, err, "storing software installer")
}
}

// fill in values from existing installer if they weren't supplied
if payload.InstallScript == nil {
payload.InstallScript = &existingInstaller.InstallScript
}
if payload.UninstallScript == nil {
payload.UninstallScript = &existingInstaller.UninstallScript
}
if payload.PostInstallScript == nil && dirty["PostInstallScript"] == false {
payload.PostInstallScript = &existingInstaller.PostInstallScript
}
if payload.PreInstallQuery == nil {
payload.PreInstallQuery = &existingInstaller.PreInstallQuery
}
if payload.SelfService == nil {
payload.SelfService = &existingInstaller.SelfService
}

if err := svc.ds.SaveInstallerUpdates(ctx, payload); err != nil {
return nil, ctxerr.Wrap(ctx, err, "saving installer updates")
}

// if we're updating anything other than self-service, we cancel pending installs/uninstalls,
// and if we're updating the package we reset counts. This is run in its own transaction internally
// for consistency, but independent of the installer update query as the main update should stick
// even if side effects fail.
if err := svc.ds.ProcessInstallerUpdateSideEffects(ctx, existingInstaller.InstallerID, true, dirty["Package"] == true); err != nil {
return nil, err
}
}

if err := svc.NewActivity(ctx, vc.User, activity); err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating activity for edited software")
}
}

// re-pull installer from database to ensure any side effects are accounted for; may be able to optimize this out later
updatedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID, true)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "re-hydrating updated installer metadata")
}

statuses, err := svc.ds.GetSummaryHostSoftwareInstalls(ctx, updatedInstaller.InstallerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting updated installer statuses")
}
updatedInstaller.Status = statuses

return updatedInstaller, nil
}

func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error {
if teamID == nil {
return fleet.NewInvalidArgumentError("team_id", "is required")
Expand Down
64 changes: 64 additions & 0 deletions frontend/components/FileDetails/FileDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from "react";

import { ISupportedGraphicNames } from "components/FileUploader/FileUploader";
import Graphic from "components/Graphic";
import Button from "components/buttons/Button";
import Icon from "components/Icon";

interface IFileDetailsProps {
graphicNames: ISupportedGraphicNames | ISupportedGraphicNames[];
fileDetails: {
name: string;
platform?: string;
};
canEdit: boolean;
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
accept?: string;
}

const baseClass = "file-details";

const FileDetails = ({
graphicNames,
fileDetails,
canEdit,
onFileSelect,
accept,
}: IFileDetailsProps) => {
return (
<div className={baseClass}>
<div className={`${baseClass}__info`}>
<Graphic
name={
typeof graphicNames === "string" ? graphicNames : graphicNames[0]
}
/>
<div className={`${baseClass}__content`}>
<div className={`${baseClass}__name`}>{fileDetails.name}</div>
{fileDetails.platform && (
<div className={`${baseClass}__platform`}>
{fileDetails.platform}
</div>
)}
</div>
</div>
{canEdit && (
<div className={`${baseClass}__edit`}>
<Button className={`${baseClass}__edit-button`} variant="icon">
<label htmlFor="edit-file">
<Icon name="pencil" color="ui-fleet-black-75" />
</label>
</Button>
<input
accept={accept}
id="edit-file"
type="file"
onChange={onFileSelect}
/>
</div>
)}
</div>
);
};

export default FileDetails;
37 changes: 37 additions & 0 deletions frontend/components/FileDetails/_styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
.file-details {
display: flex;
justify-content: space-between;
width: 100%;

&__info {
display: flex;
gap: $pad-medium;
align-items: center;
width: 100%;
text-align: left;
}

&__name {
font-size: $x-small;
font-weight: $bold;
}

&__platform {
font-size: $xx-small;
color: $ui-fleet-black-75;
}

&__edit {
display: flex;
align-items: center; // Center the button vertically
margin-right: -$pad-medium; // Adjust for button padding
}

label {
display: flex;

&:hover {
cursor: pointer;
}
}
}
1 change: 1 addition & 0 deletions frontend/components/FileDetails/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./FileDetails";
Loading
Loading