internal/ssm/install.go (190 lines of code) (raw):
package ssm
import (
"bytes"
"context"
stdErrors "errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/ProtonMail/gopenpgp/v3/crypto"
"github.com/pkg/errors"
"go.uber.org/zap"
"github.com/aws/eks-hybrid/internal/artifact"
"github.com/aws/eks-hybrid/internal/tracker"
"github.com/aws/eks-hybrid/internal/util"
"github.com/aws/eks-hybrid/internal/util/cmd"
)
const (
defaultInstallerPath = "/opt/ssm/ssm-setup-cli"
configRoot = "/etc/amazon"
artifactName = "ssm"
rootDir = "/root"
gpgConfigDirName = ".gnupg"
gpgConfigFileName = "gpg.conf"
gpgConfigFilePerms = 0o755
)
// Source serves an SSM installer binary for the target platform.
type Source interface {
GetSSMInstaller(ctx context.Context) (io.ReadCloser, error)
GetSSMInstallerSignature(ctx context.Context) (io.ReadCloser, error)
PublicKey() string
}
// PkgSource serves and defines the package for target platform
type PkgSource interface {
GetSSMPackage() artifact.Package
}
type InstallOptions struct {
Tracker *tracker.Tracker
Source Source
Logger *zap.Logger
Region string
InstallRoot string
}
func Install(ctx context.Context, opts InstallOptions) error {
if err := installFromSource(ctx, opts); err != nil {
return err
}
return opts.Tracker.Add(artifact.Ssm)
}
func installFromSource(ctx context.Context, opts InstallOptions) error {
installerPath := filepath.Join(opts.InstallRoot, defaultInstallerPath)
if err := writeGpgConfig(); err != nil {
return errors.Wrapf(err, "writing gpg config file")
}
if err := downloadFileWithRetries(ctx, opts.Source, opts.Logger, installerPath); err != nil {
return errors.Wrap(err, "failed to install ssm installer")
}
if err := runInstallWithRetries(ctx, installerPath, opts.Region); err != nil {
return errors.Wrapf(err, "failed to install ssm agent")
}
return nil
}
func Upgrade(ctx context.Context, opts InstallOptions) error {
if err := installFromSource(ctx, opts); err != nil {
return err
}
opts.Logger.Info("Upgraded", zap.String("artifact", artifactName))
return nil
}
func downloadFileWithRetries(ctx context.Context, source Source, logger *zap.Logger, installerPath string) error {
// Retry up to 3 times to download and validate the signature of
// the SSM setup cli.
var err error
for range 3 {
err = downloadFileTo(ctx, source, installerPath)
if err == nil {
break
}
logger.Error("Downloading ssm-setup-cli failed. Retrying...", zap.Error(err))
}
return err
}
// Update other functions that use InstallerPath to use the parameter instead
func downloadFileTo(ctx context.Context, source Source, installerPath string) error {
installer, err := source.GetSSMInstaller(ctx)
if err != nil {
return fmt.Errorf("getting ssm-setup-cli: %w", err)
}
defer installer.Close()
signature, err := source.GetSSMInstallerSignature(ctx)
if err != nil {
return fmt.Errorf("getting ssm-setup-cli signature: %w", err)
}
defer signature.Close()
var installerBuffer bytes.Buffer
installerTee := io.TeeReader(installer, &installerBuffer)
if err := validateSetupSignature(installerTee, signature, source.PublicKey()); err != nil {
return fmt.Errorf("validating ssm-setup-cli signature: %w", err)
}
if err := artifact.InstallFile(installerPath, bytes.NewReader(installerBuffer.Bytes()), 0o755); err != nil {
return fmt.Errorf("installing ssm-setup-cli: %w", err)
}
return nil
}
func validateSetupSignature(installer, signature io.Reader, publicKey string) error {
verificationKey, err := crypto.NewKeyFromArmored(publicKey)
if err != nil {
return err
}
pgp := crypto.PGP()
verifier, _ := pgp.Verify().
VerificationKey(verificationKey).
New()
verifyDataReader, err := verifier.VerifyingReader(installer, signature, crypto.Bytes)
if err != nil {
return err
}
verifyResult, err := verifyDataReader.ReadAllAndVerifySignature()
if err != nil {
return err
}
if err := verifyResult.SignatureError(); err != nil {
return err
}
return nil
}
type UninstallOptions struct {
Logger *zap.Logger
// InstallRoot is optionally the root directory of the installation
// If not provided, the default will be /
InstallRoot string
SSMRegistration *SSMRegistration
SSMClient SSMClient
PkgSource PkgSource
}
// Uninstall de-registers the managed instance and removes all files and components that
// make up the ssm agent component.
func Uninstall(ctx context.Context, opts UninstallOptions) error {
opts.Logger.Info("Uninstalling SSM agent...")
actions := []func() error{
func() error {
return Deregister(ctx, opts.SSMRegistration, opts.SSMClient, opts.Logger)
},
func() error {
return removeFileOrDir(opts.SSMRegistration.RegistrationFilePath(), "uninstalling ssm registration file")
},
func() error {
return uninstallPreRegisterComponents(ctx, opts.PkgSource)
},
func() error {
return removeFileOrDir(filepath.Join(opts.InstallRoot, configRoot), "uninstalling ssm config files")
},
func() error {
return removeFileOrDir(filepath.Join(opts.InstallRoot, symlinkedAWSConfigPath), "uninstalling ssm aws config symlink")
},
func() error {
return removeFileOrDir(filepath.Join(opts.InstallRoot, defaultAWSConfigPath), "uninstalling ssm aws config")
},
}
allErrors := []error{}
for _, action := range actions {
if err := action(); err != nil {
allErrors = append(allErrors, err)
}
}
if len(allErrors) > 0 {
return stdErrors.Join(allErrors...)
}
return nil
}
func removeFileOrDir(path, errorMessage string) error {
if err := os.RemoveAll(path); err != nil {
return errors.Wrap(err, errorMessage)
}
return nil
}
func writeGpgConfig() error {
// In some environments, HOME will not be defined like while running cloud-init
homeDir, set := os.LookupEnv("HOME")
if !set {
homeDir = rootDir
}
gpgConfigFile := filepath.Join(homeDir, gpgConfigDirName, gpgConfigFileName)
return util.WriteFileUniqueLine(gpgConfigFile, []byte("no-tty"), gpgConfigFilePerms)
}
func uninstallPreRegisterComponents(ctx context.Context, pkgSource PkgSource) error {
ssmPkg := pkgSource.GetSSMPackage()
if err := cmd.Retry(ctx, ssmPkg.UninstallCmd, 5*time.Second); err != nil {
return errors.Wrapf(err, "uninstalling ssm")
}
return os.RemoveAll(defaultInstallerPath)
}
func runInstallWithRetries(ctx context.Context, installerPath, region string) error {
// Sometimes install fails due to conflicts with other processes
// updating packages, specially when automating at machine startup.
// We assume errors are transient and just retry for a bit.
installCmdBuilder := func(ctx context.Context) *exec.Cmd {
return exec.CommandContext(ctx, installerPath, "-install", "-region", region, "-version", "latest")
}
return cmd.Retry(ctx, installCmdBuilder, 5*time.Second)
}