Add support to upload RPM packages (#22502)

#22473

- [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] Added/updated tests
- [X] Manual QA for all new/changed functionality
- For Orbit and Fleet Desktop changes:
- [x] Manual QA must be performed in the three main OSs, macOS, Windows
and Linux.

---------

Co-authored-by: RachelElysia <71795832+RachelElysia@users.noreply.github.com>
Co-authored-by: Ian Littman <iansltx@gmail.com>
This commit is contained in:
Lucas Manuel Rodriguez 2024-10-01 13:02:13 -03:00 committed by GitHub
parent f7fc22d766
commit f8f24e0a80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 328 additions and 31 deletions

View file

@ -0,0 +1 @@
* Added support for uploading RPM packages.

View file

@ -1727,7 +1727,7 @@ func TestGitOpsTeamSofwareInstallers(t *testing.T) {
wantErr string
}{
{"testdata/gitops/team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."},
{"testdata/gitops/team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe or .deb."},
{"testdata/gitops/team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .deb or .rpm."},
{"testdata/gitops/team_software_installer_too_large.yml", "The maximum file size is 500 MiB"},
{"testdata/gitops/team_software_installer_valid.yml", ""},
{"testdata/gitops/team_software_installer_valid_apply.yml", ""},
@ -1782,7 +1782,7 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) {
wantErr string
}{
{"testdata/gitops/no_team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."},
{"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe or .deb."},
{"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .deb or .rpm."},
{"testdata/gitops/no_team_software_installer_too_large.yml", "The maximum file size is 500 MiB"},
{"testdata/gitops/no_team_software_installer_valid.yml", ""},
{"testdata/gitops/no_team_software_installer_pre_condition_multiple_queries.yml", "should have only one query."},

View file

@ -1054,7 +1054,7 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f
if err != nil {
if errors.Is(err, file.ErrUnsupportedType) {
return "", &fleet.BadRequestError{
Message: "Couldn't edit software. File type not supported. The file should be .pkg, .msi, .exe or .deb.",
Message: "Couldn't edit software. File type not supported. The file should be .pkg, .msi, .exe, .deb or .rpm.",
InternalErr: ctxerr.Wrap(ctx, err, "extracting metadata from installer"),
}
}
@ -1517,7 +1517,7 @@ func packageExtensionToPlatform(ext string) string {
requiredPlatform = "windows"
case ".pkg":
requiredPlatform = "darwin"
case ".deb":
case ".deb", ".rpm":
requiredPlatform = "linux"
default:
return ""

View file

@ -1,4 +1,4 @@
const unixPackageTypes = ["pkg", "deb"] as const;
const unixPackageTypes = ["pkg", "deb", "rpm"] as const;
const windowsPackageTypes = ["msi", "exe"] as const;
export const packageTypes = [
...unixPackageTypes,

View file

@ -270,7 +270,7 @@ export interface ISoftwareInstallResults {
// ISoftwareInstallerType defines the supported installer types for
// software uploaded by the IT admin.
export type ISoftwareInstallerType = "pkg" | "msi" | "deb" | "exe";
export type ISoftwareInstallerType = "pkg" | "msi" | "deb" | "rpm" | "exe";
export interface ISoftwareLastInstall {
install_uuid: string;

View file

@ -23,6 +23,7 @@ const getSupportedScriptTypeText = (pkgType: PackageType) => {
const PKG_TYPE_TO_ID_TEXT = {
pkg: "package IDs",
deb: "package name",
rpm: "package name",
msi: "product code",
exe: "software name",
} as const;

View file

@ -58,7 +58,7 @@ interface IPackageFormProps {
defaultSelfService?: boolean;
}
const ACCEPTED_EXTENSIONS = ".pkg,.msi,.exe,.deb";
const ACCEPTED_EXTENSIONS = ".pkg,.msi,.exe,.deb,.rpm";
const PackageForm = ({
isUploading,
@ -173,7 +173,7 @@ const PackageForm = ({
canEdit={isEditingSoftware}
graphicName={"file-pkg"}
accept={ACCEPTED_EXTENSIONS}
message=".pkg, .msi, .exe, or .deb"
message=".pkg, .msi, .exe, .deb, or .rpm"
onFileUpload={onFileSelect}
buttonMessage="Choose file"
buttonType="link"

View file

@ -28,6 +28,7 @@ const getPlatformDisplayFromPackageSuffix = (packageName: string) => {
case "pkg":
return "macOS";
case "deb":
case "rpm":
return "Linux";
case "exe":
return "Windows";

View file

@ -2,15 +2,22 @@ import { getPlatformDisplayName } from "./fileUtils";
describe("fileUtils", () => {
describe("getPlatformDisplayName", () => {
it("should return the correct platform display name depending on the file extension", () => {
const file = new File([""], "test.pkg");
expect(getPlatformDisplayName(file)).toEqual("macOS");
const testCases = [
{ extension: "pkg", platform: "macOS" },
{ extension: "json", platform: "macOS" },
{ extension: "mobileconfig", platform: "macOS" },
{ extension: "exe", platform: "Windows" },
{ extension: "msi", platform: "Windows" },
{ extension: "xml", platform: "Windows" },
{ extension: "deb", platform: "Linux" },
{ extension: "rpm", platform: "Linux" },
];
const file2 = new File([""], "test.exe");
expect(getPlatformDisplayName(file2)).toEqual("Windows");
const file3 = new File([""], "test.deb");
expect(getPlatformDisplayName(file3)).toEqual("linux");
testCases.forEach(({ extension, platform }) => {
it(`should return ${platform} for .${extension} files`, () => {
const file = new File([""], `test.${extension}`);
expect(getPlatformDisplayName(file)).toEqual(platform);
});
});
});
});

View file

@ -1,4 +1,4 @@
type IPlatformDisplayName = "macOS" | "Windows" | "linux";
type IPlatformDisplayName = "macOS" | "Windows" | "Linux";
const getFileExtension = (file: File) => {
const nameParts = file.name.split(".");
@ -15,7 +15,8 @@ export const FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME: Record<
exe: "Windows",
msi: "Windows",
xml: "Windows",
deb: "linux",
deb: "Linux",
rpm: "Linux",
};
/**

View file

@ -6,6 +6,8 @@ import installMsi from "../../pkg/file/scripts/install_msi.ps1";
import installExe from "../../pkg/file/scripts/install_exe.ps1";
// @ts-ignore
import installDeb from "../../pkg/file/scripts/install_deb.sh";
// @ts-ignore
import installRPM from "../../pkg/file/scripts/install_rpm.sh";
/*
* getInstallScript returns a string with a script to install the
@ -20,6 +22,8 @@ const getDefaultInstallScript = (fileName: string): string => {
return installMsi;
case "deb":
return installDeb;
case "rpm":
return installRPM;
case "exe":
return installExe;
default:

View file

@ -6,6 +6,8 @@ import uninstallMsi from "../../pkg/file/scripts/uninstall_msi.ps1";
import uninstallExe from "../../pkg/file/scripts/uninstall_exe.ps1";
// @ts-ignore
import uninstallDeb from "../../pkg/file/scripts/uninstall_deb.sh";
// @ts-ignore
import uninstallRPM from "../../pkg/file/scripts/uninstall_rpm.sh";
/*
* getUninstallScript returns a string with a script to uninstall the
@ -20,6 +22,8 @@ const getDefaultUninstallScript = (fileName: string): string => {
return uninstallMsi;
case "deb":
return uninstallDeb;
case "rpm":
return uninstallRPM;
case "exe":
return uninstallExe;
default:

2
go.mod
View file

@ -199,6 +199,8 @@ require (
github.com/caarlos0/env/v6 v6.7.0 // indirect
github.com/caarlos0/go-shellwords v1.0.12 // indirect
github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e // indirect
github.com/cavaliergopher/cpio v1.0.1 // indirect
github.com/cavaliergopher/rpm v1.2.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cloudflare/circl v1.3.8 // indirect

4
go.sum
View file

@ -320,6 +320,10 @@ github.com/caarlos0/testfs v0.4.3 h1:q1zEM5hgsssqWanAfevJYYa0So60DdK6wlJeTc/yfUE
github.com/caarlos0/testfs v0.4.3/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk=
github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e h1:hHg27A0RSSp2Om9lubZpiMgVbvn39bsUmW9U5h0twqc=
github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e/go.mod h1:oDpT4efm8tSYHXV5tHSdRvBet/b/QzxZ+XyyPehvm3A=
github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM=
github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc=
github.com/cavaliergopher/rpm v1.2.0 h1:s0h+QeVK252QFTolkhGiMeQ1f+tMeIMhGl8B1HUmGUc=
github.com/cavaliergopher/rpm v1.2.0/go.mod h1:R0q3vTqa7RUvPofAZYrnjJ63hh2vngjFfphuXiExVos=
github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=

View file

@ -22,8 +22,10 @@ import (
"github.com/rs/zerolog/log"
)
type QueryResponse = osquery_gen.ExtensionResponse
type QueryResponseStatus = osquery_gen.ExtensionStatus
type (
QueryResponse = osquery_gen.ExtensionResponse
QueryResponseStatus = osquery_gen.ExtensionStatus
)
// Client defines the methods required for the API requests to the server. The
// fleet.OrbitClient type satisfies this interface.
@ -202,7 +204,7 @@ func (r *Runner) installSoftware(ctx context.Context, installID string) (*fleet.
return payload, fmt.Errorf("creating temporary directory: %w", err)
}
log.Debug().Msgf("about to download software installer")
log.Debug().Str("install_id", installID).Msgf("about to download software installer")
installerPath, err := r.OrbitClient.DownloadSoftwareInstaller(installer.InstallerID, tmpDir)
if err != nil {
return payload, err
@ -233,7 +235,7 @@ func (r *Runner) installSoftware(ctx context.Context, installID string) (*fleet.
}
if installer.PostInstallScript != "" {
log.Debug().Msgf("about to run post-install script")
log.Debug().Msgf("about to run post-install script for %s", installerPath)
postOutput, postExitCode, postErr := r.runInstallerScript(ctx, installer.PostInstallScript, installerPath, "post-install-script"+scriptExtension)
payload.PostInstallScriptOutput = &postOutput
payload.PostInstallScriptExitCode = &postExitCode

View file

@ -42,6 +42,8 @@ func ExtractInstallerMetadata(r io.Reader) (*InstallerMetadata, error) {
switch extension {
case "deb":
meta, err = ExtractDebMetadata(br)
case "rpm":
meta, err = ExtractRPMMetadata(br)
case "exe":
meta, err = ExtractPEMetadata(br)
case "pkg":
@ -59,12 +61,16 @@ func ExtractInstallerMetadata(r io.Reader) (*InstallerMetadata, error) {
return meta, err
}
// typeFromBytes deduces the type from the magic bytes.
// See https://en.wikipedia.org/wiki/List_of_file_signatures.
func typeFromBytes(br *bufio.Reader) (string, error) {
switch {
case hasPrefix(br, []byte{0x78, 0x61, 0x72, 0x21}):
return "pkg", nil
case hasPrefix(br, []byte("!<arch>\ndebian")):
return "deb", nil
case hasPrefix(br, []byte{0xed, 0xab, 0xee, 0xdb}):
return "rpm", nil
case hasPrefix(br, []byte{0xd0, 0xcf}):
return "msi", nil
case hasPrefix(br, []byte("MZ")):

View file

@ -16,6 +16,9 @@ var installExeScript string
//go:embed scripts/install_deb.sh
var installDebScript string
//go:embed scripts/install_rpm.sh
var installRPMScript string
// GetInstallScript returns a script that can be used to install the given extension
func GetInstallScript(extension string) string {
switch extension {
@ -23,6 +26,8 @@ func GetInstallScript(extension string) string {
return installMsiScript
case "deb":
return installDebScript
case "rpm":
return installRPMScript
case "pkg":
return installPkgScript
case "exe":
@ -44,6 +49,9 @@ var removeMsiScript string
//go:embed scripts/remove_deb.sh
var removeDebScript string
//go:embed scripts/remove_rpm.sh
var removeRPMScript string
// GetRemoveScript returns a script that can be used to remove an
// installer with the given extension.
func GetRemoveScript(extension string) string {
@ -52,6 +60,8 @@ func GetRemoveScript(extension string) string {
return removeMsiScript
case "deb":
return removeDebScript
case "rpm":
return removeRPMScript
case "pkg":
return removePkgScript
case "exe":
@ -73,6 +83,9 @@ var uninstallMsiScript string
//go:embed scripts/uninstall_deb.sh
var uninstallDebScript string
//go:embed scripts/uninstall_rpm.sh
var uninstallRPMScript string
// GetUninstallScript returns a script that can be used to uninstall a
// software item with the given extension.
func GetUninstallScript(extension string) string {
@ -81,6 +94,8 @@ func GetUninstallScript(extension string) string {
return uninstallMsiScript
case "deb":
return uninstallDebScript
case "rpm":
return uninstallRPMScript
case "pkg":
return uninstallPkgScript
case "exe":

View file

@ -11,9 +11,7 @@ import (
"github.com/stretchr/testify/require"
)
var (
update = flag.Bool("update", false, "update the golden files of this test")
)
var update = flag.Bool("update", false, "update the golden files of this test")
func TestMain(m *testing.M) {
flag.Parse()
@ -41,6 +39,11 @@ func TestGetInstallAndRemoveScript(t *testing.T) {
"remove": "./scripts/remove_deb.sh",
"uninstall": "./scripts/uninstall_deb.sh",
},
"rpm": {
"install": "./scripts/install_rpm.sh",
"remove": "./scripts/remove_rpm.sh",
"uninstall": "./scripts/uninstall_rpm.sh",
},
"exe": {
"install": "./scripts/install_exe.ps1",
"remove": "./scripts/remove_exe.ps1",
@ -64,7 +67,7 @@ func assertGoldenMatches(t *testing.T, goldenFile string, actual string, update
t.Helper()
goldenPath := filepath.Join("testdata", goldenFile+".golden")
f, err := os.OpenFile(goldenPath, os.O_RDWR|os.O_CREATE, 0644)
f, err := os.OpenFile(goldenPath, os.O_RDWR|os.O_CREATE, 0o644)
require.NoError(t, err)
defer f.Close()

33
pkg/file/rpm.go Normal file
View file

@ -0,0 +1,33 @@
package file
import (
"crypto/sha256"
"fmt"
"io"
"github.com/cavaliergopher/rpm"
)
func ExtractRPMMetadata(r io.Reader) (*InstallerMetadata, error) {
h := sha256.New()
r = io.TeeReader(r, h)
// Read the package headers
pkg, err := rpm.Read(r)
if err != nil {
return nil, fmt.Errorf("read headers: %w", err)
}
// r is now positioned at the RPM payload.
// Ensure the whole file is read to get the correct hash
if _, err := io.Copy(io.Discard, r); err != nil {
return nil, fmt.Errorf("read all RPM content: %w", err)
}
return &InstallerMetadata{
Name: pkg.Name(),
Version: pkg.Version(),
SHASum: h.Sum(nil),
PackageIDs: []string{pkg.Name()},
}, nil
}

94
pkg/file/rpm_test.go Normal file
View file

@ -0,0 +1,94 @@
package file
import (
"crypto/sha256"
"io"
"os"
"path/filepath"
"testing"
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
"github.com/goreleaser/nfpm/v2"
"github.com/goreleaser/nfpm/v2/files"
"github.com/goreleaser/nfpm/v2/rpm"
"github.com/stretchr/testify/require"
)
func TestExtractRPMMetadata(t *testing.T) {
//
// Build an RPM package on the fly with nfpm.
//
tmpDir := t.TempDir()
err := os.WriteFile(filepath.Join(tmpDir, "foo.sh"), []byte("#!/bin/sh\n\necho \"Foo!\"\n"), constant.DefaultFileMode)
require.NoError(t, err)
contents := files.Contents{
&files.Content{
Source: filepath.Join(tmpDir, "**"),
Destination: "/",
},
}
postInstallPath := filepath.Join(t.TempDir(), "postinstall.sh")
err = os.WriteFile(postInstallPath, []byte("#!/bin/sh\n\necho \"Hello world!\"\n"), constant.DefaultFileMode)
require.NoError(t, err)
info := &nfpm.Info{
Name: "foobar",
Version: "1.2.3",
Description: "Foo bar",
Arch: "x86_64",
Maintainer: "Fleet Device Management",
Vendor: "Fleet Device Management",
License: "LICENSE",
Homepage: "https://example.com",
Overridables: nfpm.Overridables{
Contents: contents,
Scripts: nfpm.Scripts{
PostInstall: postInstallPath,
},
},
}
rpmPath := filepath.Join(t.TempDir(), "foobar.rpm")
out, err := os.OpenFile(rpmPath, os.O_CREATE|os.O_RDWR, constant.DefaultFileMode)
require.NoError(t, err)
t.Cleanup(func() {
out.Close()
})
err = rpm.Default.Package(info, out)
require.NoError(t, err)
err = out.Close()
require.NoError(t, err)
//
// Test ExtractRPMMetadata with the generated package.
// Using ExtractInstallerMetadata for broader testing (for a file
// with rpm extension it will call ExtractRPMMetadata).
//
f, err := os.Open(rpmPath)
require.NoError(t, err)
t.Cleanup(func() {
f.Close()
})
m, err := ExtractInstallerMetadata(f)
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
require.Empty(t, m.BundleIdentifier)
require.Equal(t, "rpm", m.Extension)
require.Equal(t, "foobar", m.Name)
require.Equal(t, []string{"foobar"}, m.PackageIDs)
require.Equal(t, sha256FilePath(t, rpmPath), m.SHASum)
require.Equal(t, "1.2.3", m.Version)
}
func sha256FilePath(t *testing.T, path string) []byte {
f, err := os.Open(path)
require.NoError(t, err)
t.Cleanup(func() {
f.Close()
})
h := sha256.New()
_, err = io.Copy(h, f)
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
return h.Sum(nil)
}

View file

@ -3,10 +3,15 @@
This folder contains scripts to install/remove software for different types of installers.
Scripts are stored on their own files for two reasons:
1. Some of them are read and displayed in the UI.
2. It's helpful to have good syntax highlighting and easy ways to run them.
#### Scripts
- `install_*.*`: Default installer scripts for each platform.
- `uninstall_*.*`: Default uinstaller scripts for each platform.
- `remove_*.*`: Uninstaller scripts used when the uninstall script is not set (for packages added before the uninstall feature was released) or empty uninstaller scripts.
#### Variables
The scripts in this folder accept variables like `$VAR_NAME` that will be replaced/populated by `fleetd` when they run.
@ -14,4 +19,3 @@ The scripts in this folder accept variables like `$VAR_NAME` that will be replac
Supported variables are:
- `$INSTALLER_PATH` path to the installer file.

View file

@ -0,0 +1,3 @@
#!/bin/sh
dnf install --assumeyes "$INSTALLER_PATH"

View file

@ -0,0 +1,6 @@
#!/bin/sh
package_name=$PACKAGE_ID
# Fleet uninstalls app using product name that's extracted on upload
dnf remove --assumeyes "$package_name"

View file

@ -0,0 +1,4 @@
package_name=$PACKAGE_ID
# Fleet uninstalls app using product name that's extracted on upload
dnf remove --assumeyes "$package_name"

View file

@ -0,0 +1,3 @@
#!/bin/sh
dnf install --assumeyes "$INSTALLER_PATH"

View file

@ -0,0 +1,6 @@
#!/bin/sh
package_name=$PACKAGE_ID
# Fleet uninstalls app using product name that's extracted on upload
dnf remove --assumeyes "$package_name"

View file

@ -0,0 +1,4 @@
package_name=$PACKAGE_ID
# Fleet uninstalls app using product name that's extracted on upload
dnf remove --assumeyes "$package_name"

View file

@ -557,6 +557,7 @@ SELECT
hsi.self_service,
hsi.host_deleted_at,
hsi.created_at as created_at,
hsi.updated_at as updated_at,
si.user_id AS software_installer_user_id,
si.user_name AS software_installer_user_name,
si.user_email AS software_installer_user_email

View file

@ -511,8 +511,18 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
beforeInstallRequest := time.Now()
installUUID, err := ds.InsertSoftwareInstallRequest(ctx, host.ID, installerID, false)
require.NoError(t, err)
res, err := ds.GetSoftwareInstallResults(ctx, installUUID)
require.NoError(t, err)
require.NotNil(t, res.UpdatedAt)
require.Less(t, beforeInstallRequest, res.CreatedAt)
createdAt := res.CreatedAt
require.Less(t, beforeInstallRequest, *res.UpdatedAt)
beforeInstallResult := time.Now()
err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: host.ID,
InstallUUID: installUUID,
@ -524,7 +534,7 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
res, err := ds.GetSoftwareInstallResults(ctx, installUUID)
res, err = ds.GetSoftwareInstallResults(ctx, installUUID)
require.NoError(t, err)
require.Equal(t, installUUID, res.InstallUUID)
@ -534,6 +544,10 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) {
require.Equal(t, tc.preInstallQueryOutput, res.PreInstallQueryOutput)
require.Equal(t, tc.postInstallScriptOutput, res.PostInstallScriptOutput)
require.Equal(t, tc.installScriptOutput, res.Output)
require.NotNil(t, res.CreatedAt)
require.Equal(t, createdAt, res.CreatedAt)
require.NotNil(t, res.UpdatedAt)
require.Less(t, beforeInstallResult, *res.UpdatedAt)
})
}
}

View file

@ -363,6 +363,8 @@ func SofwareInstallerSourceFromExtensionAndName(ext, name string) (string, error
switch ext {
case "deb":
return "deb_packages", nil
case "rpm":
return "rpm_packages", nil
case "exe", "msi":
return "programs", nil
case "pkg":
@ -378,7 +380,7 @@ func SofwareInstallerSourceFromExtensionAndName(ext, name string) (string, error
func SofwareInstallerPlatformFromExtension(ext string) (string, error) {
ext = strings.TrimPrefix(ext, ".")
switch ext {
case "deb":
case "deb", "rpm":
return "linux", nil
case "exe", "msi":
return "windows", nil

View file

@ -14404,3 +14404,74 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallersWithoutBundleIden
}
s.uploadSoftwareInstaller(payload, http.StatusOK, "")
}
func (s *integrationEnterpriseTestSuite) TestSoftwareUploadRPM() {
ctx := context.Background()
t := s.T()
// Fedora and RHEL have hosts.platform = 'rhel'.
host := createOrbitEnrolledHost(t, "rhel", "", s.ds)
// Upload an RPM package.
payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install script",
PreInstallQuery: "pre install query",
PostInstallScript: "post install script",
Filename: "ruby.rpm",
Title: "ruby",
}
s.uploadSoftwareInstaller(payload, http.StatusOK, "")
titleID := getSoftwareTitleID(t, s.ds, payload.Title, "rpm_packages")
latestInstallUUID := func() string {
var id string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &id, `SELECT execution_id FROM host_software_installs ORDER BY id DESC LIMIT 1`)
})
return id
}
// Send a request to the host to install the RPM package.
var installSoftwareResp installSoftwareResponse
beforeInstallRequest := time.Now()
s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusAccepted, &installSoftwareResp)
installUUID := latestInstallUUID()
// Simulate host installing the RPM package.
beforeInstallResult := time.Now()
s.Do("POST", "/api/fleet/orbit/software_install/result",
json.RawMessage(fmt.Sprintf(`{
"orbit_node_key": %q,
"install_uuid": %q,
"pre_install_condition_output": "1",
"install_script_exit_code": 1,
"install_script_output": "failed"
}`, *host.OrbitNodeKey, installUUID)),
http.StatusNoContent,
)
var resp getSoftwareInstallResultsResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/install/%s/results", installUUID), nil, http.StatusOK, &resp)
assert.Equal(t, host.ID, resp.Results.HostID)
assert.Equal(t, installUUID, resp.Results.InstallUUID)
assert.Equal(t, fleet.SoftwareInstallFailed, resp.Results.Status)
assert.NotNil(t, resp.Results.PreInstallQueryOutput)
assert.Equal(t, fleet.SoftwareInstallerQuerySuccessCopy, *resp.Results.PreInstallQueryOutput)
assert.NotNil(t, resp.Results.Output)
assert.Equal(t, fmt.Sprintf(fleet.SoftwareInstallerInstallFailCopy, "failed"), *resp.Results.Output)
assert.Empty(t, resp.Results.PostInstallScriptOutput)
assert.Less(t, beforeInstallRequest, resp.Results.CreatedAt)
assert.Greater(t, time.Now(), resp.Results.CreatedAt)
assert.NotNil(t, resp.Results.UpdatedAt)
assert.Less(t, beforeInstallResult, *resp.Results.UpdatedAt)
wantAct := fleet.ActivityTypeInstalledSoftware{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
InstallUUID: installUUID,
Status: string(fleet.SoftwareInstallFailed),
}
s.lastActivityMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0)
}

View file

@ -1,3 +1,4 @@
# testdata
- `fleet-osquery.msi` is a dummy MSI installer created by `packaging.BuildMSI` with a fake `orbit.exe` that just has `hello world` in it. Its software title is `Fleet osquery` and its version is `1.0.0`.
- `fleet-osquery.msi` is a dummy MSI installer created by `packaging.BuildMSI` with a fake `orbit.exe` that just has `hello world` in it. Its software title is `Fleet osquery` and its version is `1.0.0`.
- `ruby.rpm` was downloaded from https://rpmfind.net/linux/fedora/linux/development/rawhide/Everything/x86_64/os/Packages/r/ruby-3.3.5-15.fc42.x86_64.rpm.

Binary file not shown.