This commit adds a Chef Client provisioner

The commit is pretty complete and has a tested/working provisioner for
both SSH and WinRM. There are a few tests, but we maybe need another
few to have better coverage. Docs are also included…
This commit is contained in:
Sander van Harmelen 2015-05-08 13:45:31 +02:00
parent f1ae920aa9
commit 60984b2da2
12 changed files with 710 additions and 2 deletions

View File

@ -0,0 +1,15 @@
package main
import (
"github.com/hashicorp/terraform/builtin/provisioners/chef-client"
"github.com/hashicorp/terraform/plugin"
"github.com/hashicorp/terraform/terraform"
)
func main() {
plugin.Serve(&plugin.ServeOpts{
ProvisionerFunc: func() terraform.ResourceProvisioner {
return new(chefclient.ResourceProvisioner)
},
})
}

View File

@ -0,0 +1 @@
package main

View File

@ -0,0 +1,395 @@
package chefclient
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"path"
"strings"
"text/template"
"time"
"github.com/hashicorp/terraform/communicator"
"github.com/hashicorp/terraform/communicator/remote"
"github.com/hashicorp/terraform/terraform"
"github.com/mitchellh/go-linereader"
"github.com/mitchellh/mapstructure"
)
const (
firstBoot = "first-boot.json"
logfileDir = "logfiles"
linuxConfDir = "/etc/chef"
windowsConfDir = "C:/chef"
)
const clientConf = `
log_location STDOUT
chef_server_url "{{ .ServerURL }}"
validation_client_name "{{ .ValidationClientName }}"
node_name "{{ .NodeName }}"
{{ if .HTTPProxy }}
http_proxy "{{ .HTTPProxy }}"
ENV['http_proxy'] = "{{ .HTTPProxy }}"
ENV['HTTP_PROXY'] = "{{ .HTTPProxy }}"
{{ end }}
{{ if .HTTPSProxy }}
https_proxy "{{ .HTTPSProxy }}"
ENV['https_proxy'] = "{{ .HTTPSProxy }}"
ENV['HTTPS_PROXY'] = "{{ .HTTPSProxy }}"
{{ end }}
{{ if .NOProxy }}no_proxy "{{ join .NOProxy "," }}"{{ end }}
{{ if .SSLVerifyMode }}ssl_verify_mode {{ .SSLVerifyMode }}{{ end }}
`
// Provisioner represents a specificly configured chef provisioner
type Provisioner struct {
Attributes interface{} `mapstructure:"-"`
Environment string `mapstructure:"environment"`
LogToFile bool `mapstructure:"log_to_file"`
HTTPProxy string `mapstructure:"http_proxy"`
HTTPSProxy string `mapstructure:"https_proxy"`
NOProxy []string `mapstructure:"no_proxy"`
NodeName string `mapstructure:"node_name"`
PreventSudo bool `mapstructure:"prevent_sudo"`
RunList []string `mapstructure:"run_list"`
ServerURL string `mapstructure:"server_url"`
SkipInstall bool `mapstructure:"skip_install"`
SSLVerifyMode string `mapstructure:"ssl_verify_mode"`
ValidationClientName string `mapstructure:"validation_client_name"`
ValidationKeyPath string `mapstructure:"validation_key_path"`
Version string `mapstructure:"version"`
installChefClient func(terraform.UIOutput, communicator.Communicator) error
createConfigFiles func(terraform.UIOutput, communicator.Communicator) error
runChefClient func(terraform.UIOutput, communicator.Communicator) error
}
// ResourceProvisioner represents a generic chef provisioner
type ResourceProvisioner struct{}
// Apply executes the file provisioner
func (r *ResourceProvisioner) Apply(
o terraform.UIOutput,
s *terraform.InstanceState,
c *terraform.ResourceConfig) error {
// Decode the raw config for this provisioner
p, err := r.decodeConfig(c)
if err != nil {
return err
}
// Set some values based on the targeted OS
switch s.Ephemeral.ConnInfo["type"] {
case "ssh", "": // The default connection type is ssh, so if the type is empty use ssh
p.PreventSudo = p.PreventSudo || s.Ephemeral.ConnInfo["user"] == "root"
p.installChefClient = p.sshInstallChefClient
p.createConfigFiles = p.sshCreateConfigFiles
p.runChefClient = p.runChefClientFunc(linuxConfDir)
case "winrm":
p.PreventSudo = true
p.installChefClient = p.winrmInstallChefClient
p.createConfigFiles = p.winrmCreateConfigFiles
p.runChefClient = p.runChefClientFunc(windowsConfDir)
default:
return fmt.Errorf("Unsupported connection type: %s", s.Ephemeral.ConnInfo["type"])
}
// Get a new communicator
comm, err := communicator.New(s)
if err != nil {
return err
}
// Wait and retry until we establish the connection
err = retryFunc(comm.Timeout(), func() error {
err := comm.Connect(o)
return err
})
if err != nil {
return err
}
defer comm.Disconnect()
if !p.SkipInstall {
if err := p.installChefClient(o, comm); err != nil {
return err
}
}
o.Output("Creating configuration files...")
if err := p.createConfigFiles(o, comm); err != nil {
return err
}
o.Output("Starting initial Chef-Client run...")
if err := p.runChefClient(o, comm); err != nil {
return err
}
return nil
}
// Validate checks if the required arguments are configured
func (r *ResourceProvisioner) Validate(c *terraform.ResourceConfig) (ws []string, es []error) {
p, err := r.decodeConfig(c)
if err != nil {
es = append(es, err)
return ws, es
}
if p.NodeName == "" {
es = append(es, fmt.Errorf("Key not found: node_name"))
}
if p.RunList == nil {
es = append(es, fmt.Errorf("Key not found: run_list"))
}
if p.ServerURL == "" {
es = append(es, fmt.Errorf("Key not found: server_url"))
}
if p.ValidationClientName == "" {
es = append(es, fmt.Errorf("Key not found: validation_client_name"))
}
if p.ValidationKeyPath == "" {
es = append(es, fmt.Errorf("Key not found: validation_key_path"))
}
return ws, es
}
func (r *ResourceProvisioner) decodeConfig(c *terraform.ResourceConfig) (*Provisioner, error) {
p := new(Provisioner)
decConf := &mapstructure.DecoderConfig{
WeaklyTypedInput: true,
Result: p,
}
dec, err := mapstructure.NewDecoder(decConf)
if err != nil {
return nil, err
}
if err := dec.Decode(c.Raw); err != nil {
return nil, err
}
if p.Environment == "" {
p.Environment = "_default"
}
if attrs, ok := c.Raw["attributes"]; ok {
p.Attributes, err = rawToJSON(attrs)
if err != nil {
return nil, fmt.Errorf("Error parsing the attributes: %v", err)
}
}
return p, nil
}
func rawToJSON(raw interface{}) (interface{}, error) {
switch s := raw.(type) {
case []map[string]interface{}:
if len(s) != 1 {
return nil, errors.New("unexpected input while parsing raw config to JSON")
}
var err error
for k, v := range s[0] {
s[0][k], err = rawToJSON(v)
if err != nil {
return nil, err
}
}
return s[0], nil
default:
return raw, nil
}
}
// retryFunc is used to retry a function for a given duration
func retryFunc(timeout time.Duration, f func() error) error {
finish := time.After(timeout)
for {
err := f()
if err == nil {
return nil
}
log.Printf("Retryable error: %v", err)
select {
case <-finish:
return err
case <-time.After(3 * time.Second):
}
}
}
func (p *Provisioner) runChefClientFunc(
confDir string) func(terraform.UIOutput, communicator.Communicator) error {
return func(o terraform.UIOutput, comm communicator.Communicator) error {
fb := path.Join(confDir, firstBoot)
cmd := fmt.Sprintf("chef-client -j %q -E %q", fb, p.Environment)
if p.LogToFile {
if err := os.MkdirAll(logfileDir, 0777); err != nil {
return fmt.Errorf("Error creating logfile directory %s: %v", logfileDir, err)
}
logFile := path.Join(logfileDir, p.NodeName)
f, err := os.Create(path.Join(logFile))
if err != nil {
return fmt.Errorf("Error creating logfile %s: %v", logFile, err)
}
f.Close()
o.Output("Writing Chef Client output to " + logFile)
o = p
}
return p.runCommand(o, comm, cmd)
}
}
// Output implementation of terraform.UIOutput interface
func (p *Provisioner) Output(output string) {
logFile := path.Join(logfileDir, p.NodeName)
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_WRONLY, 0666)
if err != nil {
log.Printf("Error creating logfile %s: %v", logFile, err)
return
}
defer f.Close()
output = strings.Replace(output, "\r", "\n", -1)
if _, err := f.WriteString(output); err != nil {
log.Printf("Error writing output to logfile %s: %v", logFile, err)
}
if err := f.Sync(); err != nil {
log.Printf("Error saving logfile %s to disk: %v", logFile, err)
}
}
func (p *Provisioner) deployConfigFiles(
o terraform.UIOutput,
comm communicator.Communicator,
confDir string) error {
// Open the validation .pem file
f, err := os.Open(p.ValidationKeyPath)
if err != nil {
return err
}
defer f.Close()
// Copy the validation .pem to the new instance
if err := comm.Upload(path.Join(confDir, "validation.pem"), f); err != nil {
return fmt.Errorf("Uploading validation.pem failed: %v", err)
}
// Make strings.Join available for use within the template
funcMap := template.FuncMap{
"join": strings.Join,
}
// Create a new template and parse the client.rb into it
t := template.Must(template.New("client.rb").Funcs(funcMap).Parse(clientConf))
var buf bytes.Buffer
err = t.Execute(&buf, p)
if err != nil {
return fmt.Errorf("Error executing client.rb template: %s", err)
}
// Copy the client.rb to the new instance
if err := comm.Upload(path.Join(confDir, "client.rb"), &buf); err != nil {
return fmt.Errorf("Uploading client.rb failed: %v", err)
}
// Create a map with first boot settings
fb := make(map[string]interface{})
if p.Attributes != nil {
fb = p.Attributes.(map[string]interface{})
}
// Add the initial runlist to the first boot settings
fb["run_list"] = p.RunList
// Marshal the first boot settings to JSON
d, err := json.Marshal(fb)
if err != nil {
return fmt.Errorf("Failed to create %s data: %s", firstBoot, err)
}
// Copy the first-boot.json to the new instance
if err := comm.Upload(path.Join(confDir, firstBoot), bytes.NewReader(d)); err != nil {
return fmt.Errorf("Uploading first-boot.json failed: %v", err)
}
return nil
}
// runCommand is used to run already prepared commands
func (p *Provisioner) runCommand(
o terraform.UIOutput,
comm communicator.Communicator,
command string) error {
var err error
// Unless prevented, prefix the command with sudo
if !p.PreventSudo {
command = "sudo " + command
}
outR, outW := io.Pipe()
errR, errW := io.Pipe()
outDoneCh := make(chan struct{})
errDoneCh := make(chan struct{})
go p.copyOutput(o, outR, outDoneCh)
go p.copyOutput(o, errR, errDoneCh)
cmd := &remote.Cmd{
Command: command,
Stdout: outW,
Stderr: errW,
}
if err := comm.Start(cmd); err != nil {
return fmt.Errorf("Error executing command %q: %v", cmd.Command, err)
}
cmd.Wait()
if cmd.ExitStatus != 0 {
err = fmt.Errorf(
"Command %q exited with non-zero exit status: %d", cmd.Command, cmd.ExitStatus)
}
// Wait for output to clean up
outW.Close()
errW.Close()
<-outDoneCh
<-errDoneCh
// If we have an error, return it out now that we've cleaned up
if err != nil {
return err
}
return nil
}
func (p *Provisioner) copyOutput(o terraform.UIOutput, r io.Reader, doneCh chan<- struct{}) {
defer close(doneCh)
lr := linereader.New(r)
for line := range lr.Ch {
o.Output(line)
}
}

