diff --git a/.gitignore b/.gitignore index 283e213b..4f788516 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ **/**/.terraform **/**/crash.log **/**/terraform.* +**/**/ansible-data/roles/**/ /terraform-provisioner-ansible* /bin/.build_output .coverage/ diff --git a/README.md b/README.md index 0f742a9b..4749b2f8 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,22 @@ resource "aws_instance" "test_box" { # enabled = ... # ... } + plays { + galaxy_install { + force = false + server = "https://optional.api.server" + ignore_certs = false + ignore_errors = false + keep_scm_meta = false + no_deps = false + role_file = "/path/to/role/file" + roles_path = "/optional/path/to/the/directory/containing/your/roles" + verbose = false + } + # shared attributes other than: + # enabled = ... + # are NOT taken into consideration for galaxy_install + } defaults { hosts = ["eu-central-1"] groups = ["platform"] @@ -223,9 +239,21 @@ Each `plays` must contain exactly one `playbook` or `module`. Define multiple `p - `plays.module.one_line`: `ansible --one-line`, boolean , default `false` (not applied) - `plays.module.poll`: `ansible --poll`, int, default `15` (applied only when `background > 0`) +#### Galaxy Install attributes + +- `play.galaxy_install.force`: `ansible-galaxy install --force`, bool, force overwriting an existing role, default `false` +- `play.galaxy_install.ignore_certs`: `ansible-galaxy --ignore-certs`, bool, ignore SSL certificate validation errors, default `false` +- `play.galaxy_install.ignore_errors`: `ansible-galaxy install --ignore-errors`, bool, ignore errors and continue with the next specified role, default `false` +- `play.galaxy_install.keep_scm_meta`: `ansible-galaxy install --keep-scm-meta`, bool, use tar instead of the scm archive option when packaging the role, default `false` +- `play.galaxy_install.no_deps`: `ansible-galaxy install --no-deps`, bool, don't download roles listed as dependencies, default `false` +- `play.galaxy_install.role_file`: `ansible-galaxy install --role-file`, string, required full path to the requirements file +- `play.galaxy_install.roles_path`: `ansible-galaxy install --roles-path`, string, the path to the directory containing your roles, the default is the roles_path configured in your `ansible.cfgfile` (`/etc/ansible/roles` if not configured); **for the remote provisioner:** if the path starts with `filesystem path separator`, the bootstrap directory will not be prepended, if the path does not start with `filesystem path separator`, the path will appended to the bootstrap directory, if the value is empty, the default value of `galaxy-roles` is used +- `play.galaxy_install.server`: `ansible-galaxy install --server`, string, optional API server +- `play.galaxy_install.verbose`: `ansible-galaxy --verbose`, bool, verbose mode, default `false` + #### Plays attributes -- `plays.hosts`: list of hosts to include in auto-generated inventory file when `inventory_file` not given, string list, default `empty list`; When used with nulll_resource this can be an interpolated list of host IP address public or private; more details below +- `plays.hosts`: list of hosts to include in auto-generated inventory file when `inventory_file` not given, string list, default `empty list`; When used with null_resource this can be an interpolated list of host IP address public or private; more details below - `plays.groups`: list of groups to include in auto-generated inventory file when `inventory_file` not given, string list, default `empty list`; more details below - `plays.enabled`: boolean, default `true`; set to `false` to skip execution - `plays.become`: `ansible[-playbook] --become`, boolean, default `false` (not applied) diff --git a/examples/README.md b/examples/README.md index dfc74886..000651d5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -72,7 +72,25 @@ All examples execute a great task of installing `tree` on the bootstrapped host. After testing each of the examples, you will need to destroy the infrastructure. Examples share names but they don't share state. -1. `sshagent-local-no-bastion`: run local provisioning for a host without a bastion +1. `sshagent-galaxy-local`: run local provisioning for a host using `ansible-galaxy` provided role file: + + ``` + cd sshagent-galaxy-local + terraform apply -var "ami_id=${TERRAFORM_PROVISIONER_ANSIBLE_AMI_ID}" + # ... + terraform destroy -var "ami_id=${TERRAFORM_PROVISIONER_ANSIBLE_AMI_ID}" + ``` + +2. `sshagent-galaxy-remote`: run remote provisioning for a host using `ansible-galaxy` provided role file: + + ``` + cd sshagent-galaxy-remote + terraform apply -var "ami_id=${TERRAFORM_PROVISIONER_ANSIBLE_AMI_ID}" + # ... + terraform destroy -var "ami_id=${TERRAFORM_PROVISIONER_ANSIBLE_AMI_ID}" + ``` + +3. `sshagent-local-no-bastion`: run local provisioning for a host without a bastion ``` cd sshagent-local-no-bastion @@ -81,7 +99,7 @@ After testing each of the examples, you will need to destroy the infrastructure. terraform destroy -var "ami_id=${TERRAFORM_PROVISIONER_ANSIBLE_AMI_ID}" ``` -2. `sshagent-remote-no-bastion`: run remote provisioning for a host without a bastion +4. `sshagent-remote-no-bastion`: run remote provisioning for a host without a bastion ``` cd sshagent-remote-no-bastion @@ -90,7 +108,7 @@ After testing each of the examples, you will need to destroy the infrastructure. terraform destroy -var "ami_id=${TERRAFORM_PROVISIONER_ANSIBLE_AMI_ID}" ``` -3. `sshagent-local-with-bastion`: VPC setup, bastion, provision local over bastion +5. `sshagent-local-with-bastion`: VPC setup, bastion, provision local over bastion ``` cd sshagent-local-with-bastion @@ -116,7 +134,7 @@ After testing each of the examples, you will need to destroy the infrastructure. -var "infrastructure_name=${R_NAME}-local" ``` -4. `sshagent-remote-with-bastion`: VPC setup, bastion, provision remote over bastion +6. `sshagent-remote-with-bastion`: VPC setup, bastion, provision remote over bastion ``` cd sshagent-remote-with-bastion @@ -142,7 +160,7 @@ After testing each of the examples, you will need to destroy the infrastructure. -var "infrastructure_name=${R_NAME}-remote" ``` -5. `sshagent-local-no-bastion-null-resource`: run local provisioning using a `null_resource` for a host without a bastion +7. `sshagent-local-no-bastion-null-resource`: run local provisioning using a `null_resource` for a host without a bastion ``` cd sshagent-local-no-bastion-null-resource diff --git a/examples/sshagent-galaxy-local/ansible-data/playbooks/install-ntp.yml b/examples/sshagent-galaxy-local/ansible-data/playbooks/install-ntp.yml new file mode 100644 index 00000000..3d2256f3 --- /dev/null +++ b/examples/sshagent-galaxy-local/ansible-data/playbooks/install-ntp.yml @@ -0,0 +1,5 @@ +--- +- hosts: all + become: yes + roles: + - geerlingguy.ntp \ No newline at end of file diff --git a/examples/sshagent-galaxy-local/ansible-data/requirements.yml b/examples/sshagent-galaxy-local/ansible-data/requirements.yml new file mode 100644 index 00000000..d028f99d --- /dev/null +++ b/examples/sshagent-galaxy-local/ansible-data/requirements.yml @@ -0,0 +1,2 @@ +# from galaxy +- src: geerlingguy.ntp \ No newline at end of file diff --git a/examples/sshagent-galaxy-local/ansible-data/roles/.gitkeep b/examples/sshagent-galaxy-local/ansible-data/roles/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/examples/sshagent-galaxy-local/main.tf b/examples/sshagent-galaxy-local/main.tf new file mode 100644 index 00000000..52faba59 --- /dev/null +++ b/examples/sshagent-galaxy-local/main.tf @@ -0,0 +1,74 @@ +provider "aws" { + region = "eu-central-1" + profile = "terraform-provisioner-ansible" +} + +variable "ami_id" {} +variable "insecure_no_strict_host_key_checking" { + default = false +} + +## -- security groups: + +resource "aws_security_group" "ssh_box" { + name = "ssh_box" + description = "SSH" + + ingress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + self = true + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +## -- machine: + +resource "aws_instance" "test_box" { + ami = "${var.ami_id}" + count = "1" + instance_type = "m3.medium" + + security_groups = ["${aws_security_group.ssh_box.name}"] + + connection { + host = "${self.public_ip}" + user = "centos" + } + + provisioner "ansible" { + plays { + galaxy_install { + role_file = "${path.module}/ansible-data/requirements.yml" + roles_path = "${path.module}/ansible-data/roles/" + verbose = true + } + } + plays { + playbook { + file_path = "${path.module}/ansible-data/playbooks/install-ntp.yml" + roles_path = [ + "${path.module}/ansible-data/roles/" + ] + } + hosts = ["testBoxToBootstrap"] + } + ansible_ssh_settings { + insecure_no_strict_host_key_checking = "${var.insecure_no_strict_host_key_checking}" + } + } + + root_block_device { + delete_on_termination = true + volume_size = 8 + volume_type = "gp2" + } +} diff --git a/examples/sshagent-galaxy-remote/ansible-data/playbooks/install-ntp.yml b/examples/sshagent-galaxy-remote/ansible-data/playbooks/install-ntp.yml new file mode 100644 index 00000000..3d2256f3 --- /dev/null +++ b/examples/sshagent-galaxy-remote/ansible-data/playbooks/install-ntp.yml @@ -0,0 +1,5 @@ +--- +- hosts: all + become: yes + roles: + - geerlingguy.ntp \ No newline at end of file diff --git a/examples/sshagent-galaxy-remote/ansible-data/requirements.yml b/examples/sshagent-galaxy-remote/ansible-data/requirements.yml new file mode 100644 index 00000000..d028f99d --- /dev/null +++ b/examples/sshagent-galaxy-remote/ansible-data/requirements.yml @@ -0,0 +1,2 @@ +# from galaxy +- src: geerlingguy.ntp \ No newline at end of file diff --git a/examples/sshagent-galaxy-remote/main.tf b/examples/sshagent-galaxy-remote/main.tf new file mode 100644 index 00000000..3acaee69 --- /dev/null +++ b/examples/sshagent-galaxy-remote/main.tf @@ -0,0 +1,70 @@ +provider "aws" { + region = "eu-central-1" + profile = "terraform-provisioner-ansible" +} + +variable "ami_id" {} + + +## -- security groups: + +resource "aws_security_group" "ssh_box" { + name = "ssh_box" + description = "SSH" + + ingress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + self = true + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +## -- machine: + +resource "aws_instance" "test_box" { + ami = "${var.ami_id}" + count = "1" + instance_type = "m3.medium" + + security_groups = ["${aws_security_group.ssh_box.name}"] + + connection { + host = "${self.public_ip}" + user = "centos" + } + + provisioner "ansible" { + plays { + galaxy_install { + role_file = "${path.module}/ansible-data/requirements.yml" + verbose = true + } + } + plays { + playbook { + file_path = "${path.module}/ansible-data/playbooks/install-ntp.yml" + roles_path = [ + # our galaxy_install does not deine roles_path, default values are being used: + "galaxy_install:/tmp/tf-ansible-bootstrap/galaxy-roles" + ] + } + hosts = ["testBoxToBootstrap"] + } + remote {} + } + + root_block_device { + delete_on_termination = true + volume_size = 8 + volume_type = "gp2" + } +} diff --git a/go.sum b/go.sum index aeabec42..7f6a4838 100644 --- a/go.sum +++ b/go.sum @@ -82,6 +82,7 @@ github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dimchansky/utfbom v1.0.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= diff --git a/mode/mode_local.go b/mode/mode_local.go index 7f057c27..7a17aff1 100644 --- a/mode/mode_local.go +++ b/mode/mode_local.go @@ -284,7 +284,7 @@ func (v *LocalMode) writeKnownHosts(knownHosts []string) (string, error) { trimmedKnownHosts = append(trimmedKnownHosts, strings.TrimSpace(entry)) } knownHostsFileContents := strings.Join(trimmedKnownHosts, "\n") - file, err := ioutil.TempFile(os.TempDir(), uuid.Must(uuid.NewV4(), nil).String()) + file, err := ioutil.TempFile(os.TempDir(), uuid.NewV4().String()) defer file.Close() if err != nil { return "", err @@ -298,7 +298,7 @@ func (v *LocalMode) writeKnownHosts(knownHosts []string) (string, error) { func (v *LocalMode) writePem(pk string) (string, error) { if v.connInfo.PrivateKey != "" { - file, err := ioutil.TempFile(os.TempDir(), uuid.Must(uuid.NewV4(), nil).String()) + file, err := ioutil.TempFile(os.TempDir(), uuid.NewV4().String()) defer file.Close() if err != nil { return "", err diff --git a/mode/mode_remote.go b/mode/mode_remote.go index 3a5aff9a..1176fb30 100644 --- a/mode/mode_remote.go +++ b/mode/mode_remote.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "fmt" "io" + "io/ioutil" "log" "os" "path/filepath" @@ -81,6 +82,8 @@ const inventoryTemplateRemote = `{{$top := . -}} {{end}}` +const defaultAnsibleGalaxyRolesPath = "ansible-galaxy-roles" + // RemoteMode represents remote provisioner mode. type RemoteMode struct { o terraform.UIOutput @@ -262,6 +265,12 @@ func (v *RemoteMode) deployAnsibleData(plays []*types.Play) error { // upload roles paths, if any: remoteRolesPath := make([]string, 0) for _, path := range entity.RolesPath() { + + if strings.HasPrefix(path, "galaxy_install:") { // TODO: extract this hard coded value + remoteRolesPath = append(remoteRolesPath, strings.TrimPrefix(path, "galaxy_install:")) + continue + } + resolvedPath, err := types.ResolvePath(path) if err != nil { return err @@ -323,6 +332,43 @@ func (v *RemoteMode) deployAnsibleData(plays []*types.Play) error { } play.SetOverrideInventoryFile(inventoryFile) + case *types.GalaxyInstall: + + if err := v.runCommandNoSudo(fmt.Sprintf("mkdir -p \"%s\"", + v.remoteSettings.BootstrapDirectory())); err != nil { + return err + } + + rolesPathDir := entity.RolesPath() + if rolesPathDir == "" { + rolesPathDir = "galaxy-roles" // TODO: find a method to customize this + } + if !strings.HasPrefix(rolesPathDir, string(os.PathSeparator)) { + rolesPathDir = filepath.Join(v.remoteSettings.BootstrapDirectory(), rolesPathDir) + } + entity.SetRolesPath(rolesPathDir) + v.o.Output(fmt.Sprintf("galaxy_install roles path used is: '%s'...", entity.RolesPath())) + if err := v.runCommandNoSudo(fmt.Sprintf("mkdir -p \"%s\"", entity.RolesPath())); err != nil { + return err + } + + originalRoleFile := entity.RoleFile() + roleFileHash := v.getMD5Hash(entity.RoleFile()) + roleFileRemotePath := filepath.Join(v.remoteSettings.BootstrapDirectory(), fmt.Sprintf("%s.yml", roleFileHash)) + entity.SetRoleFile(roleFileRemotePath) + v.o.Output(fmt.Sprintf("galaxy_install role file path used is: '%s'...", entity.RoleFile())) + + v.o.Output(fmt.Sprintf("reading original role file at: '%s'...", originalRoleFile)) + roleFileBytes, readFileError := ioutil.ReadFile(originalRoleFile) + if readFileError != nil { + return readFileError + } + + v.o.Output(fmt.Sprintf("uploading role file to: '%s'...", entity.RoleFile())) + if err := v.comm.Upload(roleFileRemotePath, bytes.NewReader(roleFileBytes)); err != nil { + return err + } + } } @@ -399,7 +445,7 @@ func (v *RemoteMode) uploadVaultPasswordOrIDFile(destination string, source stri return "", err } - u1 := uuid.Must(uuid.NewV4(), nil) + u1 := uuid.NewV4() targetPath := filepath.Join(destination, fmt.Sprintf(".vault-file-%s", u1)) v.o.Output(fmt.Sprintf("Uploading ansible vault password file / ID to '%s'...", targetPath)) @@ -428,7 +474,7 @@ func (v *RemoteMode) writeInventory(destination string, play *types.Play) (strin if err != nil { return "", err } - u1 := uuid.Must(uuid.NewV4(), nil) + u1 := uuid.NewV4() targetPath := filepath.Join(destination, fmt.Sprintf(".inventory-%s", u1)) v.o.Output(fmt.Sprintf("Uploading provided inventory file '%s' to '%s'...", play.InventoryFile(), targetPath)) @@ -461,7 +507,7 @@ func (v *RemoteMode) writeInventory(destination string, play *types.Play) (strin return "", fmt.Errorf("Error executing 'hosts' template: %s", err) } - u1 := uuid.Must(uuid.NewV4(), nil) + u1 := uuid.NewV4() targetPath := filepath.Join(destination, fmt.Sprintf(".inventory-%s", u1)) v.o.Output(fmt.Sprintf("Writing temporary ansible inventory to '%s'...", targetPath)) diff --git a/mode/ssh_bastion_keyscan.go b/mode/ssh_bastion_keyscan.go index e3e9b47b..ee8ea742 100644 --- a/mode/ssh_bastion_keyscan.go +++ b/mode/ssh_bastion_keyscan.go @@ -130,7 +130,7 @@ func (b *bastionKeyScan) scan() (string, error) { return "", err } - u1 := uuid.Must(uuid.NewV4(), nil) + u1 := uuid.NewV4() targetPath := filepath.Join(b.quotedSSHKnownFileDir(), u1.String()) timeoutMs := b.sshKeyscanTimeout * 1000 diff --git a/resource_provisioner.go b/resource_provisioner.go index 76ab83c5..4ea34829 100644 --- a/resource_provisioner.go +++ b/resource_provisioner.go @@ -77,11 +77,12 @@ func validateFn(c *terraform.ResourceConfig) (ws []string, es []error) { vPlaybook, playHasPlaybook := vPlay["playbook"] _, playHasModule := vPlay["module"] + _, playHasGalaxyInstall := vPlay["galaxy_install"] - if playHasPlaybook && playHasModule { - es = append(es, fmt.Errorf("playbook and module can't be used together")) - } else if !playHasPlaybook && !playHasModule { - es = append(es, fmt.Errorf("playbook or module must be set")) + if types.HasMoreThanOneTrue([]bool{playHasPlaybook, playHasModule, playHasGalaxyInstall}...) { + es = append(es, fmt.Errorf("play can have only one of: galaxy_install, playbook or module")) + } else if !playHasPlaybook && !playHasModule && !playHasGalaxyInstall { + es = append(es, fmt.Errorf("galaxy_install, playbook or module must be set")) } else { if playHasPlaybook { diff --git a/resource_provisioner_test.go b/resource_provisioner_test.go index df560270..e0e79afc 100644 --- a/resource_provisioner_test.go +++ b/resource_provisioner_test.go @@ -13,22 +13,26 @@ import ( var vaultPasswordFile string var alternativeVaultPasswordFile string var playbookFile string +var galaxyInstallRequirementsFile string func TestMain(m *testing.M) { tempVaultPasswordFile, _ := ioutil.TempFile("", "vault-password-file") tempAlternativeVaultPasswordFile, _ := ioutil.TempFile("", "vault-password-file") tempPlaybookFile, _ := ioutil.TempFile("", "playbook-file") + tempGalaxyInstallRequirementsFile, _ := ioutil.TempFile("", "requirements-file") vaultPasswordFile = tempVaultPasswordFile.Name() alternativeVaultPasswordFile = tempAlternativeVaultPasswordFile.Name() playbookFile = tempPlaybookFile.Name() + galaxyInstallRequirementsFile = tempGalaxyInstallRequirementsFile.Name() result := m.Run() os.Remove(vaultPasswordFile) os.Remove(alternativeVaultPasswordFile) os.Remove(playbookFile) + os.Remove(galaxyInstallRequirementsFile) os.Exit(result) } @@ -45,7 +49,9 @@ func TestProvisioner(t *testing.T) { func TestBadConfig(t *testing.T) { // play.0.playbook with no file_path - // play.0.module with no module + // play.1.module with no module + // play.2.galaxy_install with no role_file + expectedErrorCount := 3 c := testConfig(t, map[string]interface{}{ "plays": []interface{}{ map[string]interface{}{ @@ -58,6 +64,11 @@ func TestBadConfig(t *testing.T) { map[string]interface{}{}, }, }, + map[string]interface{}{ + "galaxy_install": []interface{}{ + map[string]interface{}{}, + }, + }, }, "remote": []interface{}{ @@ -79,14 +90,16 @@ func TestBadConfig(t *testing.T) { if len(warn) > 0 { t.Fatalf("Warnings: %v", warn) } - if len(errs) != 2 { - t.Fatalf("Expected 2 errors but got: %v", errs) + if len(errs) != expectedErrorCount { + t.Fatalf("Expected %d errors but got: %v", expectedErrorCount, errs) } } func TestGoodAndCompleteRemoteConfig(t *testing.T) { // warnings: // = plays.0.playbook.roles_path + // = plays.2.galaxy_install.role_file + expectedWarningCount := 2 c := testConfig(t, map[string]interface{}{ "plays": []interface{}{ map[string]interface{}{ @@ -113,6 +126,16 @@ func TestGoodAndCompleteRemoteConfig(t *testing.T) { }, }, }, + map[string]interface{}{ + "galaxy_install": []interface{}{ + map[string]interface{}{ + "server": "https://localhost:1234", + "ignore_certs": false, + "verbose": true, + "role_file": "${path.module}/path/to/a/galaxy/requirements.txt", + }, + }, + }, }, "remote": []interface{}{ @@ -147,8 +170,8 @@ func TestGoodAndCompleteRemoteConfig(t *testing.T) { }) warn, errs := Provisioner().Validate(c) - if len(warn) != 1 { - t.Fatalf("Expected one warning.") + if len(warn) != expectedWarningCount { + t.Fatalf("Expected %d warnings but got: %v", expectedWarningCount, warn) } if len(errs) > 0 { t.Fatalf("Errors: %v", errs) @@ -255,6 +278,34 @@ func TestConfigWithPlaysbookAndModuleFails(t *testing.T) { } } +func TestConfigWithPlaysbookAndGalaxyInstallFails(t *testing.T) { + // no plays gives a warning: + c := testConfig(t, map[string]interface{}{ + "plays": []interface{}{ + map[string]interface{}{ + "playbook": []interface{}{ + map[string]interface{}{ + "file_path": playbookFile, + }, + }, + "galaxy_install": []interface{}{ + map[string]interface{}{ + "role_file": galaxyInstallRequirementsFile, + }, + }, + }, + }, + }) + + warn, errs := Provisioner().Validate(c) + if len(warn) != 1 { + t.Fatalf("Should have 1 warning.") + } + if len(errs) != 1 { + t.Fatalf("Should have 1 error.") + } +} + func TestConfigWithInvalidValueTypeFailes(t *testing.T) { // file_path is set to a boolean instead of a string c := testConfig(t, map[string]interface{}{ diff --git a/types/galaxy_install.go b/types/galaxy_install.go new file mode 100644 index 00000000..346c4a3e --- /dev/null +++ b/types/galaxy_install.go @@ -0,0 +1,159 @@ +package types + +import "github.com/hashicorp/terraform/helper/schema" + +// ansible-galaxy install -r requirements.yml + +const ( + ansibleGalaxyAttributeForce = "force" + ansibleGalaxyAttributeIgnoreCerts = "ignore_certs" + ansibleGalaxyAttributeIgnoreErrors = "ignore_errors" + ansibleGalaxyAttributeKeepScmMeta = "keep_scm_meta" + ansibleGalaxyAttributeNoDeps = "no_deps" + ansibleGalaxyAttributeRoleFile = "role_file" + ansibleGalaxyAttributeRolesPath = "roles_path" + ansibleGalaxyAttributeServer = "server" + ansibleGalaxyAttributeVerbose = "verbose" +) + +// GalaxyInstall represents ansible-galaxy settings. +type GalaxyInstall struct { + force bool + ignoreCerts bool + ignoreErrors bool + keepScmMeta bool + noDeps bool + roleFile string + rolesPath string + server string + verbose bool +} + +// NewGalaxyInstallSchema returns a new Ansible Galaxy schema for the install operation. +func NewGalaxyInstallSchema() *schema.Schema { + return &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ConflictsWith: []string{"plays.module", "plays.playbook"}, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + // Ansible Galaxy parameters: + ansibleGalaxyAttributeForce: &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + ansibleGalaxyAttributeIgnoreCerts: &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + ansibleGalaxyAttributeIgnoreErrors: &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + ansibleGalaxyAttributeKeepScmMeta: &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + ansibleGalaxyAttributeNoDeps: &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + ansibleGalaxyAttributeRoleFile: &schema.Schema{ + Type: schema.TypeString, + Required: true, + ValidateFunc: vfPath, + }, + ansibleGalaxyAttributeRolesPath: &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ValidateFunc: vfPath, + }, + ansibleGalaxyAttributeServer: &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + ansibleGalaxyAttributeVerbose: &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + }, + }, + } +} + +// NewGalaxyInstallFromInterface reads Ansible Galaxy install configuration from Terraform schema. +func NewGalaxyInstallFromInterface(i interface{}) *GalaxyInstall { + vals := mapFromTypeSetList(i.(*schema.Set).List()) + return &GalaxyInstall{ + force: vals[ansibleGalaxyAttributeForce].(bool), + ignoreCerts: vals[ansibleGalaxyAttributeIgnoreCerts].(bool), + ignoreErrors: vals[ansibleGalaxyAttributeIgnoreErrors].(bool), + keepScmMeta: vals[ansibleGalaxyAttributeKeepScmMeta].(bool), + noDeps: vals[ansibleGalaxyAttributeNoDeps].(bool), + roleFile: vals[ansibleGalaxyAttributeRoleFile].(string), + rolesPath: vals[ansibleGalaxyAttributeRolesPath].(string), + server: vals[ansibleGalaxyAttributeServer].(string), + verbose: vals[ansibleGalaxyAttributeVerbose].(bool), + } +} + +// Force is the ansible-galaxy install --force flag. +func (v *GalaxyInstall) Force() bool { + return v.force +} + +// IgnoreCerts is the ansible-galaxy --ignore-certs flag. +func (v *GalaxyInstall) IgnoreCerts() bool { + return v.ignoreCerts +} + +// IgnoreErrors is the ansible-galaxy install --ignore-errors flag. +func (v *GalaxyInstall) IgnoreErrors() bool { + return v.ignoreErrors +} + +// KeepScmMeta is the ansible-galaxy install --keep-scm-meta flag. +func (v *GalaxyInstall) KeepScmMeta() bool { + return v.keepScmMeta +} + +// NoDeps is the ansible-galaxy install --no-deps flag. +func (v *GalaxyInstall) NoDeps() bool { + return v.noDeps +} + +// RoleFile ansible-galaxy install --role-file. +func (v *GalaxyInstall) RoleFile() string { + return v.roleFile +} + +// SetRoleFile is used by the remote provisioner to set calculated role file path. +func (v *GalaxyInstall) SetRoleFile(p string) { + v.roleFile = p +} + +// RolesPath ansible-galaxy install --roles-path. +func (v *GalaxyInstall) RolesPath() string { + return v.rolesPath +} + +// SetRolesPath is used by the remote provisioner to set calculated roles path. +func (v *GalaxyInstall) SetRolesPath(p string) { + v.rolesPath = p +} + +// Server is the ansible-galaxy --server. +func (v *GalaxyInstall) Server() string { + return v.server +} + +// Verbose is the ansible-galaxy --verbose flag. +func (v *GalaxyInstall) Verbose() bool { + return v.verbose +} diff --git a/types/helpers.go b/types/helpers.go index 32631234..76e59ea4 100644 --- a/types/helpers.go +++ b/types/helpers.go @@ -22,6 +22,20 @@ var ( } ) +// HasMoreThanOneTrue checks if a list of booleans contains more than one true value. +func HasMoreThanOneTrue(vals ...bool) bool { + f := false + for _, v := range vals { + if f && v { + return true + } + if !f && v { + f = v + } + } + return false +} + func vfBecomeMethod(val interface{}, key string) (warns []string, errs []error) { v := val.(string) if !becomeMethods[v] { @@ -34,6 +48,8 @@ func vfPath(val interface{}, key string) (warns []string, errs []error) { v := val.(string) if strings.Index(v, "${path.module}") > -1 { warns = append(warns, fmt.Sprintf("Unable to determine the existence of '%s', most likely because of https://github.com/hashicorp/terraform/issues/17439. If the file does not exist, you'll experience a failure at runtime.", v)) + } else if strings.HasPrefix(v, "galaxy_install:") { // TODO: extract this hard coded value + warns = append(warns, fmt.Sprintf("Not validating existence of '%s', galaxy_install roles path directory.", v)) } else { if _, err := ResolvePath(v); err != nil { errs = append(errs, fmt.Errorf("file '%s' does not exist", v)) @@ -47,6 +63,8 @@ func VfPathDirectory(val interface{}, key string) (warns []string, errs []error) v := val.(string) if strings.Index(v, "${path.module}") > -1 { warns = append(warns, fmt.Sprintf("Unable to determine the existence of '%s', most likely because of https://github.com/hashicorp/terraform/issues/17439. If the file does not exist, you'll experience a failure at runtime.", v)) + } else if strings.HasPrefix(v, "galaxy_install:") { // TODO: extract this hard coded value + warns = append(warns, fmt.Sprintf("Not validating existence of '%s', galaxy_install roles path directory.", v)) } else { if _, err := ResolveDirectory(v); err != nil { errs = append(errs, fmt.Errorf("directory '%s' does not exist or path is not a directory", v)) diff --git a/types/module.go b/types/module.go index 55cf0447..3ebc7eb6 100644 --- a/types/module.go +++ b/types/module.go @@ -30,7 +30,7 @@ func NewModuleSchema() *schema.Schema { return &schema.Schema{ Type: schema.TypeSet, Optional: true, - ConflictsWith: []string{"plays.playbook"}, + ConflictsWith: []string{"plays.galaxy_install", "plays.playbook"}, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ // Ansible parameters: diff --git a/types/play.go b/types/play.go index 8ec3c04a..19189c6a 100644 --- a/types/play.go +++ b/types/play.go @@ -2,6 +2,7 @@ package types import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -48,6 +49,7 @@ const ( playAttributeEnabled = "enabled" playAttributePlaybook = "playbook" playAttributeModule = "module" + playAttributeGalaxyInstall = "galaxy_install" playAttributeHosts = "hosts" playAttributeGroups = "groups" playAttributeBecome = "become" @@ -77,8 +79,9 @@ func NewPlaySchema() *schema.Schema { Optional: true, Default: true, }, - playAttributePlaybook: NewPlaybookSchema(), - playAttributeModule: NewModuleSchema(), + playAttributePlaybook: NewPlaybookSchema(), + playAttributeModule: NewModuleSchema(), + playAttributeGalaxyInstall: NewGalaxyInstallSchema(), playAttributeHosts: &schema.Schema{ Type: schema.TypeList, Elem: &schema.Schema{Type: schema.TypeString}, @@ -183,6 +186,8 @@ func NewPlayFromMapInterface(vals map[string]interface{}, defaults *Defaults) *P v.entity = NewPlaybookFromInterface(vals[playAttributePlaybook]) } else if vals[playAttributeModule].(*schema.Set).GoString() != emptySet { v.entity = NewModuleFromInterface(vals[playAttributeModule]) + } else if vals[playAttributeGalaxyInstall].(*schema.Set).GoString() != emptySet { + v.entity = NewGalaxyInstallFromInterface(vals[playAttributeGalaxyInstall]) } if val, ok := vals[playAttributeHosts]; ok { @@ -388,6 +393,7 @@ func (v *Play) ToCommand(ansibleArgs LocalModeAnsibleArgs) (string, error) { // entity to call: switch entity := v.Entity().(type) { case *Playbook: + // handling role directories: rolePaths := v.defaultRolePaths() for _, rp := range entity.RolesPath() { @@ -418,7 +424,10 @@ func (v *Play) ToCommand(ansibleArgs LocalModeAnsibleArgs) (string, error) { command = fmt.Sprintf("%s --tags='%s'", command, strings.Join(entity.Tags(), ",")) } + return v.appendSharedArguments(command, ansibleArgs) + case *Module: + hostPattern := entity.HostPattern() if hostPattern == "" { hostPattern = ansibleModuleDefaultHostPattern @@ -443,13 +452,75 @@ func (v *Play) ToCommand(ansibleArgs LocalModeAnsibleArgs) (string, error) { if entity.OneLine() { command = fmt.Sprintf("%s --one-line", command) } + + return v.appendSharedArguments(command, ansibleArgs) + + case *GalaxyInstall: + + command = fmt.Sprintf("%s ansible-galaxy install --role-file='%s'", command, entity.RoleFile()) + // force: + if entity.Force() { + command = fmt.Sprintf("%s --force", command) + } + // ignore certs: + if entity.IgnoreCerts() { + command = fmt.Sprintf("%s --ignore-certs", command) + } + // ignore errors: + if entity.IgnoreErrors() { + command = fmt.Sprintf("%s --ignore-errors", command) + } + // keep scm meta: + if entity.KeepScmMeta() { + command = fmt.Sprintf("%s --keep-scm-meta", command) + } + // no deps: + if entity.NoDeps() { + command = fmt.Sprintf("%s --no-deps", command) + } + // no deps: + if entity.Verbose() { + command = fmt.Sprintf("%s --verbose", command) + } + // roles path: + if len(entity.RolesPath()) > 0 { + command = fmt.Sprintf("%s --roles-path='%s'", command, entity.RolesPath()) + } + // API server: + if len(entity.Server()) > 0 { + command = fmt.Sprintf("%s --server='%s'", command, entity.Server()) + } + + // Galaxy Install does not support shared arguments + return command, nil + + default: + + return "", errors.New("Unsupported entity type") + } +} + +// ToLocalCommand serializes the play to an executable local provisioning Ansible command. +func (v *Play) ToLocalCommand(ansibleArgs LocalModeAnsibleArgs, ansibleSSHSettings *AnsibleSSHSettings) (string, error) { + baseCommand, err := v.ToCommand(ansibleArgs) + if err != nil { + return "", err + } + + switch v.Entity().(type) { + case *GalaxyInstall: + return baseCommand, nil + } + + return fmt.Sprintf("%s %s", baseCommand, v.toCommandArguments(ansibleArgs, ansibleSSHSettings)), nil +} + +func (v *Play) appendSharedArguments(command string, ansibleArgs LocalModeAnsibleArgs) (string, error) { // inventory file: command = fmt.Sprintf("%s --inventory-file='%s'", command, v.InventoryFile()) - // shared arguments: - // become: if v.Become() { command = fmt.Sprintf("%s --become", command) @@ -504,15 +575,6 @@ func (v *Play) ToCommand(ansibleArgs LocalModeAnsibleArgs) (string, error) { return command, nil } -// ToLocalCommand serializes the play to an executable local provisioning Ansible command. -func (v *Play) ToLocalCommand(ansibleArgs LocalModeAnsibleArgs, ansibleSSHSettings *AnsibleSSHSettings) (string, error) { - baseCommand, err := v.ToCommand(ansibleArgs) - if err != nil { - return "", err - } - return fmt.Sprintf("%s %s", baseCommand, v.toCommandArguments(ansibleArgs, ansibleSSHSettings)), nil -} - func (v *Play) toCommandArguments(ansibleArgs LocalModeAnsibleArgs, ansibleSSHSettings *AnsibleSSHSettings) string { args := fmt.Sprintf("--user='%s'", ansibleArgs.Username) if ansibleArgs.PemFile != "" { @@ -523,7 +585,7 @@ func (v *Play) toCommandArguments(ansibleArgs LocalModeAnsibleArgs, ansibleSSHSe sshExtraAgrsOptions = append(sshExtraAgrsOptions, fmt.Sprintf("-p %d", ansibleArgs.Port)) sshExtraAgrsOptions = append(sshExtraAgrsOptions, fmt.Sprintf("-o ConnectTimeout=%d", ansibleSSHSettings.ConnectTimeoutSeconds())) sshExtraAgrsOptions = append(sshExtraAgrsOptions, fmt.Sprintf("-o ConnectionAttempts=%d", ansibleSSHSettings.ConnectAttempts())) - + if ansibleSSHSettings.InsecureNoStrictHostKeyChecking() || v.InventoryFile() != "" { sshExtraAgrsOptions = append(sshExtraAgrsOptions, "-o StrictHostKeyChecking=no") } else { @@ -540,7 +602,7 @@ func (v *Play) toCommandArguments(ansibleArgs LocalModeAnsibleArgs, ansibleSSHSe if ansibleArgs.BastionPemFile != "" { proxyCommand = fmt.Sprintf("%s -i %s", proxyCommand, ansibleArgs.BastionPemFile) } - if ansibleSSHSettings.InsecureBastionNoStrictHostKeyChecking() { + if ansibleSSHSettings.InsecureBastionNoStrictHostKeyChecking() { proxyCommand = fmt.Sprintf("%s -o StrictHostKeyChecking=no", proxyCommand) } else { if ansibleSSHSettings.BastionUserKnownHostsFile() != "" { diff --git a/types/playbook.go b/types/playbook.go index daedc422..4c55ccfb 100644 --- a/types/playbook.go +++ b/types/playbook.go @@ -32,7 +32,7 @@ func NewPlaybookSchema() *schema.Schema { return &schema.Schema{ Type: schema.TypeSet, Optional: true, - ConflictsWith: []string{"plays.module"}, + ConflictsWith: []string{"plays.galaxy_install", "plays.module"}, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ // Ansible parameters: