plugin/step/install/windows/msi/msi_windows.go (240 lines of code) (raw):
// +build windows
package msi
/*
Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved
*/
import (
"context"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/facebookincubator/go2chef/util/temp"
"github.com/facebookincubator/go2chef/util"
"github.com/mitchellh/mapstructure"
"github.com/facebookincubator/go2chef"
"golang.org/x/sys/windows/registry"
"golang.org/x/text/encoding/unicode"
)
// TypeName is the name of this plugin
const TypeName = "go2chef.step.install.windows.msi"
var (
// DefaultPackageName sets the default package name for MSI matchign
DefaultPackageName = "chef"
)
// Step implements Chef installation via Windows MSI
type Step struct {
StepName string `mapstructure:"name"`
MSIMatch string `mapstructure:"msi_match"`
ProgramMatch string `mapstructure:"program_match"`
ExitCode []int `mapstructure:"exit_code"`
MSIEXECTimeoutSeconds int `mapstructure:"msiexec_timeout_seconds"`
RenameFolder bool `mapstructure:"rename_folder"`
Uninstall bool `mapstructure:"uninstall"`
logger go2chef.Logger
source go2chef.Source
downloadPath string
}
func shellOut(s *Step, cm string, args []string) (err error) {
done := make(chan error)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(s.MSIEXECTimeoutSeconds))
defer cancel()
go func() {
cmd := exec.CommandContext(ctx, cm, args...)
s.logger.Debugf(1, "%v: %v %v", s.Name(), cm, strings.Join(args, " "))
done <- cmd.Run()
}()
select {
case err = <-done:
case <-ctx.Done():
return fmt.Errorf("%s timed out", s.Name())
}
return err
}
func (s *Step) String() string { return "<" + TypeName + ":" + s.StepName + ">" }
// SetName sets the name of this step instance
func (s *Step) SetName(str string) { s.StepName = str }
// Name gets the name of this step instance
func (s *Step) Name() string { return s.StepName }
// Type returns the type of this step instance
func (s *Step) Type() string { return TypeName }
// Download fetches resources required for this step's execution
func (s *Step) Download() error {
if s.source == nil {
return nil
}
tmpdir, err := temp.Dir("", "go2chef-install")
if err != nil {
return err
}
if err := s.source.DownloadToPath(tmpdir); err != nil {
return err
}
s.downloadPath = tmpdir
return nil
}
// Execute performs the installation
func (s *Step) Execute() (err error) {
if s.Uninstall {
if err = s.uninstallChef(s.MSIEXECTimeoutSeconds); err != nil {
s.logger.Debugf(1, "%s", err)
return err
}
}
if s.RenameFolder {
if err = s.renameFolder(s.MSIEXECTimeoutSeconds); err != nil {
s.logger.Debugf(1, "%s", err)
return err
}
}
return s.installChef()
}
func (s *Step) installChef() error {
msi, err := s.findMSI()
if err != nil {
return err
}
// create a logfile for MSIEXEC
logfile, err := temp.File("", "")
if err != nil {
return err
}
_ = logfile.Close()
if err := shellOut(s, "msiexec", []string{"/qn", "/i", filepath.Join(s.downloadPath, msi), "/L*V", logfile.Name()}); err != nil {
// preserve exit error
xerr := err
expectedExitCode := false
if exit, ok := xerr.(*exec.ExitError); ok {
for _, c := range s.ExitCode {
if exit.ExitCode() == c {
expectedExitCode = true
break
}
}
if !expectedExitCode {
s.logger.Errorf("MSIEXEC exited with code %d", exit.ExitCode())
}
}
if !expectedExitCode {
// pull logs
log, err := ioutil.ReadFile(logfile.Name())
if err != nil {
return err
}
// msiexec writes logs in UTF16-LE which outputs extra spaces. Convert it
// to UTF8 for more readable output.
decoder := unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder()
utf8Log, err := decoder.Bytes(log)
if err != nil {
s.logger.Errorf("UNPRETTY MSIEXEC logs: %s", string(log))
} else {
s.logger.Errorf("MSIEXEC logs: %s", string(utf8Log))
}
return xerr
}
}
return nil
}
// Loader provides an instantiation function for this step plugin
func Loader(config map[string]interface{}) (go2chef.Step, error) {
step := &Step{
StepName: "",
ProgramMatch: "Chef Infra Client",
MSIMatch: "chef-client.*\\.msi",
MSIEXECTimeoutSeconds: 300,
ExitCode: []int{0, 3010},
logger: go2chef.GetGlobalLogger(),
source: nil,
downloadPath: "",
}
if err := mapstructure.Decode(config, step); err != nil {
return nil, err
}
source, err := go2chef.GetSourceFromStepConfig(config)
if err != nil {
return nil, err
}
step.source = source
return step, nil
}
func init() {
if go2chef.AutoRegisterPlugins {
go2chef.RegisterStep(TypeName, Loader)
}
}
func (s *Step) findMSI() (string, error) {
re, err := regexp.Compile(s.MSIMatch)
if err != nil {
return "", err
}
return util.MatchPath(s.downloadPath, re)
}
// The MSI of the installation is recorded in the registry. We can use this
// information to check if the desired version of Chef is already installed.
type chefInstallInfo struct {
Path string
UninstallGUID string
Version string
Installed bool
}
const (
installedProducts = `SOFTWARE\Classes\Installer\Products`
registryReadPermissions = registry.QUERY_VALUE | registry.READ
)
// Scans the registry for installed products. It will find a product name that
// matches a regex which contains enough information about what is installed.
// The resulting struct can be used to uninstall the old client, if desired, or
// make a judgement call of if a new versions has to be installed.
func (s *Step) testChefInstalled() (*chefInstallInfo, error) {
re := regexp.MustCompile(`Chef (Infra ){0,1}Client v([\d\.]+)\s*`)
k, err := registry.OpenKey(registry.LOCAL_MACHINE, installedProducts, registryReadPermissions)
if err != nil {
s.logger.Errorf("%s", err)
return &chefInstallInfo{Installed: false}, err
}
defer k.Close()
ks, err := k.Stat()
if err != nil {
s.logger.Errorf("%s", err)
return &chefInstallInfo{Installed: false}, err
}
kn, err := k.ReadSubKeyNames(int(ks.SubKeyCount))
if err != nil {
s.logger.Errorf("%s", err)
return &chefInstallInfo{Installed: false}, err
}
for _, s := range kn {
searchKey := strings.Join([]string{installedProducts, s}, `\`)
searchSubKey, err := registry.OpenKey(registry.LOCAL_MACHINE, searchKey, registryReadPermissions)
if err != nil {
continue
}
defer searchSubKey.Close()
pn, _, err := searchSubKey.GetStringValue("ProductName")
if err != nil {
continue
}
if re.MatchString(pn) {
result := &chefInstallInfo{
Installed: true,
Path: searchKey,
}
// This contains a path on disk to the product icon.
// From here we can infer the application's GUID.
// Too bad this information doesn't appear to be stored directly in the
// registry =(
pi, _, err := searchSubKey.GetStringValue("ProductIcon")
if err == nil {
for _, c := range strings.Split(pi, `\`) {
if strings.Contains(c, `{`) {
result.UninstallGUID = c
break
}
}
}
verMatch := re.FindAllStringSubmatch(pn, -1)
if len(verMatch) > 0 && len(verMatch[0]) > 2 {
result.Version = verMatch[0][2]
}
return result, nil
}
}
return &chefInstallInfo{Installed: false}, nil
}
/*
Sometimes there is a file within the Chef directory that has a lock on a file.
The installer will fail to remove this file. In this case when an installation
attempt is made it could fail to finish and then the new client won't be installed.
Congratulations!
Now your node is in an inconsistent state! If you're relying on
Chef to recover from this it is fairly challenging since the application won't run
and yet Windows will still think it's installed correctly.
Instead of relying on the MSI installation/upgrade to work correctly (it hasn't
since the early versions of Chef 12), move the old installation directory out
of the way. The installation will now be able to successfully complete since
there are no locked files to contend with!
*/
func (s *Step) renameFolder(timeout int) (err error) {
const (
chefInstallDir = `C:\opscode\chef`
recycleBin = `C:\$Recycle.Bin`
)
if info, err := os.Stat(chefInstallDir); info == nil {
if os.IsNotExist(err) {
s.logger.Debugf(1, "no chef remnants found")
}
return nil
}
var trash string
if trash, err = ioutil.TempDir(recycleBin, "go2chef"); err != nil {
return fmt.Errorf("could not create temporary directory: %s", err)
}
// I have no idea why os.Rename always throws access denied. This, however,
// works just fine.
if err := exec.Command("cmd", "/c", "move", "/Y", chefInstallDir, trash).Run(); err != nil {
return err
}
return nil
}
// Use the information collected from the registry to uninstall the client. Skip running the command if chef is already uninstalled.
func (s *Step) uninstallChef(timeout int) error {
var (
chefInfo *chefInstallInfo
err error
)
if chefInfo, err = s.testChefInstalled(); err != nil {
return err
}
if chefInfo.UninstallGUID == "" {
s.logger.Debugf(1, "no uninstall guid found")
return nil
}
if !chefInfo.Installed {
s.logger.Debugf(1, "no chef install found")
return nil
}
return shellOut(s, "msiexec", []string{"/qn", "/x", chefInfo.UninstallGUID})
}