View File

@ -0,0 +1,56 @@
package chefclient
import (
"testing"
"github.com/hashicorp/terraform/config"
"github.com/hashicorp/terraform/terraform"
)
func TestResourceProvisioner_impl(t *testing.T) {
var _ terraform.ResourceProvisioner = new(ResourceProvisioner)
}
func TestResourceProvider_Validate_good(t *testing.T) {
c := testConfig(t, map[string]interface{}{
"package": "https://opscode-omnibus-packages.s3.amazonaws.com/el/6/x86_64/chef-11.18.6-1.el6.x86_64.rpm",
"run_list": []interface{}{"cookbook::recipe"},
"node_name": "nodename1",
"environment": "_default",
"server_url": "https://chef.local",
"validation_client_name": "validator",
"validation_key_path": "validator.pem",
"attributes": []interface{}{"key1 { subkey1 = value1 }"},
})
p := new(ResourceProvisioner)
warn, errs := p.Validate(c)
if len(warn) > 0 {
t.Fatalf("Warnings: %v", warn)
}
if len(errs) > 0 {
t.Fatalf("Errors: %v", errs)
}
}
func TestResourceProvider_Validate_bad(t *testing.T) {
c := testConfig(t, map[string]interface{}{
"package": "nope",
})
p := new(ResourceProvisioner)
warn, errs := p.Validate(c)
if len(warn) > 0 {
t.Fatalf("Warnings: %v", warn)
}
if len(errs) == 0 {
t.Fatalf("Should have errors")
}
}
func testConfig(t *testing.T, c map[string]interface{}) *terraform.ResourceConfig {
r, err := config.NewRawConfig(c)
if err != nil {
t.Fatalf("bad: %s", err)
}
return terraform.NewResourceConfig(r)
}

