├── testdata ├── Berksfile ├── Gemfile ├── recipes │ └── default.rb ├── attributes │ └── default.rb ├── README.md ├── bin │ ├── bundle │ ├── bundle.bat │ ├── vagrant.bat │ └── vagrant ├── .kitchen │ └── default-ubuntu-1404.yml ├── .kitchen.yml └── metadata.rb ├── .gitignore ├── packaging ├── .gitignore ├── docker │ ├── rpm │ │ └── Dockerfile │ └── deb │ │ └── Dockerfile ├── recipe.rb └── Makefile ├── driver ├── driver_test.go ├── driver.go ├── local │ ├── driver_test.go │ └── driver.go ├── ssh │ ├── driver_test.go │ └── driver.go ├── kitchen │ ├── driver_test.go │ └── driver.go └── vagrant │ ├── driver_test.go │ └── driver.go ├── script ├── lint ├── bootstrap ├── test └── build ├── CONTRIBUTING.md ├── .travis.yml ├── provisioner ├── provisioner.go └── chefsolo │ ├── provisioner.go │ └── provisioner_test.go ├── version_test.go ├── resolver ├── librarian │ ├── resolver_test.go │ └── resolver.go ├── berkshelf │ ├── resolver_test.go │ └── resolver.go ├── dir │ ├── resolver_test.go │ └── resolver.go ├── resolver_test.go └── resolver.go ├── Makefile ├── util ├── util_test.go └── util.go ├── bundler ├── bundler.go └── bundler_test.go ├── exec ├── exec_test.go └── exec.go ├── version.go ├── chef ├── omnibus │ ├── assets │ │ ├── install-wrapper.sh │ │ └── install.sh │ ├── omnibus_test.go │ ├── omnibus.go │ └── assets.go ├── cookbook │ ├── metadata │ │ ├── metadata_test.go │ │ └── metadata.go │ ├── cookbook_test.go │ └── cookbook.go └── runlist │ ├── runlist.go │ └── runlist_test.go ├── log ├── log_test.go └── log.go ├── openssh ├── openssh.go └── openssh_test.go ├── rsync ├── rsync.go └── rsync_test.go ├── README.md ├── cli ├── cli.go └── cli_test.go ├── main.go ├── LICENSE └── CHANGELOG.md /testdata/Berksfile: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/Gemfile: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | -------------------------------------------------------------------------------- /testdata/recipes/default.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/attributes/default.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packaging/.gitignore: -------------------------------------------------------------------------------- 1 | cache 2 | pkg 3 | -------------------------------------------------------------------------------- /testdata/README.md: -------------------------------------------------------------------------------- 1 | # Test Cookbook 2 | -------------------------------------------------------------------------------- /testdata/bin/bundle: -------------------------------------------------------------------------------- 1 | # Bundler stub for testing 2 | -------------------------------------------------------------------------------- /testdata/bin/bundle.bat: -------------------------------------------------------------------------------- 1 | :: Bundler stub for testing 2 | -------------------------------------------------------------------------------- /driver/driver_test.go: -------------------------------------------------------------------------------- 1 | package driver_test 2 | 3 | // Nothing to test here 4 | -------------------------------------------------------------------------------- /script/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Check code style and correctness. 3 | # Usage: script/lint 4 | 5 | golint ./... | grep -v assets.go: 6 | go vet ./... 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please see the [Development](https://github.com/mlafeldt/chef-runner/wiki/Development) 2 | wiki page for details on how to contribute to chef-runner. 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | sudo: false 4 | 5 | go: "1.10" 6 | 7 | install: make bootstrap 8 | 9 | script: make build 10 | 11 | branches: 12 | only: 13 | - master 14 | -------------------------------------------------------------------------------- /testdata/.kitchen/default-ubuntu-1404.yml: -------------------------------------------------------------------------------- 1 | --- 2 | hostname: 127.0.0.1 3 | username: vagrant 4 | ssh_key: "/Users/mlafeldt/.vagrant.d/insecure_private_key" 5 | port: '2222' 6 | last_action: create 7 | -------------------------------------------------------------------------------- /provisioner/provisioner.go: -------------------------------------------------------------------------------- 1 | // Package provisioner defines the interface that all provisioners need to 2 | // implement. 3 | package provisioner 4 | 5 | // A Provisioner is responsible for provisioning a machine with Chef. 6 | type Provisioner interface { 7 | PrepareFiles() error 8 | Command() []string 9 | } 10 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Install Go dependencies. 3 | # Usage: script/bootstrap 4 | 5 | set -e 6 | 7 | go get -d -t ./... 8 | go get -v \ 9 | github.com/golang/lint/golint \ 10 | github.com/jteeuwen/go-bindata/... \ 11 | github.com/mitchellh/gox \ 12 | github.com/mlafeldt/pkgcloud/... 13 | -------------------------------------------------------------------------------- /testdata/.kitchen.yml: -------------------------------------------------------------------------------- 1 | --- 2 | driver: 3 | name: vagrant 4 | 5 | provisioner: 6 | name: chef_solo 7 | 8 | platforms: 9 | - name: ubuntu-12.04 10 | - name: ubuntu-14.04 11 | - name: centos-6.5 12 | 13 | suites: 14 | - name: default 15 | run_list: 16 | - recipe[cats::default] 17 | attributes: 18 | -------------------------------------------------------------------------------- /version_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestVersionString(t *testing.T) { 10 | GitVersion = "" 11 | assert.Equal(t, Version, VersionString()) 12 | 13 | GitVersion = "some-git-version" 14 | assert.Equal(t, GitVersion, VersionString()) 15 | } 16 | -------------------------------------------------------------------------------- /driver/driver.go: -------------------------------------------------------------------------------- 1 | // Package driver defines the interface that all drivers need to implement. 2 | package driver 3 | 4 | // A Driver is responsible for running commands on and uploading files to a 5 | // machine using whatever mechanism is available. 6 | type Driver interface { 7 | RunCommand(args []string) error 8 | Upload(dst string, src ...string) error 9 | String() string 10 | } 11 | -------------------------------------------------------------------------------- /testdata/bin/vagrant.bat: -------------------------------------------------------------------------------- 1 | :: Vagrant stub for testing 2 | 3 | @echo off 4 | 5 | echo Host default 6 | echo HostName 127.0.0.1 7 | echo User vagrant 8 | echo Port 2200 9 | echo UserKnownHostsFile /dev/null 10 | echo StrictHostKeyChecking no 11 | echo PasswordAuthentication no 12 | echo IdentityFile /Users/mlafeldt/.vagrant.d/insecure_private_key 13 | echo IdentitiesOnly yes 14 | echo LogLevel FATAL 15 | -------------------------------------------------------------------------------- /testdata/bin/vagrant: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Vagrant stub for testing 3 | 4 | if test "$1 $2" = "ssh-config some-machine"; then 5 | cat <&2 "invalid test args" 21 | exit 1 22 | -------------------------------------------------------------------------------- /driver/local/driver_test.go: -------------------------------------------------------------------------------- 1 | package local_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mlafeldt/chef-runner/driver" 7 | . "github.com/mlafeldt/chef-runner/driver/local" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestDriverInterface(t *testing.T) { 12 | assert.Implements(t, (*driver.Driver)(nil), new(Driver)) 13 | } 14 | 15 | func TestString(t *testing.T) { 16 | expect := "Local driver (hostname: some-host)" 17 | actual := Driver{Hostname: "some-host"}.String() 18 | assert.Equal(t, expect, actual) 19 | } 20 | -------------------------------------------------------------------------------- /resolver/librarian/resolver_test.go: -------------------------------------------------------------------------------- 1 | package librarian_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mlafeldt/chef-runner/resolver" 7 | . "github.com/mlafeldt/chef-runner/resolver/librarian" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestResolverInterface(t *testing.T) { 12 | assert.Implements(t, (*resolver.Resolver)(nil), new(Resolver)) 13 | } 14 | 15 | func TestCommand(t *testing.T) { 16 | expect := []string{"librarian-chef", "install", "--path", "a/b/c"} 17 | actual := Command("a/b/c") 18 | assert.Equal(t, expect, actual) 19 | } 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: test 2 | 3 | bootstrap: 4 | @script/bootstrap 5 | 6 | generate: 7 | @go generate -x ./... 8 | 9 | update_omnibus: 10 | @curl -L https://www.chef.io/chef/install.sh >chef/omnibus/assets/install.sh 11 | @go generate -x ./chef/omnibus 12 | 13 | lint: 14 | @script/lint 15 | 16 | test: 17 | @script/test 18 | 19 | build: 20 | @script/build 21 | 22 | release: 23 | @script/build --release 24 | 25 | packages: 26 | $(MAKE) -C packaging build 27 | 28 | clean: 29 | $(RM) -r build 30 | 31 | .PHONY: all bootstrap generate update_omnibus \ 32 | lint test build release packages clean 33 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Run package tests for a file/directory, or all tests if no argument is passed. 3 | # Useful to e.g. execute package tests for the file currently open in Vim. 4 | # Usage: script/test [path] 5 | 6 | set -e 7 | 8 | go_pkg_from_path() { 9 | path=$1 10 | if test -d "$path"; then 11 | dir="$path" 12 | else 13 | dir=$(dirname "$path") 14 | fi 15 | (cd "$dir" && go list) 16 | } 17 | 18 | if test $# -gt 0; then 19 | pkg=$(go_pkg_from_path "$1") 20 | verbose=-v 21 | else 22 | pkg=./... 23 | verbose= 24 | fi 25 | 26 | exec go test ${GOTESTOPTS:-$verbose} "$pkg" 27 | -------------------------------------------------------------------------------- /resolver/berkshelf/resolver_test.go: -------------------------------------------------------------------------------- 1 | package berkshelf_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/mlafeldt/chef-runner/resolver" 8 | . "github.com/mlafeldt/chef-runner/resolver/berkshelf" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestResolverInterface(t *testing.T) { 13 | assert.Implements(t, (*resolver.Resolver)(nil), new(Resolver)) 14 | } 15 | 16 | func TestCommand(t *testing.T) { 17 | cmd := Command("a/b/c") 18 | assert.Equal(t, []string{"ruby", "-e"}, cmd[:2]) 19 | assert.True(t, strings.Contains(cmd[2], `require "berkshelf"`)) 20 | assert.True(t, strings.Contains(cmd[2], `.vendor("a/b/c")`)) 21 | } 22 | -------------------------------------------------------------------------------- /testdata/metadata.rb: -------------------------------------------------------------------------------- 1 | name "practicingruby" 2 | maintainer "Mathias Lafeldt" 3 | maintainer_email "mathias.lafeldt@gmail.com" 4 | license "Apache 2.0" 5 | description "Sets up environment for Practicing Ruby Rails app" 6 | long_description IO.read(File.join(File.dirname(__FILE__), 'README.md')) 7 | version "1.3.1" 8 | recipe "practicingruby::default", "Sets up production-like environment for Practicing Ruby Rails app" 9 | 10 | supports "ubuntu", ">= 12.04" 11 | 12 | depends "apt", ">= 2.4.0" 13 | depends "database" 14 | depends "mailcatcher" 15 | depends "nginx" 16 | depends "postgresql" 17 | depends "sudo" 18 | depends "user" 19 | -------------------------------------------------------------------------------- /util/util_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/mlafeldt/chef-runner/util" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestFileExist(t *testing.T) { 11 | assert.False(t, FileExist("some-non-existing-file")) 12 | assert.True(t, FileExist("util_test.go")) 13 | } 14 | 15 | func TestBaseName(t *testing.T) { 16 | tests := []struct { 17 | in string 18 | suffix string 19 | out string 20 | }{ 21 | {"", "", "."}, 22 | {"a", "", "a"}, 23 | {"a/b", "", "b"}, 24 | {"/a/b/c", "", "c"}, 25 | {"a.x", ".x", "a"}, 26 | {"a/b.x", ".x", "b"}, 27 | {"a/b.x", ".y", "b.x"}, 28 | } 29 | for _, test := range tests { 30 | assert.Equal(t, test.out, BaseName(test.in, test.suffix)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /bundler/bundler.go: -------------------------------------------------------------------------------- 1 | // Package bundler helps to run external commands with Bundler if the 2 | // environment indicates that Bundler should be used. 3 | package bundler 4 | 5 | import ( 6 | "os/exec" 7 | 8 | "github.com/mlafeldt/chef-runner/util" 9 | ) 10 | 11 | func useBundler() bool { 12 | if _, err := exec.LookPath("bundle"); err != nil { 13 | // Bundler not installed 14 | return false 15 | } 16 | if !util.FileExist("Gemfile") { 17 | // No Gemfile found 18 | return false 19 | } 20 | return true 21 | } 22 | 23 | // Command prepends `bundle exec` to the passed command if the environment 24 | // indicates that Bundler should be used. 25 | func Command(args []string) []string { 26 | if !useBundler() { 27 | return args 28 | } 29 | return append([]string{"bundle", "exec"}, args...) 30 | } 31 | -------------------------------------------------------------------------------- /packaging/docker/rpm/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM centos:7 2 | 3 | MAINTAINER Mathias Lafeldt 4 | 5 | RUN yum update -y && yum install -y \ 6 | curl \ 7 | gcc \ 8 | git \ 9 | make \ 10 | rpm-build \ 11 | ruby \ 12 | ruby-devel \ 13 | tar 14 | 15 | RUN echo "gem: --no-ri --no-rdoc" >/etc/gemrc 16 | RUN gem install fpm -v 1.3.3 17 | RUN gem install fpm-cookery -v 0.25.0 18 | 19 | # Install recent version of Go. Use --no-deps below to not install Go again. 20 | RUN curl -Ls https://storage.googleapis.com/golang/go1.7.linux-amd64.tar.gz | \ 21 | tar -C /usr/local -xz 22 | ENV PATH $PATH:/usr/local/go/bin 23 | 24 | VOLUME /data 25 | WORKDIR /data 26 | 27 | CMD ["fpm-cook", "package", "--debug", "--no-deps", "--tmp-root", "/tmp", "recipe.rb"] 28 | -------------------------------------------------------------------------------- /packaging/docker/deb/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:16.04 2 | 3 | MAINTAINER Mathias Lafeldt 4 | 5 | ENV DEBIAN_FRONTEND noninteractive 6 | RUN apt-get update -y && apt-get install -y --no-install-recommends \ 7 | build-essential \ 8 | curl \ 9 | git \ 10 | ruby \ 11 | ruby-dev 12 | 13 | RUN echo "gem: --no-ri --no-rdoc" >/etc/gemrc 14 | RUN gem install fpm -v 1.3.3 15 | RUN gem install fpm-cookery -v 0.25.0 16 | 17 | # Install recent version of Go. Use --no-deps below to not install Go again. 18 | RUN curl -Ls https://storage.googleapis.com/golang/go1.7.linux-amd64.tar.gz | \ 19 | tar -C /usr/local -xz 20 | ENV PATH $PATH:/usr/local/go/bin 21 | 22 | VOLUME /data 23 | WORKDIR /data 24 | 25 | CMD ["fpm-cook", "package", "--debug", "--no-deps", "--tmp-root", "/tmp", "recipe.rb"] 26 | -------------------------------------------------------------------------------- /resolver/librarian/resolver.go: -------------------------------------------------------------------------------- 1 | // Package librarian implements a cookbook dependency resolver based on 2 | // Librarian-Chef. 3 | package librarian 4 | 5 | import ( 6 | "github.com/mlafeldt/chef-runner/bundler" 7 | "github.com/mlafeldt/chef-runner/exec" 8 | ) 9 | 10 | // Resolver is a cookbook dependency resolver based on Librarian-Chef. 11 | type Resolver struct{} 12 | 13 | // Command returns the command that will be executed by Resolve. 14 | func Command(dst string) []string { 15 | cmd := []string{"librarian-chef", "install", "--path", dst} 16 | return bundler.Command(cmd) 17 | } 18 | 19 | // Resolve runs Librarian-Chef to install cookbook dependencies to dst. 20 | func (r Resolver) Resolve(dst string) error { 21 | return exec.RunCommand(Command(dst)) 22 | } 23 | 24 | // Name returns the resolver's name. 25 | func (r Resolver) Name() string { 26 | return "Librarian-Chef" 27 | } 28 | -------------------------------------------------------------------------------- /exec/exec_test.go: -------------------------------------------------------------------------------- 1 | package exec_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | . "github.com/mlafeldt/chef-runner/exec" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestRunCommand_Success(t *testing.T) { 12 | err := RunCommand([]string{"go", "version"}) 13 | assert.NoError(t, err) 14 | } 15 | 16 | func TestRunCommand_Failure(t *testing.T) { 17 | err := RunCommand([]string{"go", "some-unknown-subcommand"}) 18 | assert.EqualError(t, err, "exit status 2") 19 | } 20 | 21 | func TestRunCommand_Func(t *testing.T) { 22 | defer SetRunnerFunc(DefaultRunner) 23 | 24 | var lastCmd string 25 | SetRunnerFunc(func(args []string) error { 26 | lastCmd = strings.Join(args, " ") 27 | return nil 28 | }) 29 | 30 | err := RunCommand([]string{"some", "test", "command"}) 31 | if assert.NoError(t, err) { 32 | assert.Equal(t, "some test command", lastCmd) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /resolver/dir/resolver_test.go: -------------------------------------------------------------------------------- 1 | package dir_test 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "testing" 7 | 8 | "github.com/mlafeldt/chef-runner/resolver" 9 | . "github.com/mlafeldt/chef-runner/resolver/dir" 10 | "github.com/mlafeldt/chef-runner/util" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestResolverInterface(t *testing.T) { 15 | assert.Implements(t, (*resolver.Resolver)(nil), new(Resolver)) 16 | } 17 | 18 | func TestResolve(t *testing.T) { 19 | defer util.TestChdir(t, "../../testdata")() 20 | 21 | cookbookPath := "test-cookbooks" 22 | defer os.RemoveAll(cookbookPath) 23 | 24 | assert.NoError(t, Resolver{}.Resolve(cookbookPath)) 25 | 26 | expectFiles := []string{ 27 | "practicingruby/README.md", 28 | "practicingruby/attributes", 29 | "practicingruby/metadata.rb", 30 | "practicingruby/recipes", 31 | } 32 | for _, f := range expectFiles { 33 | assert.True(t, util.FileExist(path.Join(cookbookPath, f))) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "runtime" 4 | 5 | // Version is the current version of chef-runner. A ".dev" suffix denotes 6 | // that the version is currently being developed. 7 | const Version = "v0.9.0" 8 | 9 | // GitVersion is the Git version that is being compiled. This string contains 10 | // tag and commit information. It will be filled in by the compiler. 11 | var GitVersion string 12 | 13 | // VersionString returns the current program version, which is either the Git 14 | // version if available or the static version defined above. 15 | func VersionString() string { 16 | if GitVersion != "" { 17 | return GitVersion 18 | } 19 | return Version 20 | } 21 | 22 | // GoVersionString returns the Go version used to build the program. 23 | func GoVersionString() string { 24 | return runtime.Version() 25 | } 26 | 27 | // TargetString returns the target operating system and architecture. 28 | func TargetString() string { 29 | return runtime.GOOS + "/" + runtime.GOARCH 30 | } 31 | -------------------------------------------------------------------------------- /chef/omnibus/assets/install-wrapper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # A smart wrapper around Omnibus Installer 3 | 4 | set -e 5 | 6 | script=$1 7 | version=$2 8 | 9 | manifest=/opt/chef/version-manifest.txt 10 | current_version=$(head -n1 "$manifest" 2>/dev/null | cut -d" " -f2) 11 | 12 | case "$version" in 13 | ""|false) 14 | echo "==> Doing nothing." 15 | ;; 16 | latest) 17 | echo "==> Installing latest version of Chef..." 18 | sh "$script" 19 | ;; 20 | true) 21 | if test -n "$current_version"; then 22 | echo "==> Chef version $current_version installed. Doing nothing." 23 | else 24 | echo "==> Installing latest version of Chef..." 25 | sh "$script" 26 | fi 27 | ;; 28 | *) 29 | if test "$current_version" = "$version"; then 30 | echo "==> Chef version $version already installed. Doing nothing." 31 | else 32 | echo "==> Installing Chef version $version ..." 33 | sh "$script" -v "$version" 34 | fi 35 | ;; 36 | esac 37 | -------------------------------------------------------------------------------- /driver/ssh/driver_test.go: -------------------------------------------------------------------------------- 1 | package ssh_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mlafeldt/chef-runner/driver" 7 | . "github.com/mlafeldt/chef-runner/driver/ssh" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestDriverInterface(t *testing.T) { 12 | assert.Implements(t, (*driver.Driver)(nil), new(Driver)) 13 | } 14 | 15 | func TestNewDriver(t *testing.T) { 16 | sshOpts := []string{"LogLevel=debug"} 17 | rsyncOpts := []string{"--verbose"} 18 | drv, err := NewDriver("some-user@some-host:1234", sshOpts, rsyncOpts) 19 | if assert.NoError(t, err) { 20 | assert.Equal(t, "some-host", drv.SSHClient.Host) 21 | assert.Equal(t, 1234, drv.SSHClient.Port) 22 | assert.Equal(t, "some-user", drv.SSHClient.User) 23 | assert.Equal(t, sshOpts, drv.SSHClient.Options) 24 | 25 | assert.Equal(t, "some-host", drv.RsyncClient.RemoteHost) 26 | assert.Equal(t, rsyncOpts, drv.RsyncClient.Options) 27 | } 28 | } 29 | 30 | func TestString(t *testing.T) { 31 | drv, _ := NewDriver("some-user@some-host:1234", nil, nil) 32 | assert.Equal(t, "SSH driver (host: some-host)", drv.String()) 33 | } 34 | -------------------------------------------------------------------------------- /driver/local/driver.go: -------------------------------------------------------------------------------- 1 | // Package local implements a driver that provisions the host system. 2 | package local 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | 8 | "github.com/mlafeldt/chef-runner/exec" 9 | "github.com/mlafeldt/chef-runner/rsync" 10 | ) 11 | 12 | // Driver is a driver for the host system. 13 | type Driver struct { 14 | Hostname string 15 | } 16 | 17 | // NewDriver creates a new driver that provisions the host system. 18 | func NewDriver() (*Driver, error) { 19 | hostname, err := os.Hostname() 20 | if err != nil { 21 | return nil, err 22 | } 23 | return &Driver{hostname}, nil 24 | } 25 | 26 | // RunCommand runs the specified command on the host system. 27 | func (drv Driver) RunCommand(args []string) error { 28 | return exec.RunCommand(args) 29 | } 30 | 31 | // Upload copies files to the right place on the host system. 32 | func (drv Driver) Upload(dst string, src ...string) error { 33 | return rsync.MirrorClient.Copy(dst, src...) 34 | } 35 | 36 | // String returns the driver's name. 37 | func (drv Driver) String() string { 38 | return fmt.Sprintf("Local driver (hostname: %s)", drv.Hostname) 39 | } 40 | -------------------------------------------------------------------------------- /resolver/dir/resolver.go: -------------------------------------------------------------------------------- 1 | // Package dir implements a cookbook dependency resolver that merely copies 2 | // cookbook directories to the right place. 3 | package dir 4 | 5 | import ( 6 | "errors" 7 | "os" 8 | "path" 9 | 10 | "github.com/mlafeldt/chef-runner/chef/cookbook" 11 | "github.com/mlafeldt/chef-runner/rsync" 12 | ) 13 | 14 | // Resolver is a cookbook dependency resolver that copies cookbook directories 15 | // to the right place. 16 | type Resolver struct{} 17 | 18 | func installCookbook(dst, src string) error { 19 | cb, err := cookbook.NewCookbook(src) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | if cb.Name == "" { 25 | return errors.New("cookbook name required") 26 | } 27 | 28 | if err := os.MkdirAll(dst, 0755); err != nil { 29 | return err 30 | } 31 | 32 | return rsync.MirrorClient.Copy(path.Join(dst, cb.Name), cb.Files()...) 33 | } 34 | 35 | // Resolve copies the cookbook in the current directory to dst. 36 | func (r Resolver) Resolve(dst string) error { 37 | return installCookbook(dst, ".") 38 | } 39 | 40 | // Name returns the resolver's name. 41 | func (r Resolver) Name() string { 42 | return "Directory" 43 | } 44 | -------------------------------------------------------------------------------- /util/util.go: -------------------------------------------------------------------------------- 1 | // Package util provides various utility functions. 2 | package util 3 | 4 | import ( 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | // FileExist reports whether a file or directory exists. 13 | func FileExist(name string) bool { 14 | _, err := os.Stat(name) 15 | return err == nil 16 | } 17 | 18 | // BaseName - as the basename Unix tool - deletes any prefix ending with the 19 | // last slash character present in a string, and a suffix, if given. 20 | func BaseName(s, suffix string) string { 21 | base := filepath.Base(s) 22 | if suffix != "" { 23 | base = strings.TrimSuffix(base, suffix) 24 | } 25 | return base 26 | } 27 | 28 | func TestChdir(t *testing.T, dir string) func() { 29 | old, err := os.Getwd() 30 | if err != nil { 31 | t.Fatalf("error: %s", err) 32 | } 33 | if err := os.Chdir(dir); err != nil { 34 | t.Fatalf("error: %s", err) 35 | } 36 | return func() { os.Chdir(old) } 37 | } 38 | 39 | func TestTempDir(t *testing.T) func() { 40 | tmp, err := ioutil.TempDir("", "chef-runner-") 41 | if err != nil { 42 | t.Fatalf("error: %s", err) 43 | } 44 | f := TestChdir(t, tmp) 45 | return func() { f(); os.RemoveAll(tmp) } 46 | } 47 | -------------------------------------------------------------------------------- /exec/exec.go: -------------------------------------------------------------------------------- 1 | // Package exec runs external commands. It's a wrapper around Go's os/exec 2 | // package that allows to stub command execution for testing. 3 | package exec 4 | 5 | import ( 6 | "os" 7 | goexec "os/exec" 8 | "strings" 9 | 10 | "github.com/mlafeldt/chef-runner/log" 11 | ) 12 | 13 | // The RunnerFunc type is an adapter to use any function for running commands. 14 | type RunnerFunc func(args []string) error 15 | 16 | // DefaultRunner is the default function used to run commands. It calls 17 | // os/exec.Run so that stdout and stderr are written to the terminal. 18 | // DefaultRunner also logs all executed commands. 19 | func DefaultRunner(args []string) error { 20 | log.Debugf("exec: %s\n", strings.Join(args, " ")) 21 | cmd := goexec.Command(args[0], args[1:]...) 22 | cmd.Stdout = os.Stdout 23 | cmd.Stderr = os.Stderr 24 | return cmd.Run() 25 | } 26 | 27 | var runnerFunc = DefaultRunner 28 | 29 | // SetRunnerFunc registers the function f to run all future commands. 30 | func SetRunnerFunc(f RunnerFunc) { 31 | runnerFunc = f 32 | } 33 | 34 | // RunCommand runs the specified command using the currently registered 35 | // RunnerFunc. 36 | func RunCommand(args []string) error { 37 | return runnerFunc(args) 38 | } 39 | -------------------------------------------------------------------------------- /chef/omnibus/omnibus_test.go: -------------------------------------------------------------------------------- 1 | package omnibus_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | . "github.com/mlafeldt/chef-runner/chef/omnibus" 8 | "github.com/mlafeldt/chef-runner/util" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestPrepareFiles(t *testing.T) { 13 | defer util.TestTempDir(t)() 14 | wd, _ := os.Getwd() 15 | i := Installer{ChefVersion: "1.2.3", SandboxPath: wd} 16 | assert.NoError(t, i.PrepareFiles()) 17 | 18 | assert.True(t, util.FileExist("install.sh")) 19 | assert.True(t, util.FileExist("install-wrapper.sh")) 20 | } 21 | 22 | func TestCommand(t *testing.T) { 23 | tests := map[string][]string{ 24 | "": []string{}, 25 | "false": []string{}, 26 | "latest": []string{"sudo", "sh", "/some/path/install-wrapper.sh", "/some/path/install.sh", "latest"}, 27 | "true": []string{"sudo", "sh", "/some/path/install-wrapper.sh", "/some/path/install.sh", "true"}, 28 | "1.2.3": []string{"sudo", "sh", "/some/path/install-wrapper.sh", "/some/path/install.sh", "1.2.3"}, 29 | } 30 | for version, cmd := range tests { 31 | i := Installer{ 32 | ChefVersion: version, 33 | RootPath: "/some/path", 34 | Sudo: true, 35 | } 36 | assert.Equal(t, cmd, i.Command()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /resolver/berkshelf/resolver.go: -------------------------------------------------------------------------------- 1 | // Package berkshelf implements a cookbook dependency resolver based on 2 | // Berkshelf. 3 | package berkshelf 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | "github.com/mlafeldt/chef-runner/bundler" 11 | "github.com/mlafeldt/chef-runner/exec" 12 | ) 13 | 14 | // Resolver is a cookbook dependency resolver based on Berkshelf. 15 | type Resolver struct{} 16 | 17 | // Command returns the command that will be executed by Resolve. 18 | func Command(dst string) []string { 19 | code := []string{ 20 | `require "berkshelf";`, 21 | `b = Berkshelf::Berksfile.from_file("Berksfile");`, 22 | `Berkshelf::Berksfile.method_defined?(:vendor)`, `?`, 23 | fmt.Sprintf(`b.vendor("%s")`, dst), `:`, 24 | fmt.Sprintf(`b.install(:path => "%s")`, dst), 25 | } 26 | cmd := append([]string{"ruby", "-e"}, strings.Join(code, " ")) 27 | return bundler.Command(cmd) 28 | } 29 | 30 | // Resolve runs Berkshelf to install cookbook dependencies to dst. 31 | func (r Resolver) Resolve(dst string) error { 32 | if err := os.RemoveAll(dst); err != nil { 33 | return err 34 | } 35 | return exec.RunCommand(Command(dst)) 36 | } 37 | 38 | // Name returns the resolver's name. 39 | func (r Resolver) Name() string { 40 | return "Berkshelf" 41 | } 42 | -------------------------------------------------------------------------------- /chef/cookbook/metadata/metadata_test.go: -------------------------------------------------------------------------------- 1 | package metadata_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | . "github.com/mlafeldt/chef-runner/chef/cookbook/metadata" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestParse(t *testing.T) { 12 | tests := []struct { 13 | in string 14 | name, version string 15 | }{ 16 | {"", "", ""}, 17 | {`name "cats"`, "cats", ""}, 18 | {`name 'cats'`, "cats", ""}, 19 | {` name "cats" `, "cats", ""}, 20 | {`version "1.2.3"`, "", "1.2.3"}, 21 | {`version '1.2.3'`, "", "1.2.3"}, 22 | {` version "1.2.3" `, "", "1.2.3"}, 23 | {` 24 | # some comment 25 | name "dogs" 26 | maintainer "Pluto" 27 | version "2.0.0"`, "dogs", "2.0.0"}, 28 | } 29 | for _, test := range tests { 30 | metadata, err := Parse(bytes.NewBufferString(test.in)) 31 | assert.NoError(t, err) 32 | if assert.NotNil(t, metadata) { 33 | assert.Equal(t, test.name, metadata.Name) 34 | assert.Equal(t, test.version, metadata.Version) 35 | } 36 | } 37 | } 38 | 39 | func TestParseFile(t *testing.T) { 40 | metadata, err := ParseFile("../../../testdata/metadata.rb") 41 | assert.NoError(t, err) 42 | if assert.NotNil(t, metadata) { 43 | assert.Equal(t, "practicingruby", metadata.Name) 44 | assert.Equal(t, "1.3.1", metadata.Version) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packaging/recipe.rb: -------------------------------------------------------------------------------- 1 | class ChefRunner < FPM::Cookery::Recipe 2 | GOPACKAGE = "github.com/mlafeldt/chef-runner" 3 | 4 | name "chef-runner" 5 | version "0.9.0" 6 | revision 1 7 | source "https://#{GOPACKAGE}/archive/v#{version}.tar.gz" 8 | sha256 "4f896fa21cab1f94fe1ce678804b2e5d481523b5c74a5695cbfb76eb9f39dc8b" 9 | 10 | description "The fastest way to run Chef cookbooks" 11 | homepage "https://#{GOPACKAGE}" 12 | maintainer "Mathias Lafeldt " 13 | license "Apache 2.0" 14 | section "development" 15 | 16 | case platform 17 | when :debian, :ubuntu 18 | build_depends %w(git golang-go) 19 | depends %w(openssh-client rsync) 20 | when :centos, :redhat 21 | build_depends %w(git golang) 22 | depends %w(openssh-clients rsync) 23 | end 24 | 25 | def build 26 | pkgdir = builddir("gobuild/src/#{GOPACKAGE}") 27 | mkdir_p pkgdir 28 | cp_r Dir["*"], pkgdir 29 | 30 | ENV["GOPATH"] = [ 31 | builddir("gobuild/src/#{GOPACKAGE}/Godeps/_workspace"), 32 | builddir("gobuild"), 33 | ].join(":") 34 | 35 | safesystem "go version" 36 | safesystem "go env" 37 | safesystem "go get -v #{GOPACKAGE}/..." 38 | end 39 | 40 | def install 41 | bin.install builddir("gobuild/bin/chef-runner") 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /packaging/Makefile: -------------------------------------------------------------------------------- 1 | CHEF_RUNNER_IMAGE ?= mlafeldt/chef-runner 2 | PACKAGECLOUD_REPO ?= mlafeldt/chef-runner 3 | 4 | DEB_DISTROS = ubuntu/precise ubuntu/trusty ubuntu/utopic \ 5 | debian/squeeze debian/wheezy debian/jessie 6 | RPM_DISTROS = el/6 el/7 7 | 8 | all: build 9 | 10 | deb_image: 11 | docker build --force-rm -t $(CHEF_RUNNER_IMAGE):deb $(CURDIR)/docker/deb 12 | 13 | rpm_image: 14 | docker build --force-rm -t $(CHEF_RUNNER_IMAGE):rpm $(CURDIR)/docker/rpm 15 | 16 | images: deb_image rpm_image 17 | 18 | deb_build: deb_image 19 | docker run -it --rm -v $(CURDIR):/data $(CHEF_RUNNER_IMAGE):deb 20 | 21 | rpm_build: rpm_image 22 | docker run -it --rm -v $(CURDIR):/data $(CHEF_RUNNER_IMAGE):rpm 23 | 24 | build: deb_build rpm_build 25 | 26 | deb_push: deb_build 27 | @for distro in $(DEB_DISTROS); do \ 28 | pkgcloud-push $(PACKAGECLOUD_REPO)/$$distro pkg/*.deb || exit 1; \ 29 | done 30 | 31 | rpm_push: rpm_build 32 | @for distro in $(RPM_DISTROS); do \ 33 | pkgcloud-push $(PACKAGECLOUD_REPO)/$$distro pkg/*.rpm || exit 1; \ 34 | done 35 | 36 | push: deb_push rpm_push 37 | 38 | release: build push 39 | 40 | clean: 41 | $(RM) -r cache tmp-build tmp-dest 42 | 43 | clobber: clean 44 | $(RM) -r pkg 45 | 46 | .PHONY: all deb_image rpm_image images deb_build rpm_build build \ 47 | deb_push rpm_push push release clean clobber 48 | -------------------------------------------------------------------------------- /bundler/bundler_test.go: -------------------------------------------------------------------------------- 1 | package bundler_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | . "github.com/mlafeldt/chef-runner/bundler" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func withPath(path string, f func()) { 12 | oldPath := os.Getenv("PATH") 13 | os.Setenv("PATH", path) 14 | defer os.Setenv("PATH", oldPath) 15 | f() 16 | } 17 | 18 | func TestCommand_WithoutBundlerAndGemfile(t *testing.T) { 19 | withPath("", func() { 20 | cmd := Command([]string{"rake", "test"}) 21 | assert.Equal(t, []string{"rake", "test"}, cmd) 22 | }) 23 | } 24 | 25 | func TestCommand_WithBundler(t *testing.T) { 26 | withPath("../testdata/bin", func() { 27 | cmd := Command([]string{"rake", "test"}) 28 | assert.Equal(t, []string{"rake", "test"}, cmd) 29 | }) 30 | } 31 | 32 | func TestCommand_WithGemfile(t *testing.T) { 33 | withPath("", func() { 34 | f, _ := os.Create("Gemfile") 35 | f.Close() 36 | defer os.Remove("Gemfile") 37 | 38 | cmd := Command([]string{"rake", "test"}) 39 | assert.Equal(t, []string{"rake", "test"}, cmd) 40 | }) 41 | } 42 | 43 | func TestCommand_WithBundlerAndGemfile(t *testing.T) { 44 | withPath("../testdata/bin", func() { 45 | f, _ := os.Create("Gemfile") 46 | f.Close() 47 | defer os.Remove("Gemfile") 48 | 49 | cmd := Command([]string{"rake", "test"}) 50 | assert.Equal(t, []string{"bundle", "exec", "rake", "test"}, cmd) 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /driver/kitchen/driver_test.go: -------------------------------------------------------------------------------- 1 | package kitchen_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mlafeldt/chef-runner/driver" 7 | . "github.com/mlafeldt/chef-runner/driver/kitchen" 8 | "github.com/mlafeldt/chef-runner/util" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestDriverInterface(t *testing.T) { 13 | assert.Implements(t, (*driver.Driver)(nil), new(Driver)) 14 | } 15 | 16 | func TestNewDriver(t *testing.T) { 17 | defer util.TestChdir(t, "../../testdata")() 18 | 19 | sshOpts := []string{"LogLevel=debug"} 20 | rsyncOpts := []string{"--verbose"} 21 | drv, err := NewDriver("default-ubuntu-1404", sshOpts, rsyncOpts) 22 | if assert.NoError(t, err) { 23 | assert.Equal(t, "127.0.0.1", drv.SSHClient.Host) 24 | assert.Equal(t, 2222, drv.SSHClient.Port) 25 | assert.Equal(t, "vagrant", drv.SSHClient.User) 26 | assert.Equal(t, "/Users/mlafeldt/.vagrant.d/insecure_private_key", 27 | drv.SSHClient.PrivateKeys[0]) 28 | assert.Equal(t, 6, len(drv.SSHClient.Options)) 29 | assert.Equal(t, "LogLevel=debug", drv.SSHClient.Options[5]) 30 | 31 | assert.Equal(t, "127.0.0.1", drv.RsyncClient.RemoteHost) 32 | assert.Equal(t, rsyncOpts, drv.RsyncClient.Options) 33 | } 34 | } 35 | 36 | func TestString(t *testing.T) { 37 | expect := "Test Kitchen driver (instance: some-instance)" 38 | actual := Driver{Instance: "some-instance"}.String() 39 | assert.Equal(t, expect, actual) 40 | } 41 | -------------------------------------------------------------------------------- /driver/ssh/driver.go: -------------------------------------------------------------------------------- 1 | // Package ssh implements a driver based on OpenSSH. 2 | package ssh 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/mlafeldt/chef-runner/openssh" 8 | "github.com/mlafeldt/chef-runner/rsync" 9 | ) 10 | 11 | // Driver is a driver based on SSH. 12 | type Driver struct { 13 | Host string 14 | SSHClient *openssh.Client 15 | RsyncClient *rsync.Client 16 | } 17 | 18 | // NewDriver creates a new SSH driver that communicates with the given host. 19 | func NewDriver(host string, sshOptions, rsyncOptions []string) (*Driver, error) { 20 | sshClient, err := openssh.NewClient(host) 21 | if err != nil { 22 | return nil, err 23 | } 24 | sshClient.Options = sshOptions 25 | 26 | rsyncClient := *rsync.MirrorClient 27 | rsyncClient.RemoteHost = sshClient.Host 28 | rsyncClient.RemoteShell = sshClient.Shell() 29 | rsyncClient.Options = rsyncOptions 30 | 31 | return &Driver{host, sshClient, &rsyncClient}, nil 32 | } 33 | 34 | // RunCommand runs the specified command on the host. 35 | func (drv Driver) RunCommand(args []string) error { 36 | return drv.SSHClient.RunCommand(args) 37 | } 38 | 39 | // Upload copies files to the host. 40 | func (drv Driver) Upload(dst string, src ...string) error { 41 | return drv.RsyncClient.Copy(dst, src...) 42 | } 43 | 44 | // String returns the driver's name. 45 | func (drv Driver) String() string { 46 | return fmt.Sprintf("SSH driver (host: %s)", drv.SSHClient.Host) 47 | } 48 | -------------------------------------------------------------------------------- /driver/vagrant/driver_test.go: -------------------------------------------------------------------------------- 1 | package vagrant_test 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/mlafeldt/chef-runner/driver" 9 | . "github.com/mlafeldt/chef-runner/driver/vagrant" 10 | "github.com/mlafeldt/chef-runner/util" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestDriverInterface(t *testing.T) { 15 | assert.Implements(t, (*driver.Driver)(nil), new(Driver)) 16 | } 17 | 18 | func TestNewDriver(t *testing.T) { 19 | defer util.TestChdir(t, "../../testdata")() 20 | 21 | oldPath := os.Getenv("PATH") 22 | os.Setenv("PATH", strings.Join([]string{"bin", oldPath}, 23 | string(os.PathListSeparator))) 24 | defer os.Setenv("PATH", oldPath) 25 | 26 | sshOpts := []string{"LogLevel=debug"} 27 | rsyncOpts := []string{"--verbose"} 28 | drv, err := NewDriver("some-machine", sshOpts, rsyncOpts) 29 | if assert.NoError(t, err) { 30 | defer os.RemoveAll(".chef-runner") 31 | assert.Equal(t, "default", drv.SSHClient.Host) 32 | assert.Equal(t, ".chef-runner/vagrant/machines/some-machine/ssh_config", 33 | drv.SSHClient.ConfigFile) 34 | assert.Equal(t, sshOpts, drv.SSHClient.Options) 35 | 36 | assert.Equal(t, "default", drv.RsyncClient.RemoteHost) 37 | assert.Equal(t, rsyncOpts, drv.RsyncClient.Options) 38 | } 39 | } 40 | 41 | func TestString(t *testing.T) { 42 | expect := "Vagrant driver (machine: some-machine)" 43 | actual := Driver{Machine: "some-machine"}.String() 44 | assert.Equal(t, expect, actual) 45 | } 46 | -------------------------------------------------------------------------------- /chef/cookbook/metadata/metadata.go: -------------------------------------------------------------------------------- 1 | // Package metadata parses Chef cookbook metadata. It can currently retrieve 2 | // the cookbook's name and version. 3 | package metadata 4 | 5 | import ( 6 | "bufio" 7 | "io" 8 | "os" 9 | "regexp" 10 | "strings" 11 | ) 12 | 13 | // Filename is the name of the cookbook file that stores metadata. 14 | const Filename = "metadata.rb" 15 | 16 | // Metadata stores metadata about a cookbook. 17 | type Metadata struct { 18 | Name string 19 | Version string 20 | } 21 | 22 | // Parse parses cookbook metadata from an io.Reader. It returns Metadata. 23 | func Parse(r io.Reader) (*Metadata, error) { 24 | metadata := Metadata{} 25 | scanner := bufio.NewScanner(r) 26 | re := regexp.MustCompile(`\A(\S+)\s+['"](.*?)['"]\z`) 27 | 28 | for scanner.Scan() { 29 | line := strings.TrimSpace(scanner.Text()) 30 | if line == "" { 31 | continue 32 | } 33 | match := re.FindStringSubmatch(line) 34 | if match == nil { 35 | continue 36 | } 37 | switch match[1] { 38 | case "name": 39 | metadata.Name = match[2] 40 | case "version": 41 | metadata.Version = match[2] 42 | } 43 | } 44 | 45 | if err := scanner.Err(); err != nil { 46 | return nil, err 47 | } 48 | 49 | return &metadata, nil 50 | } 51 | 52 | // ParseFile parses a cookbook metadata file. It returns Metadata. 53 | func ParseFile(name string) (*Metadata, error) { 54 | f, err := os.Open(name) 55 | if err != nil { 56 | return nil, err 57 | } 58 | defer f.Close() 59 | return Parse(f) 60 | } 61 | -------------------------------------------------------------------------------- /log/log_test.go: -------------------------------------------------------------------------------- 1 | package log_test 2 | 3 | import ( 4 | "os" 5 | 6 | . "github.com/mlafeldt/chef-runner/log" 7 | ) 8 | 9 | func init() { 10 | UseColor = false 11 | } 12 | 13 | func ExampleDebug() { 14 | Debug("some debug message") 15 | // Output: 16 | // DEBUG: some debug message 17 | } 18 | 19 | func ExampleDebugf() { 20 | s := "debug" 21 | Debugf("some %s message", s) 22 | // Output: 23 | // DEBUG: some debug message 24 | } 25 | 26 | func ExampleInfo() { 27 | Info("some info message") 28 | // Output: 29 | // INFO: some info message 30 | } 31 | 32 | func ExampleInfof() { 33 | s := "info" 34 | Infof("some %s message", s) 35 | // Output: 36 | // INFO: some info message 37 | } 38 | 39 | func ExampleWarn() { 40 | Warn("some warning message") 41 | // Output: 42 | // WARNING: some warning message 43 | } 44 | 45 | func ExampleWarnf() { 46 | s := "warning" 47 | Warnf("some %s message", s) 48 | // Output: 49 | // WARNING: some warning message 50 | } 51 | 52 | func ExampleError() { 53 | os.Stderr = os.Stdout 54 | Error("some error message") 55 | // Output: 56 | // ERROR: some error message 57 | } 58 | 59 | func ExampleErrorf() { 60 | os.Stderr = os.Stdout 61 | s := "error" 62 | Errorf("some %s message", s) 63 | // Output: 64 | // ERROR: some error message 65 | } 66 | 67 | func ExampleSetLevel() { 68 | defer SetLevel(LevelDebug) 69 | SetLevel(LevelInfo) 70 | 71 | Debug("some debug message") 72 | Info("some info message") 73 | Warn("some warning message") 74 | // Output: 75 | // INFO: some info message 76 | // WARNING: some warning message 77 | } 78 | -------------------------------------------------------------------------------- /chef/runlist/runlist.go: -------------------------------------------------------------------------------- 1 | // Package runlist builds Chef run lists. chef-runner allows to compose run 2 | // lists using a flexible recipe syntax. If required, this package translates 3 | // that syntax to Chef's syntax. 4 | package runlist 5 | 6 | import ( 7 | "errors" 8 | "path" 9 | "strings" 10 | 11 | "github.com/mlafeldt/chef-runner/log" 12 | "github.com/mlafeldt/chef-runner/util" 13 | ) 14 | 15 | func expand(recipe, cookbook string) (string, error) { 16 | if strings.HasPrefix(recipe, "::") { 17 | if cookbook == "" { 18 | log.Errorf("cannot add local recipe \"%s\" to run list\n", 19 | strings.TrimPrefix(recipe, "::")) 20 | return "", errors.New("cookbook name required") 21 | } 22 | return cookbook + recipe, nil 23 | } 24 | if path.Dir(recipe) == "recipes" && path.Ext(recipe) == ".rb" { 25 | if cookbook == "" { 26 | log.Errorf("cannot add local recipe \"%s\" to run list\n", recipe) 27 | return "", errors.New("cookbook name required") 28 | } 29 | return cookbook + "::" + util.BaseName(recipe, ".rb"), nil 30 | } 31 | return recipe, nil 32 | } 33 | 34 | // Build creates a Chef run list from a list of recipes and an optional 35 | // cookbook name. The cookbook name is only required to expand local recipes. 36 | func Build(recipes []string, cookbook string) ([]string, error) { 37 | runList := []string{} 38 | for _, r := range recipes { 39 | for _, r := range strings.Split(r, ",") { 40 | recipe, err := expand(r, cookbook) 41 | if err != nil { 42 | return nil, err 43 | } 44 | runList = append(runList, recipe) 45 | } 46 | } 47 | return runList, nil 48 | } 49 | -------------------------------------------------------------------------------- /chef/runlist/runlist_test.go: -------------------------------------------------------------------------------- 1 | package runlist_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/mlafeldt/chef-runner/chef/runlist" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestBuild(t *testing.T) { 11 | tests := []struct { 12 | cookbook string 13 | recipes []string 14 | runList []string 15 | errString string 16 | }{ 17 | { 18 | cookbook: "cats", 19 | recipes: []string{}, 20 | runList: []string{}, 21 | }, 22 | { 23 | cookbook: "cats", 24 | recipes: []string{"::foo"}, 25 | runList: []string{"cats::foo"}, 26 | }, 27 | { 28 | cookbook: "cats", 29 | recipes: []string{"recipes/foo.rb"}, 30 | runList: []string{"cats::foo"}, 31 | }, 32 | { 33 | cookbook: "cats", 34 | recipes: []string{"./recipes//foo.rb"}, 35 | runList: []string{"cats::foo"}, 36 | }, 37 | { 38 | cookbook: "", 39 | recipes: []string{"dogs::bar"}, 40 | runList: []string{"dogs::bar"}, 41 | }, 42 | { 43 | cookbook: "", 44 | recipes: []string{"dogs"}, 45 | runList: []string{"dogs"}, 46 | }, 47 | { 48 | cookbook: "cats", 49 | recipes: []string{"recipes/foo.rb", "::bar", "dogs::baz"}, 50 | runList: []string{"cats::foo", "cats::bar", "dogs::baz"}, 51 | }, 52 | { 53 | cookbook: "cats", 54 | recipes: []string{"recipes/foo.rb,::bar,dogs::baz"}, 55 | runList: []string{"cats::foo", "cats::bar", "dogs::baz"}, 56 | }, 57 | // Check for errors 58 | { 59 | cookbook: "", 60 | recipes: []string{"::foo"}, 61 | runList: nil, 62 | errString: "cookbook name required", 63 | }, 64 | { 65 | cookbook: "", 66 | recipes: []string{"recipes/foo.rb"}, 67 | runList: nil, 68 | errString: "cookbook name required", 69 | }, 70 | } 71 | for _, test := range tests { 72 | runList, err := Build(test.recipes, test.cookbook) 73 | if test.errString == "" { 74 | assert.NoError(t, err) 75 | } else { 76 | assert.EqualError(t, err, test.errString) 77 | } 78 | assert.Equal(t, test.runList, runList) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /chef/cookbook/cookbook_test.go: -------------------------------------------------------------------------------- 1 | package cookbook_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | 8 | . "github.com/mlafeldt/chef-runner/chef/cookbook" 9 | "github.com/mlafeldt/chef-runner/util" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestNewCookbook(t *testing.T) { 14 | cb, err := NewCookbook("../../testdata") 15 | assert.NoError(t, err) 16 | if assert.NotNil(t, cb) { 17 | assert.Equal(t, "../../testdata", cb.Path) 18 | assert.Equal(t, "practicingruby", cb.Name) 19 | assert.Equal(t, "1.3.1", cb.Version) 20 | } 21 | } 22 | 23 | func TestNewCookbook_WithoutMetadata(t *testing.T) { 24 | cb, err := NewCookbook(".") 25 | assert.NoError(t, err) 26 | if assert.NotNil(t, cb) { 27 | assert.Equal(t, ".", cb.Path) 28 | assert.Equal(t, "", cb.Name) 29 | assert.Equal(t, "", cb.Version) 30 | } 31 | } 32 | 33 | func TestString(t *testing.T) { 34 | cb := Cookbook{Name: "cats", Version: "1.2.3"} 35 | assert.Equal(t, "cats 1.2.3", cb.String()) 36 | } 37 | 38 | func TestFiles(t *testing.T) { 39 | cb, _ := NewCookbook("../../testdata") 40 | expect := []string{ 41 | "../../testdata/README.md", 42 | "../../testdata/metadata.rb", 43 | "../../testdata/attributes", 44 | "../../testdata/recipes", 45 | } 46 | assert.Equal(t, expect, cb.Files()) 47 | } 48 | 49 | func TestStrip(t *testing.T) { 50 | defer util.TestTempDir(t)() 51 | 52 | for _, f := range []string{"CHANGELOG.md", "README.md", "metadata.rb"} { 53 | ioutil.WriteFile(f, []byte{}, 0644) 54 | } 55 | for _, d := range []string{"attributes", "recipes", "tmp"} { 56 | os.Mkdir(d, 0755) 57 | } 58 | 59 | cb, _ := NewCookbook(".") 60 | assert.NoError(t, cb.Strip()) 61 | 62 | expect := []string{ 63 | "README.md", 64 | "attributes", 65 | "metadata.rb", 66 | "recipes", 67 | } 68 | 69 | files, err := ioutil.ReadDir(".") 70 | if err != nil { 71 | panic(err) 72 | } 73 | 74 | var actual []string 75 | for _, f := range files { 76 | actual = append(actual, f.Name()) 77 | } 78 | 79 | assert.Equal(t, expect, actual) 80 | } 81 | -------------------------------------------------------------------------------- /script/build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Build project for multiple architectures. With --release, also create 3 | # download archives and other release artifacts. 4 | # Usage: script/build [-r|--release] 5 | 6 | set -e 7 | 8 | git_version=$(git describe --tags --match "v[0-9]*" --abbrev=4 --dirty 2>/dev/null) 9 | version=$(expr "$git_version" : v*'\(.*\)' | sed -e 's/-/./g') 10 | build_dir="build/$version" 11 | 12 | echo "Building chef-runner $git_version ..." 13 | 14 | echo "Installing dependencies..." 15 | script/bootstrap 16 | 17 | echo "Running lint checks..." 18 | script/lint 19 | 20 | echo "Running all tests..." 21 | GOTESTOPTS="-race -cpu 1,2,4" script/test 22 | 23 | echo "Cross-compiling binaries..." 24 | rm -rf "$build_dir" 25 | gox \ 26 | -output="${build_dir}/{{.Dir}}_${version}_{{.OS}}_{{.Arch}}/{{.Dir}}" \ 27 | -os="darwin linux windows freebsd openbsd" \ 28 | -arch="amd64" \ 29 | -ldflags "-X main.GitVersion=$git_version" \ 30 | ./... 31 | ln -snf "$version" build/latest 32 | 33 | case "$1" in 34 | -r|--release) 35 | echo "Creating zip archives..." 36 | cd "$build_dir" 37 | for i in *; do zip -r "$i.zip" "$i"; done 38 | 39 | echo "Creating SHA256SUMS file..." 40 | shasum -a256 *.zip > SHA256SUMS 41 | 42 | echo "Creating Homebrew formula..." 43 | cat > chef-runner.rb < 0 { 78 | cmd = append(cmd, "--override-runlist", strings.Join(p.RunList, ",")) 79 | } 80 | 81 | if !p.Sudo { 82 | return cmd 83 | } 84 | return append([]string{"sudo"}, cmd...) 85 | } 86 | -------------------------------------------------------------------------------- /openssh/openssh.go: -------------------------------------------------------------------------------- 1 | // Package openssh provides a wrapper around the ssh command-line tool, 2 | // allowing to run commands on remote machines. 3 | package openssh 4 | 5 | import ( 6 | "errors" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/mlafeldt/chef-runner/exec" 11 | ) 12 | 13 | // A Client is an OpenSSH client. 14 | type Client struct { 15 | ConfigFile string 16 | Host string 17 | User string 18 | Port int 19 | PrivateKeys []string 20 | Options []string 21 | } 22 | 23 | // NewClient creates a new Client from the given host string. The host string 24 | // has the format [user@]hostname[:port] 25 | func NewClient(host string) (*Client, error) { 26 | var user string 27 | a := strings.Split(host, "@") 28 | if len(a) > 1 { 29 | user = a[0] 30 | host = a[1] 31 | } 32 | 33 | var port int 34 | a = strings.Split(host, ":") 35 | if len(a) > 1 { 36 | host = a[0] 37 | var err error 38 | if port, err = strconv.Atoi(a[1]); err != nil { 39 | return nil, errors.New("invalid SSH port") 40 | } 41 | } 42 | 43 | return &Client{Host: host, User: user, Port: port}, nil 44 | } 45 | 46 | // Command returns the ssh command that will be executed by Copy. 47 | func (c Client) Command(args []string) []string { 48 | cmd := []string{"ssh"} 49 | 50 | if c.ConfigFile != "" { 51 | cmd = append(cmd, "-F", c.ConfigFile) 52 | } 53 | 54 | if c.User != "" { 55 | cmd = append(cmd, "-l", c.User) 56 | } 57 | 58 | if c.Port != 0 { 59 | cmd = append(cmd, "-p", strconv.Itoa(c.Port)) 60 | } 61 | 62 | for _, pk := range c.PrivateKeys { 63 | cmd = append(cmd, "-i", pk) 64 | } 65 | 66 | for _, o := range c.Options { 67 | cmd = append(cmd, "-o", o) 68 | } 69 | 70 | if c.Host != "" { 71 | cmd = append(cmd, c.Host) 72 | } 73 | 74 | if len(args) > 0 { 75 | cmd = append(cmd, args...) 76 | } 77 | 78 | return cmd 79 | } 80 | 81 | // RunCommand uses ssh to execute a command on a remote machine. 82 | func (c Client) RunCommand(args []string) error { 83 | if len(args) == 0 { 84 | return errors.New("no command given") 85 | } 86 | if c.Host == "" { 87 | return errors.New("no host given") 88 | } 89 | return exec.RunCommand(c.Command(args)) 90 | } 91 | 92 | // Shell returns a connection string that can be used by tools like rsync. Each 93 | // argument is double-quoted to preserve spaces. 94 | func (c Client) Shell() string { 95 | cmd := c.Command([]string{}) 96 | var quoted []string 97 | for _, i := range cmd[:len(cmd)-1] { 98 | quoted = append(quoted, "'"+i+"'") 99 | } 100 | return strings.Join(quoted, " ") 101 | } 102 | -------------------------------------------------------------------------------- /resolver/resolver_test.go: -------------------------------------------------------------------------------- 1 | package resolver_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/mlafeldt/chef-runner/exec" 11 | . "github.com/mlafeldt/chef-runner/resolver" 12 | "github.com/mlafeldt/chef-runner/util" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | const CookbookPath = "test-cookbooks" 17 | 18 | var lastCmd []string 19 | 20 | func init() { 21 | exec.SetRunnerFunc(func(args []string) error { 22 | lastCmd = args 23 | return nil 24 | }) 25 | } 26 | 27 | func TestAutoResolve_Berkshelf(t *testing.T) { 28 | lastCmd = []string{} 29 | 30 | defer util.TestTempDir(t)() 31 | ioutil.WriteFile("Berksfile", []byte{}, 0644) 32 | os.MkdirAll(CookbookPath, 0755) 33 | AutoResolve(CookbookPath) 34 | 35 | assert.Equal(t, []string{"ruby", "-e"}, lastCmd[:2]) 36 | assert.True(t, strings.Contains(lastCmd[2], `require "berkshelf"`)) 37 | assert.True(t, strings.Contains(lastCmd[2], fmt.Sprintf(`.vendor("%s")`, CookbookPath))) 38 | } 39 | 40 | func TestAutoResolve_Librarian(t *testing.T) { 41 | lastCmd = []string{} 42 | 43 | defer util.TestTempDir(t)() 44 | ioutil.WriteFile("Cheffile", []byte{}, 0644) 45 | os.MkdirAll(CookbookPath, 0755) 46 | 47 | assert.NoError(t, AutoResolve(CookbookPath)) 48 | assert.Equal(t, []string{"librarian-chef", "install", "--path", CookbookPath}, lastCmd) 49 | } 50 | 51 | func TestAutoResolve_Dir(t *testing.T) { 52 | lastCmd = []string{} 53 | 54 | defer util.TestTempDir(t)() 55 | ioutil.WriteFile("metadata.rb", []byte(`name "cats"`), 0644) 56 | 57 | assert.NoError(t, AutoResolve(CookbookPath)) 58 | assert.Equal(t, []string{"rsync", "--archive", "--delete", "--compress", "metadata.rb", CookbookPath + "/cats"}, lastCmd) 59 | } 60 | 61 | func TestAutoResolve_DirUpdate(t *testing.T) { 62 | lastCmd = []string{} 63 | 64 | defer util.TestTempDir(t)() 65 | ioutil.WriteFile("metadata.rb", []byte(`name "cats"`), 0644) 66 | ioutil.WriteFile("Berksfile", []byte{}, 0644) 67 | os.MkdirAll(CookbookPath, 0755) 68 | 69 | assert.NoError(t, AutoResolve(CookbookPath)) 70 | assert.Equal(t, []string{"rsync", "--archive", "--delete", "--compress", "metadata.rb", CookbookPath + "/cats"}, lastCmd) 71 | } 72 | 73 | func TestAutoResolve_NoCookbooks(t *testing.T) { 74 | lastCmd = []string{} 75 | 76 | defer util.TestTempDir(t)() 77 | err := AutoResolve(CookbookPath) 78 | 79 | assert.EqualError(t, err, "cookbooks could not be found") 80 | assert.Equal(t, []string{}, lastCmd) 81 | } 82 | 83 | func TestResolve_Librarian(t *testing.T) { 84 | lastCmd = []string{} 85 | 86 | defer util.TestTempDir(t)() 87 | os.MkdirAll(CookbookPath, 0755) 88 | 89 | assert.NoError(t, Resolve("librarian", CookbookPath)) 90 | assert.Equal(t, []string{"librarian-chef", "install", "--path", CookbookPath}, lastCmd) 91 | } 92 | -------------------------------------------------------------------------------- /driver/vagrant/driver.go: -------------------------------------------------------------------------------- 1 | // Package vagrant implements a driver based on Vagrant. 2 | package vagrant 3 | 4 | import ( 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "os" 10 | goexec "os/exec" 11 | "path" 12 | "strings" 13 | 14 | "github.com/mlafeldt/chef-runner/log" 15 | "github.com/mlafeldt/chef-runner/openssh" 16 | "github.com/mlafeldt/chef-runner/rsync" 17 | ) 18 | 19 | const ( 20 | // DefaultMachine is the name of the default Vagrant machine. 21 | DefaultMachine = "default" 22 | 23 | // ConfigPath is the path to the local directory where chef-runner 24 | // stores Vagrant-specific information. 25 | ConfigPath = ".chef-runner/vagrant" 26 | ) 27 | 28 | // Driver is a driver based on Vagrant. 29 | type Driver struct { 30 | Machine string 31 | SSHClient *openssh.Client 32 | RsyncClient *rsync.Client 33 | } 34 | 35 | func init() { 36 | os.Setenv("VAGRANT_NO_PLUGINS", "1") 37 | } 38 | 39 | // NewDriver creates a new Vagrant driver that communicates with the given 40 | // Vagrant machine. Under the hood `vagrant ssh-config` is executed to get a 41 | // working SSH configuration for the machine. 42 | func NewDriver(machine string, sshOptions, rsyncOptions []string) (*Driver, error) { 43 | if machine == "" { 44 | machine = DefaultMachine 45 | } 46 | 47 | log.Debug("Asking Vagrant for SSH config") 48 | cmd := goexec.Command("vagrant", "ssh-config", machine) 49 | var stdout, stderr bytes.Buffer 50 | cmd.Stdout = &stdout 51 | cmd.Stderr = &stderr 52 | if err := cmd.Run(); err != nil { 53 | msg := fmt.Sprintf("`vagrant ssh-config` failed with output:\n\n%s", 54 | strings.TrimSpace(stderr.String())) 55 | return nil, errors.New(msg) 56 | } 57 | 58 | configFile := path.Join(ConfigPath, "machines", machine, "ssh_config") 59 | log.Debug("Writing current SSH config to", configFile) 60 | if err := os.MkdirAll(path.Dir(configFile), 0755); err != nil { 61 | return nil, err 62 | } 63 | if err := ioutil.WriteFile(configFile, stdout.Bytes(), 0644); err != nil { 64 | return nil, err 65 | } 66 | 67 | sshClient := &openssh.Client{ 68 | Host: "default", 69 | ConfigFile: configFile, 70 | Options: sshOptions, 71 | } 72 | 73 | rsyncClient := *rsync.MirrorClient 74 | rsyncClient.RemoteHost = "default" 75 | rsyncClient.RemoteShell = sshClient.Shell() 76 | rsyncClient.Options = rsyncOptions 77 | 78 | return &Driver{machine, sshClient, &rsyncClient}, nil 79 | } 80 | 81 | // RunCommand runs the specified command on the Vagrant machine. 82 | func (drv Driver) RunCommand(args []string) error { 83 | return drv.SSHClient.RunCommand(args) 84 | } 85 | 86 | // Upload copies files to the Vagrant machine. 87 | func (drv Driver) Upload(dst string, src ...string) error { 88 | return drv.RsyncClient.Copy(dst, src...) 89 | } 90 | 91 | // String returns the driver's name. 92 | func (drv Driver) String() string { 93 | return fmt.Sprintf("Vagrant driver (machine: %s)", drv.Machine) 94 | } 95 | -------------------------------------------------------------------------------- /rsync/rsync.go: -------------------------------------------------------------------------------- 1 | // Package rsync provides a wrapper around the fast rsync file copying tool. 2 | package rsync 3 | 4 | import ( 5 | "errors" 6 | 7 | "github.com/mlafeldt/chef-runner/exec" 8 | ) 9 | 10 | // A Client is an rsync client. It allows you to copy files from one location to 11 | // another using rsync and supports the tool's most useful command-line options. 12 | type Client struct { 13 | // Archive, if true, enables archive mode. 14 | Archive bool 15 | 16 | // Delete, if true, deletes extraneous files from destination directories. 17 | Delete bool 18 | 19 | // Compress, if true, compresses file data during the transfer. 20 | Compress bool 21 | 22 | // Verbose, if true, increases rsync's verbosity. 23 | Verbose bool 24 | 25 | // Exclude contains files to be excluded from the transfer. 26 | Exclude []string 27 | 28 | // RemoteShell specifies the remote shell to use, e.g. ssh. 29 | RemoteShell string 30 | 31 | // RemoteHost specifies the remote host to copy files to/from. 32 | RemoteHost string 33 | 34 | // Additional options. 35 | Options []string 36 | } 37 | 38 | // DefaultClient is a usable rsync client without any options enabled. 39 | var DefaultClient = &Client{} 40 | 41 | // MirrorClient is an rsync client configured to mirror files and directories. 42 | var MirrorClient = &Client{ 43 | Archive: true, 44 | Delete: true, 45 | Compress: true, 46 | } 47 | 48 | // Command returns the rsync command that will be executed by Copy. 49 | func (c Client) Command(dst string, src ...string) ([]string, error) { 50 | if len(src) == 0 { 51 | return nil, errors.New("no source given") 52 | } 53 | if dst == "" { 54 | return nil, errors.New("no destination given") 55 | } 56 | 57 | cmd := []string{"rsync"} 58 | 59 | if c.Archive { 60 | cmd = append(cmd, "--archive") 61 | } 62 | 63 | if c.Delete { 64 | cmd = append(cmd, "--delete") 65 | } 66 | 67 | if c.Compress { 68 | cmd = append(cmd, "--compress") 69 | } 70 | 71 | if c.Verbose { 72 | cmd = append(cmd, "--verbose") 73 | } 74 | 75 | for _, x := range c.Exclude { 76 | cmd = append(cmd, "--exclude", x) 77 | } 78 | 79 | // FIXME: Only copies files to a remote host, not the other way around. 80 | if c.RemoteShell != "" { 81 | if c.RemoteHost == "" { 82 | return nil, errors.New("no remote host given") 83 | } 84 | cmd = append(cmd, "--rsh", c.RemoteShell) 85 | dst = c.RemoteHost + ":" + dst 86 | } 87 | 88 | for _, o := range c.Options { 89 | cmd = append(cmd, o) 90 | } 91 | 92 | cmd = append(cmd, src...) 93 | cmd = append(cmd, dst) 94 | return cmd, nil 95 | } 96 | 97 | // Copy uses rsync to copy one or more src files to dst. 98 | func (c Client) Copy(dst string, src ...string) error { 99 | cmd, err := c.Command(dst, src...) 100 | if err != nil { 101 | return err 102 | } 103 | return exec.RunCommand(cmd) 104 | } 105 | -------------------------------------------------------------------------------- /rsync/rsync_test.go: -------------------------------------------------------------------------------- 1 | package rsync_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/mlafeldt/chef-runner/rsync" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var commandTests = []struct { 11 | client Client 12 | src []string 13 | dst string 14 | cmd []string 15 | errString string 16 | }{ 17 | { 18 | client: Client{}, 19 | src: []string{"a"}, 20 | dst: "b", 21 | cmd: []string{"rsync", "a", "b"}, 22 | }, 23 | { 24 | client: Client{}, 25 | src: []string{"a", "b"}, 26 | dst: "c", 27 | cmd: []string{"rsync", "a", "b", "c"}, 28 | }, 29 | { 30 | client: Client{Archive: true}, 31 | src: []string{"a"}, 32 | dst: "b", 33 | cmd: []string{"rsync", "--archive", "a", "b"}, 34 | }, 35 | { 36 | client: Client{Delete: true}, 37 | src: []string{"a"}, 38 | dst: "b", 39 | cmd: []string{"rsync", "--delete", "a", "b"}, 40 | }, 41 | { 42 | client: Client{Compress: true}, 43 | src: []string{"a"}, 44 | dst: "b", 45 | cmd: []string{"rsync", "--compress", "a", "b"}, 46 | }, 47 | { 48 | client: Client{Verbose: true}, 49 | src: []string{"a"}, 50 | dst: "b", 51 | cmd: []string{"rsync", "--verbose", "a", "b"}, 52 | }, 53 | { 54 | client: Client{Exclude: []string{"x", "y"}}, 55 | src: []string{"a"}, 56 | dst: "b", 57 | cmd: []string{"rsync", "--exclude", "x", "--exclude", "y", "a", "b"}, 58 | }, 59 | { 60 | client: Client{RemoteShell: "some-shell", RemoteHost: "some-host"}, 61 | src: []string{"a"}, 62 | dst: "b", 63 | cmd: []string{"rsync", "--rsh", "some-shell", "a", "some-host:b"}, 64 | }, 65 | { 66 | client: Client{Options: []string{"--some-option", "--another-option"}}, 67 | src: []string{"a"}, 68 | dst: "b", 69 | cmd: []string{"rsync", "--some-option", "--another-option", "a", "b"}, 70 | }, 71 | { 72 | client: Client{Archive: true, Compress: true, Exclude: []string{"x"}}, 73 | src: []string{"a"}, 74 | dst: "b", 75 | cmd: []string{"rsync", "--archive", "--compress", "--exclude", "x", "a", "b"}, 76 | }, 77 | // Check for errors 78 | { 79 | client: Client{}, 80 | src: []string{}, 81 | dst: "b", 82 | cmd: nil, 83 | errString: "no source given", 84 | }, 85 | { 86 | client: Client{}, 87 | src: []string{"a"}, 88 | dst: "", 89 | cmd: nil, 90 | errString: "no destination given", 91 | }, 92 | { 93 | client: Client{RemoteShell: "some-shell"}, 94 | src: []string{"a"}, 95 | dst: "b", 96 | cmd: nil, 97 | errString: "no remote host given", 98 | }, 99 | } 100 | 101 | func TestCommand(t *testing.T) { 102 | for _, test := range commandTests { 103 | cmd, err := test.client.Command(test.dst, test.src...) 104 | if test.errString == "" { 105 | assert.NoError(t, err) 106 | } else { 107 | assert.EqualError(t, err, test.errString) 108 | } 109 | assert.Equal(t, test.cmd, cmd) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | // Package log provides functions for logging debug, informational, warning, 2 | // and error messages to standard output/error. Clients should set the current 3 | // log level; only messages at that level or higher will actually be logged. 4 | // Compared to Go's standard log package, this package supports colored output. 5 | // 6 | // Inspired by https://github.com/cloudflare/cfssl/blob/master/log/log.go 7 | package log 8 | 9 | import ( 10 | "fmt" 11 | "io" 12 | "os" 13 | 14 | "github.com/mitchellh/colorstring" 15 | ) 16 | 17 | // The Level type is the type of all log levels. 18 | type Level int 19 | 20 | // The different log levels. 21 | const ( 22 | LevelDebug Level = iota 23 | LevelInfo 24 | LevelWarn 25 | LevelError 26 | ) 27 | 28 | var levelPrefix = [...]string{ 29 | LevelDebug: "DEBUG: ", 30 | LevelInfo: "INFO: ", 31 | LevelWarn: "WARNING: ", 32 | LevelError: "ERROR: ", 33 | } 34 | 35 | var levelColor = [...]string{ 36 | LevelDebug: "[blue]", 37 | LevelInfo: "[cyan]", 38 | LevelWarn: "[yellow]", 39 | LevelError: "[red]", 40 | } 41 | 42 | var level = LevelDebug 43 | 44 | // UseColor enables colorized output is set to true. 45 | var UseColor = true 46 | 47 | // SetLevel changes the current log level to l. 48 | func SetLevel(l Level) { 49 | level = l 50 | } 51 | 52 | func colorize(l Level, s string) string { 53 | if !UseColor { 54 | return s 55 | } 56 | return colorstring.Color(levelColor[l] + s) 57 | } 58 | 59 | func output(w io.Writer, l Level, v ...interface{}) error { 60 | if l < level { 61 | return nil 62 | } 63 | _, err := fmt.Fprint(w, colorize(l, levelPrefix[l]+fmt.Sprintln(v...))) 64 | return err 65 | } 66 | 67 | func outputf(w io.Writer, l Level, format string, v ...interface{}) error { 68 | if l < level { 69 | return nil 70 | } 71 | _, err := fmt.Fprintf(w, colorize(l, levelPrefix[l]+format), v...) 72 | return err 73 | } 74 | 75 | // Debug logs a debug message to stdout. 76 | func Debug(v ...interface{}) error { 77 | return output(os.Stdout, LevelDebug, v...) 78 | } 79 | 80 | // Debugf logs a formatted debug message to stdout. 81 | func Debugf(format string, v ...interface{}) error { 82 | return outputf(os.Stdout, LevelDebug, format, v...) 83 | } 84 | 85 | // Info logs an informational message to stdout. 86 | func Info(v ...interface{}) error { 87 | return output(os.Stdout, LevelInfo, v...) 88 | } 89 | 90 | // Infof logs a formatted informational message to stdout. 91 | func Infof(format string, v ...interface{}) error { 92 | return outputf(os.Stdout, LevelInfo, format, v...) 93 | } 94 | 95 | // Warn logs a warning message to stdout. 96 | func Warn(v ...interface{}) error { 97 | return output(os.Stdout, LevelWarn, v...) 98 | } 99 | 100 | // Warnf logs a formatted warning message to stdout. 101 | func Warnf(format string, v ...interface{}) error { 102 | return outputf(os.Stdout, LevelWarn, format, v...) 103 | } 104 | 105 | // Error logs an error message to stderr. 106 | func Error(v ...interface{}) error { 107 | return output(os.Stderr, LevelError, v...) 108 | } 109 | 110 | // Errorf logs a formatted error message to stderr. 111 | func Errorf(format string, v ...interface{}) error { 112 | return outputf(os.Stderr, LevelError, format, v...) 113 | } 114 | -------------------------------------------------------------------------------- /resolver/resolver.go: -------------------------------------------------------------------------------- 1 | // Package resolver provides a generic cookbook dependency resolver. 2 | package resolver 3 | 4 | import ( 5 | "errors" 6 | "io/ioutil" 7 | "path" 8 | "strings" 9 | 10 | "github.com/mlafeldt/chef-runner/chef/cookbook" 11 | "github.com/mlafeldt/chef-runner/log" 12 | "github.com/mlafeldt/chef-runner/resolver/berkshelf" 13 | "github.com/mlafeldt/chef-runner/resolver/dir" 14 | "github.com/mlafeldt/chef-runner/resolver/librarian" 15 | "github.com/mlafeldt/chef-runner/util" 16 | ) 17 | 18 | // A Resolver resolves cookbook dependencies and installs them to directory dst. 19 | // This is the interface that all resolvers need to implement. 20 | type Resolver interface { 21 | Resolve(dst string) error 22 | Name() string 23 | } 24 | 25 | func findResolverByName(name string) (Resolver, error) { 26 | var resolvers = [...]Resolver{ 27 | berkshelf.Resolver{}, 28 | librarian.Resolver{}, 29 | dir.Resolver{}, 30 | } 31 | for _, r := range resolvers { 32 | if strings.HasPrefix(strings.ToLower(r.Name()), strings.ToLower(name)) { 33 | return r, nil 34 | } 35 | } 36 | return nil, errors.New("unknown resolver name: " + name) 37 | } 38 | 39 | func findResolver(name, dst string) (Resolver, error) { 40 | if name != "" { 41 | return findResolverByName(name) 42 | } 43 | 44 | cb, _ := cookbook.NewCookbook(".") 45 | 46 | // If the current folder is a cookbook and its dependencies have 47 | // already been resolved, only update this cookbook with rsync. 48 | // TODO: improve this check by comparing timestamps etc. 49 | if cb.Name != "" && util.FileExist(dst) { 50 | return dir.Resolver{}, nil 51 | } 52 | 53 | if util.FileExist("Berksfile") { 54 | return berkshelf.Resolver{}, nil 55 | } 56 | 57 | if util.FileExist("Cheffile") { 58 | return librarian.Resolver{}, nil 59 | } 60 | 61 | if cb.Name != "" { 62 | return dir.Resolver{}, nil 63 | } 64 | 65 | log.Error("Berksfile, Cheffile, or metadata.rb must exist in current directory") 66 | return nil, errors.New("cookbooks could not be found") 67 | } 68 | 69 | func stripCookbooks(dst string) error { 70 | cookbookDirs, err := ioutil.ReadDir(dst) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | for _, dir := range cookbookDirs { 76 | if !dir.IsDir() { 77 | continue 78 | } 79 | cb := cookbook.Cookbook{Path: path.Join(dst, dir.Name())} 80 | if err := cb.Strip(); err != nil { 81 | return err 82 | } 83 | } 84 | 85 | return nil 86 | } 87 | 88 | // Resolve resolves cookbook dependencies using the named resolver. If no 89 | // resolver is specified, it will be guessed based on the files present in the 90 | // current directory. After resolving dependencies, all non-cookbook files will 91 | // be deleted as well. 92 | func Resolve(name, dst string) error { 93 | log.Debug("Preparing cookbooks") 94 | 95 | r, err := findResolver(name, dst) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | log.Infof("Installing cookbook dependencies with %s resolver\n", r.Name()) 101 | if err := r.Resolve(dst); err != nil { 102 | return err 103 | } 104 | 105 | log.Info("Stripping non-cookbook files") 106 | return stripCookbooks(dst) 107 | 108 | } 109 | 110 | // AutoResolve automatically resolves cookbook dependencies based on the files 111 | // present in the current directory. After resolving dependencies, all 112 | // non-cookbook files will be deleted as well. 113 | func AutoResolve(dst string) error { 114 | return Resolve("", dst) 115 | } 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chef-runner - The fastest way to run Chef cookbooks 2 | 3 | [![Build Status](https://travis-ci.org/mlafeldt/chef-runner.svg?branch=master)](https://travis-ci.org/mlafeldt/chef-runner) 4 | [![GoDoc](https://godoc.org/github.com/mlafeldt/chef-runner?status.svg)](https://godoc.org/github.com/mlafeldt/chef-runner) 5 | 6 | The goal of chef-runner is to speed up your Chef development and testing 7 | workflow by allowing you to change infrastructure code and get *immediate 8 | feedback*. 9 | 10 | chef-runner was originally developed as a fast alternative to the painfully slow 11 | `vagrant provision`. The tool has since evolved and can now be used to rapidly 12 | provision not only local Vagrant machines but also remote hosts like EC2 13 | instances. 14 | 15 | To further shorten the feedback loop, chef-runner [integrates with Vim][vim] so 16 | you don't have to leave your editor while hacking on recipes. 17 | 18 | For more background, check out my blog post ["Telling people about 19 | chef-runner"][blog]. 20 | 21 | ## Quick Start 22 | 23 | Install chef-runner using one of the [available installation methods][installation]. 24 | 25 | Use chef-runner for local cookbook development with Vagrant: 26 | 27 | $ cd my-awesome-cookook/ 28 | $ vagrant up 29 | $ chef-runner # will run recipes/default.rb inside the Vagrant machine 30 | 31 | Compose Chef run list using flexible recipe syntax: 32 | 33 | $ chef-runner recipes/foo.rb 34 | $ chef-runner ::foo # same as above 35 | $ chef-runner dogs::bar 36 | $ chef-runner dogs # same as dogs::default 37 | $ chef-runner recipes/foo.rb bar dogs::baz # will run recipes in given order 38 | $ chef-runner recipe[cats],dogs::bar # standard Chef syntax 39 | 40 | Provision a specific Vagrant machine in a multi-machine environment: 41 | 42 | $ chef-runner -M db ... 43 | 44 | Provision any Vagrant machine by specifying the machine's UUID: 45 | 46 | $ chef-runner -M a748337 ... 47 | 48 | Use chef-runner for local cookbook development with Test Kitchen: 49 | 50 | $ kitchen converge default-ubuntu-1404 51 | $ chef-runner -K default-ubuntu-1404 ... 52 | 53 | Use chef-runner as a general purpose Chef provisioner for any system reachable 54 | over SSH: 55 | 56 | $ cd directory-with-berksfile/ 57 | $ chef-runner -H user@example.local apt::default dogs::bar 58 | 59 | (chef-runner automatically resolves cookbook dependencies using tools like 60 | Berkshelf or Librarian-Chef.) 61 | 62 | Use chef-runner to provision the host system without running commands as root: 63 | 64 | $ chef-runner -L --sudo=false 65 | 66 | If required, install a specific version of Chef before provisioning: 67 | 68 | $ chef-runner -i 11.12.8 ... 69 | 70 | ## More Information 71 | 72 | * See the [chef-runner wiki][wiki] for the official documentation. 73 | * For bug reports and feature requests, please [open an issue here][issues]. 74 | * New releases are announced on [Twitter] and the [Chef Users mailing list][list]. 75 | 76 | ## License 77 | 78 | Please see [LICENSE](/LICENSE) for licensing details. 79 | 80 | ## Want to help? 81 | 82 | See the [Development] wiki page for details on how to get the source code and 83 | build chef-runner locally. 84 | 85 | ## Author 86 | 87 | chef-runner is being developed by [Mathias Lafeldt][twitter]. 88 | 89 | 90 | [blog]: http://mlafeldt.github.io/blog/telling-people-about-chef-runner/ 91 | [development]: https://github.com/mlafeldt/chef-runner/wiki/Development 92 | [installation]: https://github.com/mlafeldt/chef-runner/wiki/Installation 93 | [issues]: https://github.com/mlafeldt/chef-runner/issues 94 | [list]: https://groups.google.com/a/lists.chef.io/forum/#!forum/chef 95 | [twitter]: https://twitter.com/mlafeldt 96 | [vim]: https://github.com/mlafeldt/chef-runner/wiki/Vim 97 | [wiki]: https://github.com/mlafeldt/chef-runner/wiki 98 | -------------------------------------------------------------------------------- /openssh/openssh_test.go: -------------------------------------------------------------------------------- 1 | package openssh_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/mlafeldt/chef-runner/openssh" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var newClientTests = map[string]*Client{ 11 | "some-host": &Client{Host: "some-host"}, 12 | "some-user@some-host": &Client{Host: "some-host", User: "some-user"}, 13 | "some-host:1234": &Client{Host: "some-host", Port: 1234}, 14 | "some-user@some-host:1234": &Client{Host: "some-host", User: "some-user", Port: 1234}, 15 | } 16 | 17 | func TestNewClient(t *testing.T) { 18 | for host, client := range newClientTests { 19 | result, err := NewClient(host) 20 | assert.NoError(t, err) 21 | assert.Equal(t, client, result) 22 | } 23 | } 24 | 25 | func TestNewClient_InvalidPort(t *testing.T) { 26 | c, err := NewClient("some-host:abc") 27 | assert.EqualError(t, err, "invalid SSH port") 28 | assert.Nil(t, c) 29 | } 30 | 31 | var commandTests = []struct { 32 | client Client 33 | args []string 34 | result []string 35 | }{ 36 | { 37 | client: Client{}, 38 | args: []string{}, 39 | result: []string{"ssh"}, 40 | }, 41 | { 42 | client: Client{ 43 | Host: "some-host", 44 | }, 45 | args: []string{"uname", "-a"}, 46 | result: []string{"ssh", "some-host", "uname", "-a"}, 47 | }, 48 | { 49 | client: Client{ 50 | Host: "some-host", 51 | User: "some-user", 52 | }, 53 | args: []string{"uname", "-a"}, 54 | result: []string{"ssh", "-l", "some-user", "some-host", "uname", "-a"}, 55 | }, 56 | { 57 | client: Client{ 58 | Host: "some-host", 59 | Port: 1234, 60 | }, 61 | args: []string{"uname", "-a"}, 62 | result: []string{"ssh", "-p", "1234", "some-host", "uname", "-a"}, 63 | }, 64 | { 65 | client: Client{ 66 | Host: "some-host", 67 | PrivateKeys: []string{"some-key", "another-key"}, 68 | }, 69 | args: []string{"uname", "-a"}, 70 | result: []string{"ssh", "-i", "some-key", "-i", "another-key", 71 | "some-host", "uname", "-a"}, 72 | }, 73 | { 74 | client: Client{ 75 | Host: "some-host", 76 | Options: []string{ 77 | "SomeOption=yes", 78 | "AnotherOption 1 2 3", 79 | }, 80 | }, 81 | args: []string{"uname", "-a"}, 82 | result: []string{"ssh", "-o", "SomeOption=yes", "-o", "AnotherOption 1 2 3", 83 | "some-host", "uname", "-a"}, 84 | }, 85 | { 86 | client: Client{ 87 | Host: "some-host", 88 | User: "some-user", 89 | Port: 1234, 90 | PrivateKeys: []string{"some-key"}, 91 | Options: []string{"SomeOption=yes"}, 92 | }, 93 | args: []string{"uname", "-a"}, 94 | result: []string{"ssh", "-l", "some-user", "-p", "1234", 95 | "-i", "some-key", "-o", "SomeOption=yes", "some-host", 96 | "uname", "-a"}, 97 | }, 98 | { 99 | client: Client{ 100 | Host: "some-host", 101 | ConfigFile: "some/config/file", 102 | }, 103 | args: []string{"uname", "-a"}, 104 | result: []string{"ssh", "-F", "some/config/file", "some-host", 105 | "uname", "-a"}, 106 | }, 107 | } 108 | 109 | func TestCommand(t *testing.T) { 110 | for _, test := range commandTests { 111 | result := test.client.Command(test.args) 112 | assert.Equal(t, test.result, result) 113 | } 114 | } 115 | 116 | func TestRunCommand_MissingCommand(t *testing.T) { 117 | err := Client{Host: "some-host"}.RunCommand([]string{}) 118 | assert.EqualError(t, err, "no command given") 119 | } 120 | 121 | func TestRunCommand_MissingHost(t *testing.T) { 122 | err := Client{}.RunCommand([]string{"uname", "-a"}) 123 | assert.EqualError(t, err, "no host given") 124 | } 125 | 126 | var shellTests = []struct { 127 | client Client 128 | shell string 129 | }{ 130 | { 131 | Client{Host: "some-host", User: "some-user", Port: 1234}, 132 | "'ssh' '-l' 'some-user' '-p' '1234'", 133 | }, 134 | { 135 | Client{Host: "some-host", Options: []string{"x=1"}}, 136 | "'ssh' '-o' 'x=1'", 137 | }, 138 | { 139 | Client{Host: "some-host", Options: []string{"y 2 3"}}, 140 | "'ssh' '-o' 'y 2 3'", 141 | }, 142 | } 143 | 144 | func TestShell(t *testing.T) { 145 | for _, test := range shellTests { 146 | assert.Equal(t, test.shell, test.client.Shell()) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /cli/cli.go: -------------------------------------------------------------------------------- 1 | // Package cli handles the command line interface of chef-runner. This includes 2 | // parsing of options and arguments as well as printing help text. 3 | package cli 4 | 5 | import ( 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "os" 10 | "strings" 11 | 12 | "github.com/mlafeldt/chef-runner/log" 13 | ) 14 | 15 | var usage = `Usage: chef-runner [options] [--] [...] 16 | 17 | -H, --host Name of host reachable over SSH 18 | -M, --machine Name or UUID of Vagrant virtual machine 19 | -K, --kitchen Name of Test Kitchen instance 20 | -L, --local Provision host system 21 | 22 | --ssh