diff --git a/common/step_provision.go b/common/step_provision.go index 888bea431..954544fed 100644 --- a/common/step_provision.go +++ b/common/step_provision.go @@ -63,6 +63,7 @@ func PopulateProvisionHookData(state multistep.StateBag) map[string]interface{} hookData["ConnType"] = commConf.Type hookData["SSHPublicKey"] = string(commConf.SSHPublicKey) hookData["SSHPrivateKey"] = string(commConf.SSHPrivateKey) + hookData["SSHPrivateKeyFile"] = commConf.SSHPrivateKeyFile // Backwards compatibility; in practice, WinRMPassword is fulfilled by // Password. diff --git a/provisioner/ansible/provisioner.go b/provisioner/ansible/provisioner.go index 699790940..41cb51900 100644 --- a/provisioner/ansible/provisioner.go +++ b/provisioner/ansible/provisioner.go @@ -67,6 +67,9 @@ type Config struct { GalaxyCommand string `mapstructure:"galaxy_command"` GalaxyForceInstall bool `mapstructure:"galaxy_force_install"` RolesPath string `mapstructure:"roles_path"` + //TODO: change default to false in v1.6.0. + UseProxy config.Trilean `mapstructure:"use_proxy"` + userWasEmpty bool } type Provisioner struct { @@ -76,6 +79,9 @@ type Provisioner struct { ansibleVersion string ansibleMajVersion uint generatedData map[string]interface{} + + setupAdapterFunc func(ui packer.Ui, comm packer.Communicator) (string, error) + executeAnsibleFunc func(ui packer.Ui, comm packer.Communicator, privKeyFile string) error } func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec { return p.config.FlatMapstructure().HCL2Spec() } @@ -163,6 +169,7 @@ func (p *Provisioner) Prepare(raws ...interface{}) error { } if p.config.User == "" { + p.config.userWasEmpty = true usr, err := user.Current() if err != nil { errs = packer.MultiErrorAppend(errs, err) @@ -174,6 +181,16 @@ func (p *Provisioner) Prepare(raws ...interface{}) error { errs = packer.MultiErrorAppend(errs, fmt.Errorf("user: could not determine current user from environment.")) } + // These fields exist so that we can replace the functions for testing + // logic inside of the Provision func; in actual use, these don't ever + // need to get set. + if p.setupAdapterFunc == nil { + p.setupAdapterFunc = p.setupAdapter + } + if p.executeAnsibleFunc == nil { + p.executeAnsibleFunc = p.executeAnsible + } + if errs != nil && len(errs.Errors) > 0 { return errs } @@ -207,40 +224,17 @@ func (p *Provisioner) getVersion() error { return nil } -func (p *Provisioner) Provision(ctx context.Context, ui packer.Ui, comm packer.Communicator, generatedData map[string]interface{}) error { - ui.Say("Provisioning with Ansible...") - // Interpolate env vars to check for generated values like password and port - p.generatedData = generatedData - p.config.ctx.Data = generatedData - for i, envVar := range p.config.AnsibleEnvVars { - envVar, err := interpolate.Render(envVar, &p.config.ctx) - if err != nil { - return fmt.Errorf("Could not interpolate ansible env vars: %s", err) - } - p.config.AnsibleEnvVars[i] = envVar - } - // Interpolate extra vars to check for generated values like password and port - for i, arg := range p.config.ExtraArguments { - arg, err := interpolate.Render(arg, &p.config.ctx) - if err != nil { - return fmt.Errorf("Could not interpolate ansible env vars: %s", err) - } - p.config.ExtraArguments[i] = arg - } +func (p *Provisioner) setupAdapter(ui packer.Ui, comm packer.Communicator) (string, error) { + ui.Message("Setting up proxy adapter for Ansible....") k, err := newUserKey(p.config.SSHAuthorizedKeyFile) if err != nil { - return err + return "", err } hostSigner, err := newSigner(p.config.SSHHostKeyFile) if err != nil { - return fmt.Errorf("error creating host signer: %s", err) - } - - // Remove the private key file - if len(k.privKeyFile) > 0 { - defer os.Remove(k.privKeyFile) + return "", fmt.Errorf("error creating host signer: %s", err) } keyChecker := ssh.CertChecker{ @@ -298,7 +292,7 @@ func (p *Provisioner) Provision(ctx context.Context, ui packer.Ui, comm packer.C }() if err != nil { - return err + return "", err } ui = &packer.SafeUi{ @@ -307,50 +301,155 @@ func (p *Provisioner) Provision(ctx context.Context, ui packer.Ui, comm packer.C } p.adapter = adapter.NewAdapter(p.done, localListener, config, p.config.SFTPCmd, ui, comm) - defer func() { - log.Print("shutting down the SSH proxy") - close(p.done) - p.adapter.Shutdown() - }() + return k.privKeyFile, nil +} - go p.adapter.Serve() +func (p *Provisioner) createInventoryFile(hostIP string, hostPort int) error { + log.Printf("Creating inventory file for Ansible run...") + tf, err := ioutil.TempFile(p.config.InventoryDirectory, "packer-provisioner-ansible") + if err != nil { + return fmt.Errorf("Error preparing inventory file: %s", err) + } + host := fmt.Sprintf("%s ansible_host=%s ansible_user=%s ansible_port=%d\n", + p.config.HostAlias, hostIP, p.config.User, hostPort) + if p.ansibleMajVersion < 2 { + host = fmt.Sprintf("%s ansible_ssh_host=%s ansible_ssh_user=%s ansible_ssh_port=%d\n", + p.config.HostAlias, hostIP, p.config.User, hostPort) + } + + w := bufio.NewWriter(tf) + w.WriteString(host) + + for _, group := range p.config.Groups { + fmt.Fprintf(w, "[%s]\n%s", group, host) + } + + for _, group := range p.config.EmptyGroups { + fmt.Fprintf(w, "[%s]\n", group) + } + + if err := w.Flush(); err != nil { + tf.Close() + os.Remove(tf.Name()) + return fmt.Errorf("Error preparing inventory file: %s", err) + } + tf.Close() + p.config.InventoryFile = tf.Name() + + return nil +} + +func (p *Provisioner) Provision(ctx context.Context, ui packer.Ui, comm packer.Communicator, generatedData map[string]interface{}) error { + ui.Say("Provisioning with Ansible...") + // Interpolate env vars to check for generated values like password and port + p.generatedData = generatedData + p.config.ctx.Data = generatedData + for i, envVar := range p.config.AnsibleEnvVars { + envVar, err := interpolate.Render(envVar, &p.config.ctx) + if err != nil { + return fmt.Errorf("Could not interpolate ansible env vars: %s", err) + } + p.config.AnsibleEnvVars[i] = envVar + } + // Interpolate extra vars to check for generated values like password and port + for i, arg := range p.config.ExtraArguments { + arg, err := interpolate.Render(arg, &p.config.ctx) + if err != nil { + return fmt.Errorf("Could not interpolate ansible env vars: %s", err) + } + p.config.ExtraArguments[i] = arg + } + + // Set up proxy if there's no host IP to access, regardless of user config. + hostIP := generatedData["Host"].(string) + + if hostIP == "" && p.config.UseProxy.False() { + ui.Error("Warning: use_proxy is false, but instance does" + + " not have an IP address to give to Ansible. Falling back" + + " to use localhost proxy.") + p.config.UseProxy = config.TriTrue + } + + privKeyFile := "" + if !p.config.UseProxy.False() { + // We set up the proxy if useProxy is either true or unset. + pkf, err := p.setupAdapterFunc(ui, comm) + if err != nil { + return err + } + // This is necessary to avoid accidentally redeclaring + // privKeyFile in the scope of this if statement. + privKeyFile = pkf + + defer func() { + log.Print("shutting down the SSH proxy") + close(p.done) + p.adapter.Shutdown() + }() + + go p.adapter.Serve() + + // Remove the private key file + if len(privKeyFile) > 0 { + defer os.Remove(privKeyFile) + } + } else { + ui.Message("Not using Proxy adapter for Ansible run:\n" + + "\tUsing ssh keys from Packer communicator...") + // In this situation, we need to make sure we have the + // private key we actually use to access the instance. + SSHPrivateKeyFile := generatedData["SSHPrivateKeyFile"].(string) + if SSHPrivateKeyFile != "" { + privKeyFile = SSHPrivateKeyFile + } else { + // See if we can get a private key and write that to a tmpfile + SSHPrivateKey := generatedData["SSHPrivateKey"].([]byte) + tmpSSHPrivateKey, err := tmp.File("ansible-key") + if err != nil { + return fmt.Errorf("Error writing private key to temp file for"+ + "ansible connection: %v", err) + } + _, err = tmpSSHPrivateKey.Write(SSHPrivateKey) + if err != nil { + return errors.New("failed to write private key to temp file") + } + err = tmpSSHPrivateKey.Close() + if err != nil { + return errors.New("failed to close private key temp file") + } + privKeyFile = tmpSSHPrivateKey.Name() + } + + // Also make sure that the username matches the SSH keys given. + if p.config.userWasEmpty { + p.config.User = generatedData["User"].(string) + } + } if len(p.config.InventoryFile) == 0 { - tf, err := ioutil.TempFile(p.config.InventoryDirectory, "packer-provisioner-ansible") + hostIP = "127.0.0.1" + hostPort := p.config.LocalPort + if p.config.UseProxy.False() { + // We aren't using a proxy, so we need to retrieve the + // host IP and port from generated data. + hostIP = generatedData["Host"].(string) + hostPort = int(generatedData["Port"].(int64)) + } + + // Create the inventory file + err := p.createInventoryFile(hostIP, hostPort) if err != nil { - return fmt.Errorf("Error preparing inventory file: %s", err) - } - defer os.Remove(tf.Name()) - - host := fmt.Sprintf("%s ansible_host=127.0.0.1 ansible_user=%s ansible_port=%d\n", - p.config.HostAlias, p.config.User, p.config.LocalPort) - if p.ansibleMajVersion < 2 { - host = fmt.Sprintf("%s ansible_ssh_host=127.0.0.1 ansible_ssh_user=%s ansible_ssh_port=%d\n", - p.config.HostAlias, p.config.User, p.config.LocalPort) + return err } - w := bufio.NewWriter(tf) - w.WriteString(host) - for _, group := range p.config.Groups { - fmt.Fprintf(w, "[%s]\n%s", group, host) - } - - for _, group := range p.config.EmptyGroups { - fmt.Fprintf(w, "[%s]\n", group) - } - - if err := w.Flush(); err != nil { - tf.Close() - return fmt.Errorf("Error preparing inventory file: %s", err) - } - tf.Close() - p.config.InventoryFile = tf.Name() + // Delete the generated inventory file defer func() { + os.Remove(p.config.InventoryFile) p.config.InventoryFile = "" }() } - if err := p.executeAnsible(ui, comm, k.privKeyFile); err != nil { + if err := p.executeAnsibleFunc(ui, comm, privKeyFile); err != nil { return fmt.Errorf("Error executing Ansible: %s", err) } diff --git a/provisioner/ansible/provisioner.hcl2spec.go b/provisioner/ansible/provisioner.hcl2spec.go index dbee849a1..e0b808af9 100644 --- a/provisioner/ansible/provisioner.hcl2spec.go +++ b/provisioner/ansible/provisioner.hcl2spec.go @@ -36,6 +36,7 @@ type FlatConfig struct { GalaxyCommand *string `mapstructure:"galaxy_command" cty:"galaxy_command"` GalaxyForceInstall *bool `mapstructure:"galaxy_force_install" cty:"galaxy_force_install"` RolesPath *string `mapstructure:"roles_path" cty:"roles_path"` + UseProxy *bool `mapstructure:"use_proxy" cty:"use_proxy"` } // FlatMapstructure returns a new FlatConfig. @@ -77,6 +78,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "galaxy_command": &hcldec.AttrSpec{Name: "galaxy_command", Type: cty.String, Required: false}, "galaxy_force_install": &hcldec.AttrSpec{Name: "galaxy_force_install", Type: cty.Bool, Required: false}, "roles_path": &hcldec.AttrSpec{Name: "roles_path", Type: cty.String, Required: false}, + "use_proxy": &hcldec.AttrSpec{Name: "use_proxy", Type: cty.Bool, Required: false}, } return s } diff --git a/provisioner/ansible/provisioner_test.go b/provisioner/ansible/provisioner_test.go index 39035d4ed..295e38699 100644 --- a/provisioner/ansible/provisioner_test.go +++ b/provisioner/ansible/provisioner_test.go @@ -14,9 +14,32 @@ import ( "strings" "testing" + confighelper "github.com/hashicorp/packer/helper/config" "github.com/hashicorp/packer/packer" ) +type provisionLogicTracker struct { + setupAdapterCalled bool + executeAnsibleCalled bool + happyPath bool +} + +func (l *provisionLogicTracker) setupAdapter(ui packer.Ui, comm packer.Communicator) (string, error) { + l.setupAdapterCalled = true + if l.happyPath { + return "fakeKeyString", nil + } + return "", fmt.Errorf("chose sadpath") +} + +func (l *provisionLogicTracker) executeAnsible(ui packer.Ui, comm packer.Communicator, privKeyFile string) error { + l.executeAnsibleCalled = true + if l.happyPath { + return fmt.Errorf("Chose sadpath") + } + return nil +} + // Be sure to remove the Ansible stub file in each test with: // defer os.Remove(config["command"].(string)) func testConfig(t *testing.T) map[string]interface{} { @@ -354,3 +377,220 @@ func TestAnsibleLongMessages(t *testing.T) { t.Fatalf("err: %s", err) } } + +func TestCreateInventoryFile_vers1(t *testing.T) { + var p Provisioner + p.Prepare(testConfig(t)) + defer os.Remove(p.config.Command) + p.ansibleMajVersion = 1 + p.config.User = "testuser" + + err := p.createInventoryFile("123.45.67.89", 2222) + if err != nil { + t.Fatalf("error creating config using localhost and local port proxy") + } + if p.config.InventoryFile == "" { + t.Fatalf("No inventory file was created") + } + defer os.Remove(p.config.InventoryFile) + f, err := ioutil.ReadFile(p.config.InventoryFile) + if err != nil { + t.Fatalf("couldn't read created inventoryfile: %s", err) + } + + expected := "default ansible_ssh_host=123.45.67.89 ansible_ssh_user=testuser ansible_ssh_port=2222\n" + if fmt.Sprintf("%s", f) != expected { + t.Fatalf("File didn't match expected:\n\n expected: \n%s\n; recieved: \n%s\n", expected, f) + } +} + +func TestCreateInventoryFile_vers2(t *testing.T) { + var p Provisioner + p.Prepare(testConfig(t)) + defer os.Remove(p.config.Command) + p.ansibleMajVersion = 2 + p.config.User = "testuser" + + err := p.createInventoryFile("123.45.67.89", 1234) + if err != nil { + t.Fatalf("error creating config using localhost and local port proxy") + } + if p.config.InventoryFile == "" { + t.Fatalf("No inventory file was created") + } + defer os.Remove(p.config.InventoryFile) + f, err := ioutil.ReadFile(p.config.InventoryFile) + if err != nil { + t.Fatalf("couldn't read created inventoryfile: %s", err) + } + expected := "default ansible_host=123.45.67.89 ansible_user=testuser ansible_port=1234\n" + if fmt.Sprintf("%s", f) != expected { + t.Fatalf("File didn't match expected:\n\n expected: \n%s\n; recieved: \n%s\n", expected, f) + } +} + +func TestCreateInventoryFile_Groups(t *testing.T) { + var p Provisioner + p.Prepare(testConfig(t)) + defer os.Remove(p.config.Command) + p.ansibleMajVersion = 1 + p.config.User = "testuser" + p.config.Groups = []string{"Group1", "Group2"} + + err := p.createInventoryFile("123.45.67.89", 1234) + if err != nil { + t.Fatalf("error creating config using localhost and local port proxy") + } + if p.config.InventoryFile == "" { + t.Fatalf("No inventory file was created") + } + defer os.Remove(p.config.InventoryFile) + f, err := ioutil.ReadFile(p.config.InventoryFile) + if err != nil { + t.Fatalf("couldn't read created inventoryfile: %s", err) + } + expected := `default ansible_ssh_host=123.45.67.89 ansible_ssh_user=testuser ansible_ssh_port=1234 +[Group1] +default ansible_ssh_host=123.45.67.89 ansible_ssh_user=testuser ansible_ssh_port=1234 +[Group2] +default ansible_ssh_host=123.45.67.89 ansible_ssh_user=testuser ansible_ssh_port=1234 +` + if fmt.Sprintf("%s", f) != expected { + t.Fatalf("File didn't match expected:\n\n expected: \n%s\n; recieved: \n%s\n", expected, f) + } +} + +func TestCreateInventoryFile_EmptyGroups(t *testing.T) { + var p Provisioner + p.Prepare(testConfig(t)) + defer os.Remove(p.config.Command) + p.ansibleMajVersion = 1 + p.config.User = "testuser" + p.config.EmptyGroups = []string{"Group1", "Group2"} + + err := p.createInventoryFile("123.45.67.89", 1234) + if err != nil { + t.Fatalf("error creating config using localhost and local port proxy") + } + if p.config.InventoryFile == "" { + t.Fatalf("No inventory file was created") + } + defer os.Remove(p.config.InventoryFile) + f, err := ioutil.ReadFile(p.config.InventoryFile) + if err != nil { + t.Fatalf("couldn't read created inventoryfile: %s", err) + } + expected := `default ansible_ssh_host=123.45.67.89 ansible_ssh_user=testuser ansible_ssh_port=1234 +[Group1] +[Group2] +` + if fmt.Sprintf("%s", f) != expected { + t.Fatalf("File didn't match expected:\n\n expected: \n%s\n; recieved: \n%s\n", expected, f) + } +} + +func TestCreateInventoryFile_GroupsAndEmptyGroups(t *testing.T) { + var p Provisioner + p.Prepare(testConfig(t)) + defer os.Remove(p.config.Command) + p.ansibleMajVersion = 1 + p.config.User = "testuser" + p.config.Groups = []string{"Group1", "Group2"} + p.config.EmptyGroups = []string{"Group3"} + + err := p.createInventoryFile("123.45.67.89", 1234) + if err != nil { + t.Fatalf("error creating config using localhost and local port proxy") + } + if p.config.InventoryFile == "" { + t.Fatalf("No inventory file was created") + } + defer os.Remove(p.config.InventoryFile) + f, err := ioutil.ReadFile(p.config.InventoryFile) + if err != nil { + t.Fatalf("couldn't read created inventoryfile: %s", err) + } + expected := `default ansible_ssh_host=123.45.67.89 ansible_ssh_user=testuser ansible_ssh_port=1234 +[Group1] +default ansible_ssh_host=123.45.67.89 ansible_ssh_user=testuser ansible_ssh_port=1234 +[Group2] +default ansible_ssh_host=123.45.67.89 ansible_ssh_user=testuser ansible_ssh_port=1234 +[Group3] +` + if fmt.Sprintf("%s", f) != expected { + t.Fatalf("File didn't match expected:\n\n file is \n\n %s", f) + } +} + +func TestUseProxy(t *testing.T) { + type testcase struct { + UseProxy confighelper.Trilean + generatedData map[string]interface{} + expectedSetupAdapterCalled bool + explanation string + } + + tcs := []testcase{ + { + explanation: "use_proxy is true; we should set up adapter", + UseProxy: confighelper.TriTrue, + generatedData: map[string]interface{}{ + "Host": "123.45.67.8", + "Port": int64(1234), + }, + expectedSetupAdapterCalled: true, + }, + { + explanation: "use_proxy is false but no IP addr is available; we should set up adapter anyway.", + UseProxy: confighelper.TriFalse, + generatedData: map[string]interface{}{ + "Host": "", + "Port": nil, + }, + expectedSetupAdapterCalled: true, + }, + { + explanation: "use_proxy is false; we shouldn't set up adapter.", + UseProxy: confighelper.TriFalse, + generatedData: map[string]interface{}{ + "Host": "123.45.67.8", + "Port": int64(1234), + }, + expectedSetupAdapterCalled: false, + }, + { + explanation: "use_proxy is unset; we should default to setting up the adapter (for now).", + UseProxy: confighelper.TriUnset, + generatedData: map[string]interface{}{ + "Host": "123.45.67.8", + "Port": int64(1234), + }, + expectedSetupAdapterCalled: true, + }, + } + + for _, tc := range tcs { + var p Provisioner + p.Prepare(testConfig(t)) + p.config.UseProxy = tc.UseProxy + defer os.Remove(p.config.Command) + p.ansibleMajVersion = 1 + + var l provisionLogicTracker + l.setupAdapterCalled = false + p.setupAdapterFunc = l.setupAdapter + p.executeAnsibleFunc = l.executeAnsible + ctx := context.TODO() + comm := new(packer.MockCommunicator) + ui := &packer.BasicUi{ + Reader: new(bytes.Buffer), + Writer: new(bytes.Buffer), + } + p.Provision(ctx, ui, comm, tc.generatedData) + + if l.setupAdapterCalled != tc.expectedSetupAdapterCalled { + t.Fatalf("Should have called set up adapter: %s", tc.explanation) + } + os.Remove(p.config.Command) + } +} diff --git a/website/source/docs/provisioners/ansible.html.md.erb b/website/source/docs/provisioners/ansible.html.md.erb index cd9179418..e8cde5d9c 100644 --- a/website/source/docs/provisioners/ansible.html.md.erb +++ b/website/source/docs/provisioners/ansible.html.md.erb @@ -183,6 +183,22 @@ Optional Parameters: - `user` (string) - The `ansible_user` to use. Defaults to the user running packer. +- `use_proxy` (boolean) - Whether or not to set up a localhost proxy adapter + so that Ansible has an IP address to connect to, even if your guest does not + have an IP address. For example, the adapter is necessary for Docker builds + to use the Ansible provisioner. Defaults to "true". If you set this option + to `false`, but Packer cannot find an IP address to connect Ansible to, it + will automatically set up the adapter anyway. + + In order for Ansible to connect properly even when use_proxy is false, you + need to make sure that you are either providing a valid username and ssh key + to the ansible provisioner directly, or that the username and ssh key + being used by the ssh communicator will work for your needs. If you do not + provide a user to ansible, it will use the user associated with your + builder, not the user running Packer. + + use_proxy=false is currently only supported for SSH, not winRM. + <%= partial "partials/provisioners/common-config" %> ## Default Extra Variables