View File

@ -0,0 +1,70 @@
package chefclient
import (
"bytes"
"fmt"
"strings"
"github.com/hashicorp/terraform/communicator"
"github.com/hashicorp/terraform/terraform"
)
func (p *Provisioner) sshInstallChefClient(
o terraform.UIOutput,
comm communicator.Communicator) error {
var installCmd bytes.Buffer
// Build up a single command based on the given config options
installCmd.WriteString("curl")
if p.HTTPProxy != "" {
installCmd.WriteString(" --proxy " + p.HTTPProxy)
}
if p.NOProxy != nil {
installCmd.WriteString(" --noproxy " + strings.Join(p.NOProxy, ","))
}
installCmd.WriteString(" -LO https://www.chef.io/chef/install.sh 2>/dev/null &&")
if !p.PreventSudo {
installCmd.WriteString(" sudo")
}
installCmd.WriteString(" bash ./install.sh")
if p.Version != "" {
installCmd.WriteString(" -v " + p.Version)
}
installCmd.WriteString(" && rm -f install.sh")
// Execute the command to install Chef Client
return p.runCommand(o, comm, installCmd.String())
}
func (p *Provisioner) sshCreateConfigFiles(
o terraform.UIOutput,
comm communicator.Communicator) error {
// Make sure the config directory exists
cmd := fmt.Sprintf("mkdir -p %q", linuxConfDir)
if err := p.runCommand(o, comm, cmd); err != nil {
return err
}
// Make sure we have enough rights to upload the files if using sudo
if !p.PreventSudo {
if err := p.runCommand(o, comm, "chmod 777 "+linuxConfDir); err != nil {
return err
}
}
if err := p.deployConfigFiles(o, comm, linuxConfDir); err != nil {
return err
}
// When done copying the files restore the rights and make sure root is owner
if !p.PreventSudo {
if err := p.runCommand(o, comm, "chmod 755 "+linuxConfDir); err != nil {
return err
}
if err := p.runCommand(o, comm, "chown -R root.root "+linuxConfDir); err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1 @@
package chefclient

View File

@ -0,0 +1,75 @@
package chefclient
import (
"fmt"
"path"
"strings"
"github.com/hashicorp/terraform/communicator"
"github.com/hashicorp/terraform/terraform"
)
const installScript = `
$winver = [System.Environment]::OSVersion.Version | %% {"{0}.{1}" -f $_.Major,$_.Minor}
switch ($winver)
{
"6.0" {$machine_os = "2008"}
"6.1" {$machine_os = "2008r2"}
"6.2" {$machine_os = "2012"}
"6.3" {$machine_os = "2012"}
default {$machine_os = "2008r2"}
}
if ([System.IntPtr]::Size -eq 4) {$machine_arch = "i686"} else {$machine_arch = "x86_64"}
$url = "http://www.chef.io/chef/download?p=windows&pv=$machine_os&m=$machine_arch&v=%s"
$dest = [System.IO.Path]::GetTempFileName()
$dest = [System.IO.Path]::ChangeExtension($dest, ".msi")
$downloader = New-Object System.Net.WebClient
$http_proxy = '%s'
if ($http_proxy -ne '') {
$no_proxy = '%s'
if ($no_proxy -eq ''){
$no_proxy = "127.0.0.1"
}
$proxy = New-Object System.Net.WebProxy($http_proxy, $true, ,$no_proxy.Split(','))
$downloader.proxy = $proxy
}
Write-Host 'Downloading Chef Client...'
$downloader.DownloadFile($url, $dest)
Write-Host 'Installing Chef Client...'
Start-Process -FilePath msiexec -ArgumentList /qn, /i, $dest -Wait
`
func (p *Provisioner) winrmInstallChefClient(
o terraform.UIOutput,
comm communicator.Communicator) error {
script := path.Join(path.Dir(comm.ScriptPath()), "chefclient.ps1")
content := fmt.Sprintf(installScript, p.Version, p.HTTPProxy, strings.Join(p.NOProxy, ","))
// Copy the script to the new instance
if err := comm.UploadScript(script, strings.NewReader(content)); err != nil {
return fmt.Errorf("Uploading client.rb failed: %v", err)
}
// Execute the script to install Chef Client
installCmd := fmt.Sprintf("powershell -NoProfile -ExecutionPolicy Bypass -File %s", script)
return p.runCommand(o, comm, installCmd)
}
func (p *Provisioner) winrmCreateConfigFiles(
o terraform.UIOutput,
comm communicator.Communicator) error {
// Make sure the config directory exists
cmd := fmt.Sprintf("if not exist %q mkdir %q", windowsConfDir, windowsConfDir)
if err := p.runCommand(o, comm, cmd); err != nil {
return err
}
return p.deployConfigFiles(o, comm, windowsConfDir)
}

View File

@ -0,0 +1 @@
package chefclient

View File

@ -0,0 +1,90 @@
---
layout: "docs"
page_title: "Provisioner: chef-client"
sidebar_current: "docs-provisioners-chef-client"
description: |-
The `chef-client` provisioner invokes a Chef Client run on a remote resource after first installing and configuring Chef Client on the remote resource. The `chef-client` provisioner supports both `ssh` and `winrm` type connections.
---
# chef Provisioner
The `chef-client` provisioner invokes a Chef Client run on a remote resource after first
installing and configuring Chef Client on the remote resource. The `chef-client` provisioner
supports both `ssh` and `winrm` type [connections](/docs/provisioners/connection.html).
## Example usage
```
# Start a initial chef run on a resource
resource "aws_instance" "web" {
...
provisioner "chef-client" {
attributes {
"key" = "value"
"app" {
"cluster1" {
"nodes" = ["webserver1", webserver2]
}
}
}
environment = "_default"
run_list = ["cookbook::recipe"]
node_name = "webserver1"
server_url = "https://chef.company.com/organizations/org1"
validation_client_name = "chef-validator"
validation_key_path = "../chef-validator.pem"
version = "11.18.6"
}
}
```
## Argument Reference
The following arguments are supported:
* `attributes (hash)` - (Optional) A hash with initial node attributes for the new node.
See example.
* `environment (string)` - (Optional) The Chef environment the new node will be joining
(defaults `_default`).
* `log_to_file (boolean)` - (Optional) If true, the output of the initial Chef Client run
will be logged to a local file instead of the console. The file will be created in a
subdirectory called `logfiles` created in your current directory. The filename will be
the `node_name` of the new node.
* `http_proxy (string)` - (Optional) The proxy server for Chef Client HTTP connections.
* `https_proxy (string)` - (Optional) The proxy server for Chef Client HTTPS connections.
* `no_proxy (array)` - (Optional) A list of URLs that should bypass the proxy.
* `node_name (string)` - (Required) The name of the node to register with the Chef Server.
* `prevent_sudo (boolean)` - (Optional) Prevent the use of sudo while installing, configuring
and running the initial Chef Client run. This option is only used with `ssh` type
[connections](/docs/provisioners/connection.html).
* `run_list (array)` - (Required) A list with recipes that will be invoked during the initial
Chef Client run. The run-list will also be saved to the Chef Server after a successful
initial run.
* `server_url (string)` - (Required) The URL to the Chef server. This includes the path to
the organization. See the example.
* `skip_install (boolean)` - (Optional) Skip the installation of Chef Client on the remote
machine. This assumes Chef Client is already installed when you run the `chef-client`
provisioner.
* `ssl_verify_mode (string)` - (Optional) Use to set the verify mode for Chef Client HTTPS
requests.
* `validation_client_name (string)` - (Required) The name of the validation client to use
for the initial communication with the Chef Server.
* `validation_key_path (string)` - (Required) The path to the validation key that is needed
by the node to register itself with the Chef Server. The key will be uploaded to the remote
machine.
* `version (string)` - (Optional) The Chef Client version to install on the remote machine.
If not set the latest available version will be installed.

View File

@ -3,7 +3,7 @@ layout: "docs"
page_title: "Provisioner: file"
sidebar_current: "docs-provisioners-file"
description: |-
The `file` provisioner is used to copy files or directories from the machine executing Terraform to the newly created resource. The `file` provisioner only supports `ssh` type connections.
The `file` provisioner is used to copy files or directories from the machine executing Terraform to the newly created resource. The `file` provisioner supports both `ssh` and `winrm` type connections.
---
# File Provisioner

View File

@ -3,7 +3,7 @@ layout: "docs"
page_title: "Provisioner: remote-exec"
sidebar_current: "docs-provisioners-remote"
description: |-
The `remote-exec` provisioner invokes a script on a remote resource after it is created. This can be used to run a configuration management tool, bootstrap into a cluster, etc. To invoke a local process, see the `local-exec` provisioner instead. The `remote-exec` provisioner only supports `ssh` type connections.
The `remote-exec` provisioner invokes a script on a remote resource after it is created. This can be used to run a configuration management tool, bootstrap into a cluster, etc. To invoke a local process, see the `local-exec` provisioner instead. The `remote-exec` provisioner supports both `ssh` and `winrm` type connections.
---
# remote-exec Provisioner

View File

@ -178,6 +178,10 @@
<li<%= sidebar_current("docs-provisioners") %>>
<a href="/docs/provisioners/index.html">Provisioners</a>
<ul class="nav">
<li<%= sidebar_current("docs-provisioners-chef-client") %>>
+ <a href="/docs/provisioners/chef-client.html">chef-client</a>
+ </li>
<li<%= sidebar_current("docs-provisioners-connection") %>>
<a href="/docs/provisioners/connection.html">connection</a>
</li>