// Package bundler helps to run external commands with Bundler if the // environment indicates that Bundler should be used. package bundler import ( "os/exec" "github.com/mlafeldt/chef-runner/util" ) func useBundler() bool { if _, err := exec.LookPath("bundle"); err != nil { // Bundler not installed return false } if !util.FileExist("Gemfile") { // No Gemfile found return false } return true } // Command prepends `bundle exec` to the passed command if the environment // indicates that Bundler should be used. func Command(args []string) []string { if !useBundler() { return args } return append([]string{"bundle", "exec"}, args...) }
// Package cookbook reads and manipulates data from Chef cookbooks stored on // disk. package cookbook import ( "io/ioutil" "os" "path" "github.com/mlafeldt/chef-runner/chef/cookbook/metadata" "github.com/mlafeldt/chef-runner/util" ) // A Cookbook is a Chef cookbook stored on disk. type Cookbook struct { Path string Name string Version string } // NewCookbook returns a Cookbook that is located at cookbookPath. func NewCookbook(cookbookPath string) (*Cookbook, error) { cb := Cookbook{Path: cookbookPath} metadataPath := path.Join(cookbookPath, metadata.Filename) if util.FileExist(metadataPath) { metadata, err := metadata.ParseFile(metadataPath) if err != nil { return nil, err } cb.Name = metadata.Name cb.Version = metadata.Version } return &cb, nil } // String returns the cookbook's name and version. func (cb Cookbook) String() string { // TODO: check if fields are actually set return cb.Name + " " + cb.Version } // Files returns the names of all cookbook files. Other files are ignored. func (cb Cookbook) Files() []string { fileList := [...]string{ "README.md", "metadata.json", "metadata.rb", "attributes", "definitions", "files", "libraries", "providers", "recipes", "resources", "templates", } var files []string for _, f := range fileList { name := path.Join(cb.Path, f) if util.FileExist(name) { files = append(files, name) } } return files } // Strip removes all non-cookbook files from the cookbook. func (cb Cookbook) Strip() error { cbFiles := make(map[string]bool) for _, f := range cb.Files() { cbFiles[f] = true } files, err := ioutil.ReadDir(cb.Path) if err != nil { return err } for _, f := range files { name := path.Join(cb.Path, f.Name()) if _, keep := cbFiles[name]; !keep { if err := os.RemoveAll(name); err != nil { return err } } } return nil }
// Package metadata parses Chef cookbook metadata. It can currently retrieve // the cookbook's name and version. package metadata import ( "bufio" "io" "os" "regexp" "strings" ) // Filename is the name of the cookbook file that stores metadata. const Filename = "metadata.rb" // Metadata stores metadata about a cookbook. type Metadata struct { Name string Version string } // Parse parses cookbook metadata from an io.Reader. It returns Metadata. func Parse(r io.Reader) (*Metadata, error) { metadata := Metadata{} scanner := bufio.NewScanner(r) re := regexp.MustCompile(`\A(\S+)\s+['"](.*?)['"]\z`) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } match := re.FindStringSubmatch(line) if match == nil { continue } switch match[1] { case "name": metadata.Name = match[2] case "version": metadata.Version = match[2] } } if err := scanner.Err(); err != nil { return nil, err } return &metadata, nil } // ParseFile parses a cookbook metadata file. It returns Metadata. func ParseFile(name string) (*Metadata, error) { f, err := os.Open(name) if err != nil { return nil, err } defer f.Close() return Parse(f) }
// Package omnibus allows to install Chef using Omnibus Installer. // See https://docs.getchef.com/install_omnibus.html package omnibus import ( "io/ioutil" "path" "github.com/mlafeldt/chef-runner/log" "github.com/mlafeldt/chef-runner/util" ) // ScriptURL is the URL of the Omnibus install script. var ScriptURL = "https://www.opscode.com/chef/install.sh" // An Installer allows to install Chef. type Installer struct { ChefVersion string ScriptPath string } func (i Installer) skip() bool { return i.ChefVersion == "" || i.ChefVersion == "false" } func (i Installer) scriptPathTo(elem ...string) string { slice := append([]string{i.ScriptPath}, elem...) return path.Join(slice...) } func (i Installer) writeWrapperScript() error { script := i.scriptPathTo("install-wrapper.sh") log.Debugf("Writing install wrapper script to %s\n", script) return ioutil.WriteFile(script, []byte(wrapperScript), 0644) } func (i Installer) downloadOmnibusScript() error { script := i.scriptPathTo("install.sh") if util.FileExist(script) { log.Debugf("Omnibus script already downloaded to %s\n", script) return nil } log.Debugf("Downloading Omnibus script from %s to %s\n", ScriptURL, script) return util.DownloadFile(script, ScriptURL) } // PrepareScripts sets up the scripts required to install Chef. func (i Installer) PrepareScripts() error { if i.skip() { log.Debug("Skipping setup of install scripts") return nil } log.Debug("Preparing install scripts") if err := i.writeWrapperScript(); err != nil { return err } return i.downloadOmnibusScript() } // Command returns the command string to conditionally install Chef onto a // machine. func (i Installer) Command() []string { if i.skip() { return []string{} } return []string{ "sudo", "sh", i.scriptPathTo("install-wrapper.sh"), i.scriptPathTo("install.sh"), i.ChefVersion, } }
// Package runlist builds Chef run lists. chef-runner allows to compose run // lists using a flexible recipe syntax. If required, this package translates // that syntax to Chef's syntax. package runlist import ( "errors" "path" "strings" "github.com/mlafeldt/chef-runner/log" "github.com/mlafeldt/chef-runner/util" ) func expand(recipe, cookbook string) (string, error) { if strings.HasPrefix(recipe, "::") { if cookbook == "" { log.Errorf("cannot add local recipe \"%s\" to run list\n", strings.TrimPrefix(recipe, "::")) return "", errors.New("cookbook name required") } return cookbook + recipe, nil } if path.Dir(recipe) == "recipes" && path.Ext(recipe) == ".rb" { if cookbook == "" { log.Errorf("cannot add local recipe \"%s\" to run list\n", recipe) return "", errors.New("cookbook name required") } return cookbook + "::" + util.BaseName(recipe, ".rb"), nil } return recipe, nil } // Build creates a Chef run list from a list of recipes and an optional // cookbook name. The cookbook name is only required to expand local recipes. func Build(recipes []string, cookbook string) ([]string, error) { runList := []string{} for _, r := range recipes { for _, r := range strings.Split(r, ",") { recipe, err := expand(r, cookbook) if err != nil { return nil, err } runList = append(runList, recipe) } } return runList, nil }
// Package cli handles the command line interface of chef-runner. This includes // parsing of options and arguments as well as printing help text. package cli import ( "errors" "flag" "fmt" "os" "strings" "github.com/mlafeldt/chef-runner/log" ) var usage = `Usage: chef-runner [options] [--] [<recipe>...] -H, --host <name> Name of host reachable over SSH -M, --machine <name> Name or UUID of Vagrant virtual machine -K, --kitchen <name> Name of Test Kitchen instance --ssh-option <option> Add OpenSSH option as specified in ssh_config(5) -i, --install-chef <version> Install Chef (x.y.z, latest, true, false) default: false -F, --format <format> Chef output format (null, doc, minimal, min) default: doc -l, --log_level <level> Chef log level (debug, info, warn, error, fatal) default: info -j, --json-attributes <file> Load attributes from a JSON file -h, --help Show help text --version Show program version ` // This slice is used to implement options that can be passed multiple times. type stringSlice []string func (s *stringSlice) String() string { return fmt.Sprintf("%v", *s) } func (s *stringSlice) Set(value string) error { *s = append(*s, value) return nil } // Flags stores the options and arguments passed on the command line. type Flags struct { Host string Machine string Kitchen string SSHOptions stringSlice ChefVersion string Format string LogLevel string JSONFile string ShowVersion bool Recipes []string } // ParseFlags parses the command line and returns the result. func ParseFlags(args []string) (*Flags, error) { f := flag.NewFlagSet("chef-runner", flag.ExitOnError) f.Usage = func() { fmt.Fprintf(os.Stderr, usage) } var flags Flags f.StringVar(&flags.Host, "H", "", "") f.StringVar(&flags.Host, "host", "", "") f.StringVar(&flags.Machine, "M", "", "") f.StringVar(&flags.Machine, "machine", "", "") f.StringVar(&flags.Kitchen, "K", "", "") f.StringVar(&flags.Kitchen, "kitchen", "", "") f.Var(&flags.SSHOptions, "ssh-option", "") f.StringVar(&flags.ChefVersion, "i", "", "") f.StringVar(&flags.ChefVersion, "install-chef", "", "") f.StringVar(&flags.Format, "F", "", "") f.StringVar(&flags.Format, "format", "", "") f.StringVar(&flags.LogLevel, "l", "", "") f.StringVar(&flags.LogLevel, "log_level", "", "") f.StringVar(&flags.JSONFile, "j", "", "") f.StringVar(&flags.JSONFile, "json-attributes", "", "") f.BoolVar(&flags.ShowVersion, "version", false, "") if err := f.Parse(args); err != nil { return nil, err } n := 0 for _, i := range []string{flags.Host, flags.Machine, flags.Kitchen} { if i != "" { n++ } } if n > 1 { return nil, errors.New("-H, -M, and -K cannot be used together") } if len(f.Args()) > 0 { flags.Recipes = f.Args() } return &flags, nil } // LogLevel returns the log level to use based on the CHEF_RUNNER_LOG // environment variable. func LogLevel() log.Level { l := log.LevelInfo e := os.Getenv("CHEF_RUNNER_LOG") if e == "" { return l } m := map[string]log.Level{ "debug": log.LevelDebug, "info": log.LevelInfo, "warn": log.LevelWarn, "error": log.LevelError, } if v, ok := m[strings.ToLower(e)]; ok { l = v } return l }
// Package kitchen implements a driver based on Test Kitchen. package kitchen import ( "errors" "fmt" "io/ioutil" "path" "strconv" "github.com/mlafeldt/chef-runner/log" "github.com/mlafeldt/chef-runner/openssh" "github.com/mlafeldt/chef-runner/rsync" "github.com/mlafeldt/chef-runner/util" "gopkg.in/yaml.v2" ) // Driver is a driver based on Test Kitchen. type Driver struct { Instance string SSHClient *openssh.Client RsyncClient *rsync.Client } // This is what `vagrant ssh` uses var defaultSSHOptions = [...]string{ "UserKnownHostsFile /dev/null", "StrictHostKeyChecking no", "PasswordAuthentication no", "IdentitiesOnly yes", "LogLevel FATAL", } type instanceConfig struct { Hostname string `yaml:"hostname"` Username string `yaml:"username"` SSHKey string `yaml:"ssh_key"` Port string `yaml:"port"` } func readInstanceConfig(instance string) (*instanceConfig, error) { configFile := path.Join(".kitchen", instance+".yml") log.Debugf("Kitchen config file = %s\n", configFile) data, err := ioutil.ReadFile(configFile) if err != nil { return nil, err } var config instanceConfig if err := yaml.Unmarshal(data, &config); err != nil { return nil, err } log.Debugf("Kitchen config = %+v\n", config) if config.Hostname == "" { return nil, errors.New(configFile + ": invalid `hostname`") } if config.Username == "" { return nil, errors.New(configFile + ": invalid `username`") } if config.SSHKey == "" { return nil, errors.New(configFile + ": invalid `ssh_key`") } if _, err := strconv.Atoi(config.Port); err != nil { return nil, errors.New(configFile + ": invalid `port`") } return &config, nil } // NewDriver creates a new Test Kitchen driver that communicates with the given // Test Kitchen instance. Under the hood the instance's YAML configuration is // parsed to get a working SSH configuration. func NewDriver(instance string, sshOptions []string) (*Driver, error) { if !util.FileExist(".kitchen.yml") { return nil, errors.New("Kitchen YAML file not found") } config, err := readInstanceConfig(instance) if err != nil { return nil, err } // Test Kitchen stores the port as an string port, _ := strconv.Atoi(config.Port) sshOpts := make([]string, len(defaultSSHOptions)) copy(sshOpts, defaultSSHOptions[:]) for _, o := range sshOptions { sshOpts = append(sshOpts, o) } sshClient := &openssh.Client{ Host: config.Hostname, User: config.Username, Port: port, PrivateKeys: []string{config.SSHKey}, Options: sshOpts, } rsyncClient := *rsync.MirrorClient rsyncClient.RemoteHost = config.Hostname rsyncClient.RemoteShell = sshClient.Shell() return &Driver{instance, sshClient, &rsyncClient}, nil } // RunCommand runs the specified command on the Test Kitchen instance. func (drv Driver) RunCommand(args []string) error { return drv.SSHClient.RunCommand(args) } // Upload copies files to the Test Kitchen instance. func (drv Driver) Upload(dst string, src ...string) error { return drv.RsyncClient.Copy(dst, src...) } // String returns the driver's name. func (drv Driver) String() string { return fmt.Sprintf("Test Kitchen driver (instance: %s)", drv.Instance) }
// Package ssh implements a driver based on OpenSSH. package ssh import ( "fmt" "github.com/mlafeldt/chef-runner/openssh" "github.com/mlafeldt/chef-runner/rsync" ) // Driver is a driver based on SSH. type Driver struct { Host string SSHClient *openssh.Client RsyncClient *rsync.Client } // NewDriver creates a new SSH driver that communicates with the given host. func NewDriver(host string, sshOptions []string) (*Driver, error) { sshClient, err := openssh.NewClient(host) if err != nil { return nil, err } sshClient.Options = sshOptions rsyncClient := *rsync.MirrorClient rsyncClient.RemoteHost = sshClient.Host rsyncClient.RemoteShell = sshClient.Shell() return &Driver{host, sshClient, &rsyncClient}, nil } // RunCommand runs the specified command on the host. func (drv Driver) RunCommand(args []string) error { return drv.SSHClient.RunCommand(args) } // Upload copies files to the host. func (drv Driver) Upload(dst string, src ...string) error { return drv.RsyncClient.Copy(dst, src...) } // String returns the driver's name. func (drv Driver) String() string { return fmt.Sprintf("SSH driver (host: %s)", drv.SSHClient.Host) }
// Package vagrant implements a driver based on Vagrant. package vagrant import ( "bytes" "errors" "fmt" "io/ioutil" "os" goexec "os/exec" "path" "strings" "github.com/mlafeldt/chef-runner/log" "github.com/mlafeldt/chef-runner/openssh" "github.com/mlafeldt/chef-runner/rsync" ) const ( // DefaultMachine is the name of the default Vagrant machine. DefaultMachine = "default" // ConfigPath is the path to the local directory where chef-runner // stores Vagrant-specific information. ConfigPath = ".chef-runner/vagrant" ) // Driver is a driver based on Vagrant. type Driver struct { Machine string SSHClient *openssh.Client RsyncClient *rsync.Client } func init() { os.Setenv("VAGRANT_NO_PLUGINS", "1") } // NewDriver creates a new Vagrant driver that communicates with the given // Vagrant machine. Under the hood `vagrant ssh-config` is executed to get a // working SSH configuration for the machine. func NewDriver(machine string, sshOptions []string) (*Driver, error) { if machine == "" { machine = DefaultMachine } log.Debug("Asking Vagrant for SSH config") cmd := goexec.Command("vagrant", "ssh-config", machine) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { msg := fmt.Sprintf("`vagrant ssh-config` failed with output:\n\n%s", strings.TrimSpace(stderr.String())) return nil, errors.New(msg) } configFile := path.Join(ConfigPath, "machines", machine, "ssh_config") log.Debug("Writing current SSH config to", configFile) if err := os.MkdirAll(path.Dir(configFile), 0755); err != nil { return nil, err } if err := ioutil.WriteFile(configFile, stdout.Bytes(), 0644); err != nil { return nil, err } sshClient := &openssh.Client{ Host: "default", ConfigFile: configFile, Options: sshOptions, } rsyncClient := *rsync.MirrorClient rsyncClient.RemoteHost = "default" rsyncClient.RemoteShell = sshClient.Shell() return &Driver{machine, sshClient, &rsyncClient}, nil } // RunCommand runs the specified command on the Vagrant machine. func (drv Driver) RunCommand(args []string) error { return drv.SSHClient.RunCommand(args) } // Upload copies files to the Vagrant machine. func (drv Driver) Upload(dst string, src ...string) error { return drv.RsyncClient.Copy(dst, src...) } // String returns the driver's name. func (drv Driver) String() string { return fmt.Sprintf("Vagrant driver (machine: %s)", drv.Machine) }
// Package exec runs external commands. It's a wrapper around Go's os/exec // package that allows to stub command execution for testing. package exec import ( "os" goexec "os/exec" "strings" "github.com/mlafeldt/chef-runner/log" ) // The RunnerFunc type is an adapter to use any function for running commands. type RunnerFunc func(args []string) error // DefaultRunner is the default function used to run commands. It calls // os/exec.Run so that stdout and stderr are written to the terminal. // DefaultRunner also logs all executed commands. func DefaultRunner(args []string) error { log.Debugf("exec: %s\n", strings.Join(args, " ")) cmd := goexec.Command(args[0], args[1:]...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } var runnerFunc = DefaultRunner // SetRunnerFunc registers the function f to run all future commands. func SetRunnerFunc(f RunnerFunc) { runnerFunc = f } // RunCommand runs the specified command using the currently registered // RunnerFunc. func RunCommand(args []string) error { return runnerFunc(args) }
// Package log provides functions for logging debug, informational, warning, // and error messages to standard output/error. Clients should set the current // log level; only messages at that level or higher will actually be logged. // Compared to Go's standard log package, this package supports colored output. // // Inspired by https://github.com/cloudflare/cfssl/blob/master/log/log.go package log import ( "fmt" "io" "os" "github.com/mitchellh/colorstring" ) // The Level type is the type of all log levels. type Level int // The different log levels. const ( LevelDebug Level = iota LevelInfo LevelWarn LevelError ) var levelPrefix = [...]string{ LevelDebug: "DEBUG: ", LevelInfo: "INFO: ", LevelWarn: "WARNING: ", LevelError: "ERROR: ", } var levelColor = [...]string{ LevelDebug: "[blue]", LevelInfo: "[cyan]", LevelWarn: "[yellow]", LevelError: "[red]", } var level = LevelDebug var useColor = true // SetLevel changes the current log level to l. func SetLevel(l Level) { level = l } // DisableColor disables any color output. func DisableColor() { useColor = false } func colorize(l Level, s string) string { if !useColor { return s } return colorstring.Color(levelColor[l] + s) } func output(w io.Writer, l Level, v ...interface{}) error { if l < level { return nil } _, err := fmt.Fprint(w, colorize(l, levelPrefix[l]+fmt.Sprintln(v...))) return err } func outputf(w io.Writer, l Level, format string, v ...interface{}) error { if l < level { return nil } _, err := fmt.Fprintf(w, colorize(l, levelPrefix[l]+format), v...) return err } // Debug logs a debug message to stdout. func Debug(v ...interface{}) error { return output(os.Stdout, LevelDebug, v...) } // Debugf logs a formatted debug message to stdout. func Debugf(format string, v ...interface{}) error { return outputf(os.Stdout, LevelDebug, format, v...) } // Info logs an informational message to stdout. func Info(v ...interface{}) error { return output(os.Stdout, LevelInfo, v...) } // Infof logs a formatted informational message to stdout. func Infof(format string, v ...interface{}) error { return outputf(os.Stdout, LevelInfo, format, v...) } // Warn logs a warning message to stdout. func Warn(v ...interface{}) error { return output(os.Stdout, LevelWarn, v...) } // Warnf logs a formatted warning message to stdout. func Warnf(format string, v ...interface{}) error { return outputf(os.Stdout, LevelWarn, format, v...) } // Error logs an error message to stderr. func Error(v ...interface{}) error { return output(os.Stderr, LevelError, v...) } // Errorf logs a formatted error message to stderr. func Errorf(format string, v ...interface{}) error { return outputf(os.Stderr, LevelError, format, v...) }
package main import ( "fmt" "io/ioutil" "os" "strings" "time" "github.com/mlafeldt/chef-runner/chef/cookbook" "github.com/mlafeldt/chef-runner/chef/runlist" "github.com/mlafeldt/chef-runner/cli" "github.com/mlafeldt/chef-runner/driver" "github.com/mlafeldt/chef-runner/driver/kitchen" "github.com/mlafeldt/chef-runner/driver/ssh" "github.com/mlafeldt/chef-runner/driver/vagrant" "github.com/mlafeldt/chef-runner/log" "github.com/mlafeldt/chef-runner/provisioner" "github.com/mlafeldt/chef-runner/provisioner/chefsolo" ) func abort(v ...interface{}) { log.Error(v...) os.Exit(1) } func findDriver(flags *cli.Flags) (driver.Driver, error) { if flags.Host != "" { return ssh.NewDriver(flags.Host, flags.SSHOptions) } if flags.Kitchen != "" { return kitchen.NewDriver(flags.Kitchen, flags.SSHOptions) } return vagrant.NewDriver(flags.Machine, flags.SSHOptions) } func uploadFiles(drv driver.Driver) error { log.Info("Uploading local files to machine. This may take a while...") log.Debugf("Uploading files from %s to %s on machine\n", provisioner.SandboxPath, provisioner.RootPath) return drv.Upload(provisioner.RootPath, provisioner.SandboxPath+"/") } func installChef(drv driver.Driver, p provisioner.Provisioner) error { installCmd := p.InstallCommand() if len(installCmd) == 0 { log.Info("Skipping installation of Chef") return nil } log.Info("Installing Chef") return drv.RunCommand(installCmd) } func runChef(drv driver.Driver, p provisioner.Provisioner) error { log.Infof("Running Chef using %s\n", drv) return drv.RunCommand(p.ProvisionCommand()) } func main() { startTime := time.Now() log.SetLevel(cli.LogLevel()) flags, err := cli.ParseFlags(os.Args[1:]) if err != nil { abort(err) } if flags.ShowVersion { fmt.Printf("chef-runner %s %s\n", VersionString(), TargetString()) os.Exit(0) } log.Infof("Starting chef-runner (%s %s)\n", VersionString(), TargetString()) var attributes string if flags.JSONFile != "" { data, err := ioutil.ReadFile(flags.JSONFile) if err != nil { abort(err) } attributes = string(data) } // 1) Run default recipe if no recipes are passed // 2) Use run list from JSON file if present, overriding 1) // 3) Use run list from command line if present, overriding 1) and 2) recipes := flags.Recipes if len(recipes) == 0 { // TODO: parse actual JSON data if strings.Contains(attributes, "run_list") { log.Infof("Using run list from %s\n", flags.JSONFile) } else { recipes = []string{"::default"} } } var runList []string if len(recipes) > 0 { cb, err := cookbook.NewCookbook(".") if err != nil { abort(err) } log.Debugf("Cookbook = %s\n", cb) if runList, err = runlist.Build(recipes, cb.Name); err != nil { abort(err) } log.Infof("Run list is %s\n", runList) } var p provisioner.Provisioner p = chefsolo.Provisioner{ RunList: runList, Attributes: attributes, Format: flags.Format, LogLevel: flags.LogLevel, UseSudo: true, ChefVersion: flags.ChefVersion, } log.Debugf("Provisioner = %+v\n", p) if err := p.CreateSandbox(); err != nil { abort(err) } drv, err := findDriver(flags) if err != nil { abort(err) } if err := uploadFiles(drv); err != nil { abort(err) } if err := installChef(drv, p); err != nil { abort(err) } if err := runChef(drv, p); err != nil { abort(err) } log.Info("chef-runner finished in", time.Now().Sub(startTime)) }
// Package openssh provides a wrapper around the ssh command-line tool, // allowing to run commands on remote machines. package openssh import ( "errors" "strconv" "strings" "github.com/mlafeldt/chef-runner/exec" ) // A Client is an OpenSSH client. type Client struct { ConfigFile string Host string User string Port int PrivateKeys []string Options []string } // NewClient creates a new Client from the given host string. The host string // has the format [user@]hostname[:port] func NewClient(host string) (*Client, error) { var user string a := strings.Split(host, "@") if len(a) > 1 { user = a[0] host = a[1] } var port int a = strings.Split(host, ":") if len(a) > 1 { host = a[0] var err error if port, err = strconv.Atoi(a[1]); err != nil { return nil, errors.New("invalid SSH port") } } return &Client{Host: host, User: user, Port: port}, nil } // Command returns the ssh command that will be executed by Copy. func (c Client) Command(args []string) []string { cmd := []string{"ssh"} if c.ConfigFile != "" { cmd = append(cmd, "-F", c.ConfigFile) } if c.User != "" { cmd = append(cmd, "-l", c.User) } if c.Port != 0 { cmd = append(cmd, "-p", strconv.Itoa(c.Port)) } for _, pk := range c.PrivateKeys { cmd = append(cmd, "-i", pk) } for _, o := range c.Options { cmd = append(cmd, "-o", o) } if c.Host != "" { cmd = append(cmd, c.Host) } if len(args) > 0 { cmd = append(cmd, args...) } return cmd } // RunCommand uses ssh to execute a command on a remote machine. func (c Client) RunCommand(args []string) error { if len(args) == 0 { return errors.New("no command given") } if c.Host == "" { return errors.New("no host given") } return exec.RunCommand(c.Command(args)) } // Shell returns a connection string that can be used by tools like rsync. Each // argument is double-quoted to preserve spaces. func (c Client) Shell() string { cmd := c.Command([]string{}) var quoted []string for _, i := range cmd[:len(cmd)-1] { quoted = append(quoted, `"`+i+`"`) } return strings.Join(quoted, " ") }
// Package chefsolo implements a provisioner using Chef Solo. package chefsolo import ( "fmt" "io/ioutil" "strings" "github.com/mlafeldt/chef-runner/chef/omnibus" "github.com/mlafeldt/chef-runner/log" base "github.com/mlafeldt/chef-runner/provisioner" "github.com/mlafeldt/chef-runner/resolver" ) const ( // DefaultFormat is the default output format of Chef. DefaultFormat = "doc" // DefaultLogLevel is the default log level of Chef. DefaultLogLevel = "info" ) // CookbookPath is the path to the sandbox directory where cookbooks are stored. var CookbookPath = base.SandboxPathTo("cookbooks") // Provisioner is a provisioner based on Chef Solo. type Provisioner struct { RunList []string Attributes string Format string LogLevel string UseSudo bool ChefVersion string } func (p Provisioner) prepareJSON() error { log.Debug("Preparing JSON data") data := "{}\n" if p.Attributes != "" { data = p.Attributes } return ioutil.WriteFile(base.SandboxPathTo("dna.json"), []byte(data), 0644) } func (p Provisioner) prepareSoloConfig() error { log.Debug("Preparing Chef Solo config") data := fmt.Sprintf("cookbook_path \"%s\"\n", base.RootPathTo("cookbooks")) data += "ssl_verify_mode :verify_peer\n" return ioutil.WriteFile(base.SandboxPathTo("solo.rb"), []byte(data), 0644) } func (p Provisioner) prepareCookbooks() error { log.Debug("Preparing cookbooks") return resolver.AutoResolve(CookbookPath) } func (p Provisioner) prepareInstallScripts() error { i := omnibus.Installer{ ChefVersion: p.ChefVersion, ScriptPath: base.SandboxPath, } return i.PrepareScripts() } // CreateSandbox creates the sandbox directory. This includes preparing Chef // configuration data and cookbooks. func (p Provisioner) CreateSandbox() error { funcs := []func() error{ base.CreateSandbox, p.prepareJSON, p.prepareSoloConfig, p.prepareCookbooks, p.prepareInstallScripts, } for _, f := range funcs { if err := f(); err != nil { return err } } return nil } // CleanupSandbox deletes the sandbox directory. func (p Provisioner) CleanupSandbox() error { return base.CleanupSandbox() } // InstallCommand returns the command string to conditionally install Chef onto // a machine. func (p Provisioner) InstallCommand() []string { i := omnibus.Installer{ ChefVersion: p.ChefVersion, ScriptPath: base.RootPath, } return i.Command() } func (p Provisioner) sudo(args []string) []string { if !p.UseSudo { return args } return append([]string{"sudo"}, args...) } // ProvisionCommand returns the command string which will invoke the // provisioner on the prepared machine. func (p Provisioner) ProvisionCommand() []string { format := p.Format if format == "" { format = DefaultFormat } logLevel := p.LogLevel if logLevel == "" { logLevel = DefaultLogLevel } cmd := []string{ "chef-solo", "--config", base.RootPathTo("solo.rb"), "--json-attributes", base.RootPathTo("dna.json"), "--format", format, "--log_level", logLevel, } if len(p.RunList) > 0 { cmd = append(cmd, "--override-runlist", strings.Join(p.RunList, ",")) } return p.sudo(cmd) }
// Package provisioner defines the interface that all provisioners need to // implement. It also provides common functions shared by all provisioners. package provisioner import ( "os" "path" "github.com/mlafeldt/chef-runner/log" ) const ( // SandboxPath is the path to the local sandbox directory where // chef-runner stores files that will be uploaded to a machine. SandboxPath = ".chef-runner/sandbox" // RootPath is the path on the machine where files from SandboxPath // will be uploaded to. RootPath = "/tmp/chef-runner" ) // A Provisioner is responsible for provisioning a machine with Chef. type Provisioner interface { CreateSandbox() error CleanupSandbox() error InstallCommand() []string ProvisionCommand() []string } // SandboxPathTo returns a path relative to SandboxPath. func SandboxPathTo(elem ...string) string { slice := append([]string{SandboxPath}, elem...) return path.Join(slice...) } // RootPathTo returns a path relative to RootPath. func RootPathTo(elem ...string) string { slice := append([]string{RootPath}, elem...) return path.Join(slice...) } // CreateSandbox creates the sandbox directory. func CreateSandbox() error { log.Info("Preparing local files") log.Debug("Creating local sandbox in", SandboxPath) return os.MkdirAll(SandboxPath, 0755) } // CleanupSandbox deletes the sandbox directory. func CleanupSandbox() error { log.Debug("Cleaning up local sandbox in", SandboxPath) return os.RemoveAll(SandboxPath) }
// Package berkshelf implements a cookbook dependency resolver based on // Berkshelf. package berkshelf import ( "fmt" "strings" "github.com/mlafeldt/chef-runner/bundler" "github.com/mlafeldt/chef-runner/exec" ) // Resolver is a cookbook dependency resolver based on Berkshelf. type Resolver struct{} // Command returns the command that will be executed by Resolve. func Command(dst string) []string { code := []string{ `require "berkshelf";`, `b = Berkshelf::Berksfile.from_file("Berksfile");`, `Berkshelf::Berksfile.method_defined?(:vendor)`, `?`, fmt.Sprintf(`b.vendor("%s")`, dst), `:`, fmt.Sprintf(`b.install(:path => "%s")`, dst), } cmd := append([]string{"ruby", "-e"}, strings.Join(code, " ")) return bundler.Command(cmd) } // Resolve runs Berkshelf to install cookbook dependencies to dst. func (r Resolver) Resolve(dst string) error { return exec.RunCommand(Command(dst)) } // String returns the resolver's name. func (r Resolver) String() string { return "Berkshelf resolver" }
// Package dir implements a cookbook dependency resolver that merely copies // cookbook directories to the right place. package dir import ( "errors" "os" "path" "github.com/mlafeldt/chef-runner/chef/cookbook" "github.com/mlafeldt/chef-runner/rsync" ) // Resolver is a cookbook dependency resolver that copies cookbook directories // to the right place. type Resolver struct{} func installCookbook(dst, src string) error { cb, err := cookbook.NewCookbook(src) if err != nil { return err } if cb.Name == "" { return errors.New("cookbook name required") } if err := os.MkdirAll(dst, 0755); err != nil { return err } return rsync.MirrorClient.Copy(path.Join(dst, cb.Name), cb.Files()...) } // Resolve copies the cookbook in the current directory to dst. func (r Resolver) Resolve(dst string) error { return installCookbook(dst, ".") } // String returns the resolver's name. func (r Resolver) String() string { return "Directory resolver" }
// Package librarian implements a cookbook dependency resolver based on // Librarian-Chef. package librarian import ( "github.com/mlafeldt/chef-runner/bundler" "github.com/mlafeldt/chef-runner/exec" ) // Resolver is a cookbook dependency resolver based on Librarian-Chef. type Resolver struct{} // Command returns the command that will be executed by Resolve. func Command(dst string) []string { cmd := []string{"librarian-chef", "install", "--path", dst} return bundler.Command(cmd) } // Resolve runs Librarian-Chef to install cookbook dependencies to dst. func (r Resolver) Resolve(dst string) error { return exec.RunCommand(Command(dst)) } // String returns the resolver's name. func (r Resolver) String() string { return "Librarian-Chef resolver" }
// Package resolver provides a generic cookbook dependency resolver. package resolver import ( "errors" "io/ioutil" "path" "github.com/mlafeldt/chef-runner/chef/cookbook" "github.com/mlafeldt/chef-runner/log" "github.com/mlafeldt/chef-runner/resolver/berkshelf" "github.com/mlafeldt/chef-runner/resolver/dir" "github.com/mlafeldt/chef-runner/resolver/librarian" "github.com/mlafeldt/chef-runner/util" ) // A Resolver resolves cookbook dependencies and installs them to directory dst. // This is the interface that all resolvers need to implement. type Resolver interface { Resolve(dst string) error String() string } // Helper to determine resolver from files in current directory. func findResolver(dst string) (Resolver, error) { cb, _ := cookbook.NewCookbook(".") // If the current folder is a cookbook and its dependencies have // already been resolved, only update this cookbook with rsync. // TODO: improve this check by comparing timestamps etc. if cb.Name != "" && util.FileExist(dst) { return dir.Resolver{}, nil } if util.FileExist("Berksfile") { return berkshelf.Resolver{}, nil } if util.FileExist("Cheffile") { return librarian.Resolver{}, nil } if cb.Name != "" { return dir.Resolver{}, nil } log.Error("Berksfile, Cheffile, or metadata.rb must exist in current directory") return nil, errors.New("cookbooks could not be found") } func stripCookbooks(dst string) error { cookbookDirs, err := ioutil.ReadDir(dst) if err != nil { return err } for _, dir := range cookbookDirs { if !dir.IsDir() { continue } cb := cookbook.Cookbook{Path: path.Join(dst, dir.Name())} if err := cb.Strip(); err != nil { return err } } return nil } // AutoResolve automatically resolves cookbook dependencies based on the files // present in the current directory. After resolving dependencies, it also // deletes all non-cookbook files. func AutoResolve(dst string) error { r, err := findResolver(dst) if err != nil { return err } log.Infof("Installing cookbook dependencies with %s\n", r) if err := r.Resolve(dst); err != nil { return err } log.Info("Stripping non-cookbook files") return stripCookbooks(dst) }
// Package rsync provides a wrapper around the fast rsync file copying tool. package rsync import ( "errors" "github.com/mlafeldt/chef-runner/exec" ) // A Client is an rsync client. It allows you to copy files from one location to // another using rsync and supports the tool's most useful command-line options. type Client struct { // Archive, if true, enables archive mode. Archive bool // Delete, if true, deletes extraneous files from destination directories. Delete bool // Compress, if true, compresses file data during the transfer. Compress bool // Verbose, if true, increases rsync's verbosity. Verbose bool // Exclude contains files to be excluded from the transfer. Exclude []string // RemoteShell specifies the remote shell to use, e.g. ssh. RemoteShell string // RemoteHost specifies the remote host to copy files to/from. RemoteHost string } // DefaultClient is a usable rsync client without any options enabled. var DefaultClient = &Client{} // MirrorClient is an rsync client configured to mirror files and directories. var MirrorClient = &Client{ Archive: true, Delete: true, Compress: true, Verbose: true, } // Command returns the rsync command that will be executed by Copy. func (c Client) Command(dst string, src ...string) ([]string, error) { if len(src) == 0 { return nil, errors.New("no source given") } if dst == "" { return nil, errors.New("no destination given") } cmd := []string{"rsync"} if c.Archive { cmd = append(cmd, "--archive") } if c.Delete { cmd = append(cmd, "--delete") } if c.Compress { cmd = append(cmd, "--compress") } if c.Verbose { cmd = append(cmd, "--verbose") } for _, x := range c.Exclude { cmd = append(cmd, "--exclude", x) } // FIXME: Only copies files to a remote host, not the other way around. if c.RemoteShell != "" { if c.RemoteHost == "" { return nil, errors.New("no remote host given") } cmd = append(cmd, "--rsh", c.RemoteShell) dst = c.RemoteHost + ":" + dst } cmd = append(cmd, src...) cmd = append(cmd, dst) return cmd, nil } // Copy uses rsync to copy one or more src files to dst. func (c Client) Copy(dst string, src ...string) error { cmd, err := c.Command(dst, src...) if err != nil { return err } return exec.RunCommand(cmd) }
// Package util provides various utility functions. package util import ( "errors" "io" "io/ioutil" "net/http" "os" "path" "strings" ) // FileExist reports whether a file or directory exists. func FileExist(name string) bool { _, err := os.Stat(name) return err == nil } // BaseName - as the basename Unix tool - deletes any prefix ending with the // last slash character present in a string, and a suffix, if given. func BaseName(s, suffix string) string { base := path.Base(s) if suffix != "" { base = strings.TrimSuffix(base, suffix) } return base } // TempDir creates a new temporary directory to be used by chef-runner. func TempDir() (string, error) { return ioutil.TempDir("", "chef-runner-") } // InDir runs a function inside a specific directory. func InDir(dir string, f func()) { wd, err := os.Getwd() if err != nil { panic(err) } if err := os.Chdir(dir); err != nil { panic(err) } f() if err := os.Chdir(wd); err != nil { panic(err) } } // InTestDir runs the passed function inside a temporary directory, which will // be removed afterwards. Use it for isolated testing. func InTestDir(f func()) { testDir, err := TempDir() if err != nil { panic(err) } defer os.RemoveAll(testDir) InDir(testDir, f) } // DownloadFile downloads a file from url and writes it to filename. func DownloadFile(filename, url string) error { resp, err := http.Get(url) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return errors.New("HTTP error: " + resp.Status) } f, err := os.Create(filename) if err != nil { return err } defer f.Close() _, err = io.Copy(f, resp.Body) return err }
package main import "runtime" // The current version of chef-runner. A ".dev" suffix denotes that the version // is currently being developed. const Version = "v0.8.0.dev" // GitVersion is the Git version that is being compiled. This string contains // tag and commit information. It will be filled in by the compiler. var GitVersion string // VersionString returns the current program version, which is either the Git // version if available or the static version defined above. func VersionString() string { if GitVersion != "" { return GitVersion } return Version } // TargetString returns the target operating system and architecture. func TargetString() string { return runtime.GOOS + "/" + runtime.GOARCH }