internal/pkg/agent/install/install.go (415 lines of code) (raw):
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License 2.0;
// you may not use this file except in compliance with the Elastic License 2.0.
package install
import (
goerrors "errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/jaypipes/ghw"
"github.com/kardianos/service"
"github.com/otiai10/copy"
"github.com/schollz/progressbar/v3"
"github.com/elastic/elastic-agent-libs/logp"
"github.com/elastic/elastic-agent/internal/pkg/agent/application/paths"
"github.com/elastic/elastic-agent/internal/pkg/agent/errors"
"github.com/elastic/elastic-agent/internal/pkg/agent/perms"
"github.com/elastic/elastic-agent/internal/pkg/cli"
v1 "github.com/elastic/elastic-agent/pkg/api/v1"
"github.com/elastic/elastic-agent/pkg/utils"
)
const (
darwin = "darwin"
ElasticUsername = "elastic-agent-user"
ElasticGroupName = "elastic-agent"
// DefaultStopTimeout is the default stop timeout that can be used to stop a running daemon.
DefaultStopTimeout = 30 * time.Second
// DefaultStopInterval is the check interval to determine if the service has stopped.
DefaultStopInterval = 250 * time.Millisecond
)
// Install installs Elastic Agent persistently on the system including creating and starting its service.
func Install(cfgFile, topPath string, unprivileged bool, log *logp.Logger, pt *progressbar.ProgressBar, streams *cli.IOStreams, customUser, customGroup, userPassword string, flavor string) (utils.FileOwner, error) {
dir, err := findDirectory()
if err != nil {
return utils.FileOwner{}, errors.New(err, "failed to discover the source directory for installation", errors.TypeFilesystem)
}
var ownership utils.FileOwner
username := ""
groupName := ""
password := ""
if unprivileged {
username, password = UnprivilegedUser(customUser, userPassword)
groupName = UnprivilegedGroup(customGroup)
ownership, err = EnsureUserAndGroup(username, groupName, pt, username == ElasticUsername && password == "") // force create only elastic user
if err != nil {
// error context already added by EnsureUserAndGroup
return utils.FileOwner{}, err
}
}
err = setupInstallPath(topPath, ownership)
if err != nil {
return utils.FileOwner{}, fmt.Errorf("error setting up install path: %w", err)
}
manifest, err := readPackageManifest(dir)
if err != nil {
return utils.FileOwner{}, fmt.Errorf("reading package manifest: %w", err)
}
pathMappings := manifest.Package.PathMappings
pt.Describe("Copying install files")
copyConcurrency := calculateCopyConcurrency(streams)
skipFn := func(relPath string) bool { return false }
if flavor != "" {
flavorDefinition, err := Flavor(flavor, "", manifest.Package.Flavors)
if err != nil {
return utils.FileOwner{}, err
}
skipFn, err = SkipComponentsPathFn(paths.VersionedHome(dir), flavorDefinition)
if err != nil {
return utils.FileOwner{}, err
}
}
err = copyFiles(copyConcurrency, pathMappings, dir, topPath, skipFn)
if err != nil {
pt.Describe("Error copying files")
return utils.FileOwner{}, err
}
if err := markFlavor(topPath, flavor); err != nil {
return utils.FileOwner{}, fmt.Errorf("failed marking flavor %q at %q: %w", flavor, topPath, err)
}
pt.Describe("Successfully copied files")
// place shell wrapper, if present on platform
if paths.ShellWrapperPath() != "" {
pathDir := filepath.Dir(paths.ShellWrapperPath())
err = os.MkdirAll(pathDir, 0755)
if err != nil {
return utils.FileOwner{}, errors.New(
err,
fmt.Sprintf("failed to create directory (%s) for shell wrapper (%s)", pathDir, paths.ShellWrapperPath()),
errors.M("directory", pathDir))
}
// Install symlink for darwin instead of the wrapper script.
// Elastic-agent should be first process that launchd starts in order to be able to grant
// the Full-Disk Access (FDA) to the agent and it's child processes.
// This is specifically important for osquery FDA permissions at the moment.
if runtime.GOOS == darwin {
// Check if previous shell wrapper or symlink exists and remove it so it can be overwritten
if _, err := os.Lstat(paths.ShellWrapperPath()); err == nil {
if err := os.Remove(paths.ShellWrapperPath()); err != nil {
return utils.FileOwner{}, errors.New(
err,
fmt.Sprintf("failed to remove (%s)", paths.ShellWrapperPath()),
errors.M("destination", paths.ShellWrapperPath()))
}
}
err = os.Symlink(filepath.Join(topPath, paths.BinaryName), paths.ShellWrapperPath())
if err != nil {
return utils.FileOwner{}, errors.New(
err,
fmt.Sprintf("failed to create elastic-agent symlink (%s)", paths.ShellWrapperPath()),
errors.M("destination", paths.ShellWrapperPath()))
}
} else {
// We use strings.Replace instead of fmt.Sprintf here because, with the
// latter, govet throws a false positive error here: "fmt.Sprintf call has
// arguments but no formatting directives".
shellWrapper := strings.Replace(paths.ShellWrapperFmt, "%s", topPath, -1)
err = os.WriteFile(paths.ShellWrapperPath(), []byte(shellWrapper), 0755)
if err != nil {
return utils.FileOwner{}, errors.New(
err,
fmt.Sprintf("failed to write shell wrapper (%s)", paths.ShellWrapperPath()),
errors.M("destination", paths.ShellWrapperPath()))
}
}
}
// post install (per platform)
err = postInstall(topPath)
if err != nil {
return ownership, fmt.Errorf("error running post-install steps: %w", err)
}
// fix permissions
err = perms.FixPermissions(topPath, perms.WithOwnership(ownership))
if err != nil {
return ownership, fmt.Errorf("failed to perform permission changes on path %s: %w", topPath, err)
}
if paths.ShellWrapperPath() != "" {
err = perms.FixPermissions(paths.ShellWrapperPath(), perms.WithOwnership(ownership))
if err != nil {
return ownership, fmt.Errorf("failed to perform permission changes on path %s: %w", paths.ShellWrapperPath(), err)
}
}
// install service
pt.Describe("Installing service")
// ensure that service is removed
err = EnsureServiceRemoved(30*time.Second, 250*time.Millisecond, paths.ServiceName())
if err != nil {
pt.Describe(fmt.Sprintf("Failed to ensure service does not exist: %s", err.Error()))
}
// install service
err = InstallService(topPath, ownership, username, groupName, password)
if err != nil {
pt.Describe("Failed to install service")
// error context already added by InstallService
return ownership, err
}
pt.Describe("Installed service")
return ownership, nil
}
// setup the basic topPath, and the .installed file
func setupInstallPath(topPath string, ownership utils.FileOwner) error {
// ensure parent directory exists
err := os.MkdirAll(filepath.Dir(topPath), 0755)
if err != nil {
return errors.New(err, fmt.Sprintf("failed to create installation parent directory (%s)", filepath.Dir(topPath)), errors.M("directory", filepath.Dir(topPath)))
}
// create Agent/ directory with more locked-down permissions
err = os.MkdirAll(topPath, 0750)
if err != nil {
return errors.New(err, fmt.Sprintf("failed to create top path (%s)", topPath), errors.M("directory", topPath))
}
// create the install marker
if err := CreateInstallMarker(topPath, ownership); err != nil {
return fmt.Errorf("failed to create install marker: %w", err)
}
return nil
}
func readPackageManifest(extractedPackageDir string) (*v1.PackageManifest, error) {
manifestFilePath := filepath.Join(extractedPackageDir, v1.ManifestFileName)
manifestFile, err := os.Open(manifestFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open package manifest file (%s): %w", manifestFilePath, err)
}
defer manifestFile.Close()
manifest, err := v1.ParseManifest(manifestFile)
if err != nil {
return nil, fmt.Errorf("failed to parse package manifest file %q contents: %w", manifestFilePath, err)
}
return manifest, nil
}
func calculateCopyConcurrency(streams *cli.IOStreams) int {
// Try to detect if we are running with SSDs. If we are increase the copy concurrency,
// otherwise fall back to the default.
copyConcurrency := 1
hasSSDs, detectHWErr := HasAllSSDs()
if detectHWErr != nil {
fmt.Fprintf(streams.Out, "Could not determine block hardware type, disabling copy concurrency: %s\n", detectHWErr)
}
if hasSSDs {
copyConcurrency = runtime.NumCPU() * 4
}
return copyConcurrency
}
func copyFiles(copyConcurrency int, pathMappings []map[string]string, srcDir string, topPath string, skipFn func(string) bool) error {
// copy source into install path
// these are needed to keep track of what we already copied
copiedFiles := map[string]struct{}{}
// collect any symlink we found that need remapping
symlinks := map[string]string{}
var copyErrors []error
// Start copying the remapped paths first
for _, pathMapping := range pathMappings {
for packagePath, installedPath := range pathMapping {
// flag the original path as handled
copiedFiles[packagePath] = struct{}{}
srcPath := filepath.Join(srcDir, packagePath)
dstPath := filepath.Join(topPath, installedPath)
err := copy.Copy(srcPath, dstPath, copy.Options{
OnSymlink: func(_ string) copy.SymlinkAction {
return copy.Shallow
},
Skip: func(srcinfo os.FileInfo, src, dest string) (bool, error) {
relPath, err := filepath.Rel(srcDir, src)
if err != nil {
return false, fmt.Errorf("calculating relative path for %s: %w", src, err)
}
if skipFn != nil && skipFn(relPath) {
return true, nil
}
return false, nil
},
Sync: true,
NumOfWorkers: int64(copyConcurrency),
})
if err != nil {
return errors.New(
err,
fmt.Sprintf("failed to copy source directory (%s) to destination (%s)", packagePath, installedPath),
errors.M("source", packagePath), errors.M("destination", installedPath),
)
}
}
}
// copy the remaining files excluding overlaps with the mapped paths
err := copy.Copy(srcDir, topPath, copy.Options{
OnSymlink: func(source string) copy.SymlinkAction {
target, err := os.Readlink(source)
if err != nil {
// error reading the link, not much choice to leave it unchanged and collect the error
copyErrors = append(copyErrors, fmt.Errorf("unable to read link %q for remapping", source))
return copy.Skip
}
// if we find a link, check if its target need to be remapped, in which case skip it for now and save it for
// later creation with the remapped target
for _, pathMapping := range pathMappings {
for srcPath, dstPath := range pathMapping {
srcPathLocal := filepath.FromSlash(srcPath)
dstPathLocal := filepath.FromSlash(dstPath)
if strings.HasPrefix(target, srcPathLocal) {
newTarget := strings.Replace(target, srcPathLocal, dstPathLocal, 1)
rel, err := filepath.Rel(srcDir, source)
if err != nil {
copyErrors = append(copyErrors, fmt.Errorf("extracting relative path for %q using %q as base: %w", source, srcDir, err))
return copy.Skip
}
symlinks[rel] = newTarget
return copy.Skip
}
}
}
return copy.Shallow
},
Skip: func(srcinfo os.FileInfo, src, dest string) (bool, error) {
relPath, err := filepath.Rel(srcDir, src)
if err != nil {
return false, fmt.Errorf("calculating relative path for %s: %w", src, err)
}
if skipFn != nil && skipFn(relPath) {
return true, nil
}
// check if we already handled this path as part of the mappings: if we did, skip it
relPath = filepath.ToSlash(relPath)
_, ok := copiedFiles[relPath]
return ok, nil
},
Sync: true,
NumOfWorkers: int64(copyConcurrency),
})
if err != nil {
return errors.New(
err,
fmt.Sprintf("failed to copy source directory (%s) to destination (%s)", srcDir, topPath),
errors.M("source", srcDir), errors.M("destination", topPath),
)
}
if len(copyErrors) > 0 {
return fmt.Errorf("errors encountered during copy from %q to %q: %w", srcDir, topPath, goerrors.Join(copyErrors...))
}
// Create the remapped symlinks
for src, target := range symlinks {
absSrcPath := filepath.Join(topPath, src)
err := os.Symlink(target, absSrcPath)
if err != nil {
return errors.New(
err,
fmt.Sprintf("failed to link source %q to destination %q", absSrcPath, target),
)
}
}
return nil
}
// StartService starts the installed service.
//
// This should only be called after Install is successful.
func StartService(topPath string) error {
// only starting the service, so no need to set the username and group to any value
svc, err := newService(topPath)
if err != nil {
return fmt.Errorf("error creating new service handler for start: %w", err)
}
err = svc.Start()
if err != nil {
return fmt.Errorf("failed to start service (%s): %w", paths.ServiceName(), err)
}
return nil
}
// StopService stops the installed service.
func StopService(topPath string, timeout time.Duration, interval time.Duration) error {
// only stopping the service, so no need to set the username and group to any value
svc, err := newService(topPath)
if err != nil {
return fmt.Errorf("error creating new service handler for stop: %w", err)
}
err = svc.Stop()
if err != nil {
return fmt.Errorf("failed to stop service (%s): %w", paths.ServiceName(), err)
}
err = isStopped(timeout, interval, paths.ServiceName())
if err != nil {
return fmt.Errorf("failed to stop service (%s): %w", paths.ServiceName(), err)
}
return nil
}
// RestartService restarts the installed service.
func RestartService(topPath string) error {
// only restarting the service, so no need to set the username and group to any value
svc, err := newService(topPath)
if err != nil {
return fmt.Errorf("error creating new service handler for restart: %w", err)
}
err = svc.Restart()
if err != nil {
return fmt.Errorf("failed to restart service (%s): %w", paths.ServiceName(), err)
}
return nil
}
// StatusService returns the status of the service.
func StatusService(topPath string) (service.Status, error) {
svc, err := newService(topPath)
if err != nil {
return service.StatusUnknown, fmt.Errorf("error creating new service handler for status: %w", err)
}
return svc.Status()
}
// InstallService installs the service.
func InstallService(topPath string, ownership utils.FileOwner, username string, groupName string, password string) error {
opts, err := withServiceOptions(username, groupName, password)
if err != nil {
return fmt.Errorf("error getting service installation options: %w", err)
}
svc, err := newService(topPath, opts...)
if err != nil {
return fmt.Errorf("error creating new service handler for install: %w", err)
}
err = svc.Install()
if err != nil {
return fmt.Errorf("failed to install service (%s): %w", paths.ServiceName(), err)
}
err = serviceConfigure(ownership)
if err != nil {
// ignore error
_ = svc.Uninstall()
return fmt.Errorf("failed to configure service (%s): %w", paths.ServiceName(), err)
}
return nil
}
// UninstallService uninstalls the service.
func UninstallService(topPath string) error {
svc, err := newService(topPath)
if err != nil {
return fmt.Errorf("error creating new service handler for uninstall: %w", err)
}
err = svc.Uninstall()
if err != nil {
return fmt.Errorf("failed to uninstall service (%s): %w", paths.ServiceName(), err)
}
return nil
}
// findDirectory returns the directory to copy into the installation location.
//
// This also verifies that the discovered directory is a valid directory for installation.
func findDirectory() (string, error) {
execPath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("error fetching executable of current process: %w", err)
}
execPath, err = filepath.Abs(execPath)
if err != nil {
return "", fmt.Errorf("error fetching absolute file path: %w", err)
}
sourceDir := paths.ExecDir(filepath.Dir(execPath))
err = verifyDirectory(sourceDir)
if err != nil {
return "", fmt.Errorf("error verifying directory: %w", err)
}
return sourceDir, nil
}
// verifyDirectory ensures that the directory includes the executable.
func verifyDirectory(dir string) error {
_, err := os.Stat(filepath.Join(dir, paths.BinaryName))
if os.IsNotExist(err) {
return fmt.Errorf("missing %s", paths.BinaryName)
}
return nil
}
// HasAllSSDs returns true if the host we are on uses SSDs for
// all its persistent storage; false otherwise. Returns any error
// encountered detecting the hardware type for informational purposes.
// Errors from this function are not fatal. Note that errors may be
// returned on some Mac hardware configurations as the ghw package
// does not fully support MacOS.
func HasAllSSDs() (bool, error) {
block, err := ghw.Block()
if err != nil {
return false, err
}
return hasAllSSDs(*block), nil
}
// Internal version of HasAllSSDs for testing.
func hasAllSSDs(block ghw.BlockInfo) bool {
for _, disk := range block.Disks {
switch disk.DriveType {
case ghw.DRIVE_TYPE_FDD, ghw.DRIVE_TYPE_ODD:
// Floppy or optical drive; we don't care about these
continue
case ghw.DRIVE_TYPE_SSD:
// SSDs
continue
case ghw.DRIVE_TYPE_HDD:
// HDD (spinning hard disk)
return false
default:
return false
}
}
return true
}
// CreateInstallMarker creates a `.installed` file at the given install path,
// and then calls fixInstallMarkerPermissions to set the ownership provided by `ownership`
func CreateInstallMarker(topPath string, ownership utils.FileOwner) error {
markerFilePath := filepath.Join(topPath, paths.MarkerFileName)
handle, err := os.Create(markerFilePath)
if err != nil {
return err
}
_ = handle.Close()
return fixInstallMarkerPermissions(markerFilePath, ownership)
}
func UnprivilegedUser(username, password string) (string, string) {
if username != "" {
return username, password
}
return ElasticUsername, password
}
func UnprivilegedGroup(groupName string) string {
if groupName != "" {
return groupName
}
return ElasticGroupName
}