diff --git a/aws/config.go b/aws/config.go index d26517425c5..fc11c2b14bb 100644 --- a/aws/config.go +++ b/aws/config.go @@ -89,6 +89,7 @@ import ( "github.com/aws/aws-sdk-go/service/mq" "github.com/aws/aws-sdk-go/service/neptune" "github.com/aws/aws-sdk-go/service/opsworks" + "github.com/aws/aws-sdk-go/service/opsworkscm" "github.com/aws/aws-sdk-go/service/organizations" "github.com/aws/aws-sdk-go/service/pinpoint" "github.com/aws/aws-sdk-go/service/pricing" @@ -264,6 +265,7 @@ type AWSClient struct { mqconn *mq.MQ neptuneconn *neptune.Neptune opsworksconn *opsworks.OpsWorks + opsworkscmconn *opsworkscm.OpsWorksCM organizationsconn *organizations.Organizations partition string pinpointconn *pinpoint.Pinpoint @@ -434,6 +436,7 @@ func (c *Config) Client() (interface{}, error) { mqconn: mq.New(sess), neptuneconn: neptune.New(sess), opsworksconn: opsworks.New(sess), + opsworkscmconn: opsworkscm.New(sess), organizationsconn: organizations.New(sess), partition: partition, pinpointconn: pinpoint.New(sess), diff --git a/aws/provider.go b/aws/provider.go index 5b2c3782c02..ea5e656e011 100644 --- a/aws/provider.go +++ b/aws/provider.go @@ -577,6 +577,7 @@ func Provider() terraform.ResourceProvider { "aws_opsworks_user_profile": resourceAwsOpsworksUserProfile(), "aws_opsworks_permission": resourceAwsOpsworksPermission(), "aws_opsworks_rds_db_instance": resourceAwsOpsworksRdsDbInstance(), + "aws_opsworks_chef": resourceAwsOpsworksChef(), "aws_organizations_organization": resourceAwsOrganizationsOrganization(), "aws_organizations_account": resourceAwsOrganizationsAccount(), "aws_organizations_policy": resourceAwsOrganizationsPolicy(), diff --git a/aws/resource_aws_opsworks_chef.go b/aws/resource_aws_opsworks_chef.go new file mode 100644 index 00000000000..cc03d79f2cf --- /dev/null +++ b/aws/resource_aws_opsworks_chef.go @@ -0,0 +1,343 @@ +package aws + +import ( + "log" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/opsworkscm" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/helper/validation" +) + +const EngineModel = "Single" +const EngineVersion = "12" + +func resourceAwsOpsworksChef() *schema.Resource { + return &schema.Resource{ + Create: resourceAwsOpsworksChefCreate, + Read: resourceAwsOpsworksChefRead, + Update: resourceAwsOpsworksChefUpdate, + Delete: resourceAwsOpsworksChefDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + // TODO: do we want this? I think this is another "magic" thing that happens + // I think we can still associate a public IP later if we want? :shrug: + "associate_public_ip_address": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + + "backup_retention_count": { + Type: schema.TypeInt, + Optional: true, + Default: 1, + // https://docs.aws.amazon.com/opsworks/latest/userguide/opscm-chef-backup.html + // I could swear I found this 1-30 limitation elsewhere, but I can't seem to find it now other than there + ValidateFunc: validation.IntBetween(1, 30), + }, + + "disable_automated_backup": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + + // TODO: + // I kinda want to make this required because how else are we going to get at that info? + // Also, since these are write-only and not returned by the API, we'll probably need a custom diff function that ignores them? + "chef_pivotal_key": { + Type: schema.TypeString, + Required: true, + // TODO: should we? + // ValidateFunc: validateRsaKey, + }, + + "chef_delivery_admin_password": { + Type: schema.TypeString, + Required: true, + Sensitive: true, + // TODO: should we? + // ValidateFunc: validatePasswordOk, + }, + + // TODO: document the instance role required + // https://s3.amazonaws.com/opsworks-cm-us-east-1-prod-default-assets/misc/opsworks-cm-roles.yaml + "instance_profile_arn": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validateArn, + }, + + "instance_type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "ssh_key_pair": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + + "preferred_backup_window": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validateStartTimeFormat, + }, + + "preferred_maintenance_window": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validateStartTimeFormat, + }, + + // TODO: + // if you don't specify this, it'll create one. I'm of the opinion that users should + // create it themselves. Or maybe some sort of wrapper module? I dunno. + // if it's optional, will they be computed? if so, is that something we can express here? + "security_group_ids": { + Type: schema.TypeSet, + Required: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + MinItems: 1, + }, + + // TODO: document this role creation + // https://s3.amazonaws.com/opsworks-cm-us-east-1-prod-default-assets/misc/opsworks-cm-roles.yaml + "service_role_arn": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validateArn, + }, + + // TODO: huh? + // The IDs of subnets in which to launch the server EC2 instance. + // + // Amazon EC2-Classic customers: This field is required. All servers must run + // within a VPC. The VPC must have "Auto Assign Public IP" enabled. + // + // EC2-VPC customers: This field is optional. If you do not specify subnet IDs, + // your EC2 instances are created in a default subnet that is selected by Amazon + // EC2. If you specify subnet IDs, the VPC must have "Auto Assign Public IP" + // enabled. + // if it's optional, will they be computed? if so, is that something we can express here? + "subnet_ids": { + Type: schema.TypeSet, + Required: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + MinItems: 1, + }, + + "endpoint": { + Type: schema.TypeString, + Computed: true, + }, + + "arn": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceAwsOpsworksChefValidate(d *schema.ResourceData) error { + // TODO: validation function + // chefPivotalKey validate rsa? or is that too much? + // chefDeliveryAdminPassword validate the password meets the API's requirements or is that too much? + // backupRetentionCount validate that it's positive + // but also maybe validate that it's in the valid range? or is that too much? + // I also wonder if there are constants in the API connector that maybe we can leverage here + return nil +} + +func resourceAwsOpsworksChefRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*AWSClient).opsworkscmconn + + req := &opsworkscm.DescribeServersInput{ + ServerName: aws.String(d.Id()), + } + + log.Printf("[DEBUG] Reading OpsWorks Chef server: %s", d.Id()) + + var resp *opsworkscm.DescribeServersOutput + var dErr error + + resp, dErr = client.DescribeServers(req) + if dErr != nil { + log.Printf("[DEBUG] OpsWorks Chef (%s) not found", d.Id()) + d.SetId("") + return dErr + } + + server := resp.Servers[0] + d.Set("associate_public_ip_address", server.AssociatePublicIpAddress) + d.Set("backup_retention_count", server.BackupRetentionCount) + d.Set("disable_automated_backup", server.DisableAutomatedBackup) + d.Set("instance_profile_arn", server.InstanceProfileArn) + d.Set("instance_type", server.InstanceType) + d.SetId(*server.ServerName) + d.Set("name", server.ServerName) + if server.KeyPair != nil { + d.Set("ssh_key_pair", server.KeyPair) + } + d.Set("preferred_backup_window", server.PreferredBackupWindow) + d.Set("preferred_maintenance_window", server.PreferredMaintenanceWindow) + d.Set("security_group_ids", flattenStringList(server.SecurityGroupIds)) + d.Set("subnet_ids", flattenStringList(server.SubnetIds)) + d.Set("endpoint", server.Endpoint) + d.Set("arn", server.ServerArn) + + return nil +} + +func resourceAwsOpsworksChefCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*AWSClient).opsworkscmconn + + err := resourceAwsOpsworksChefValidate(d) + if err != nil { + return err + } + + req := &opsworkscm.CreateServerInput{ + AssociatePublicIpAddress: aws.Bool(d.Get("associate_public_ip_address").(bool)), + BackupRetentionCount: aws.Int64(int64(d.Get("backup_retention_count").(int))), + DisableAutomatedBackup: aws.Bool(d.Get("disable_automated_backup").(bool)), + Engine: aws.String("Chef"), + EngineModel: aws.String(EngineModel), + EngineVersion: aws.String(EngineVersion), + InstanceProfileArn: aws.String(d.Get("instance_profile_arn").(string)), + InstanceType: aws.String(d.Get("instance_type").(string)), + ServerName: aws.String(d.Get("name").(string)), + PreferredBackupWindow: aws.String(d.Get("preferred_backup_window").(string)), + PreferredMaintenanceWindow: aws.String(d.Get("preferred_maintenance_window").(string)), + SecurityGroupIds: expandStringSet(d.Get("security_group_ids").(*schema.Set)), + ServiceRoleArn: aws.String(d.Get("service_role_arn").(string)), + SubnetIds: expandStringSet(d.Get("subnet_ids").(*schema.Set)), + } + + chefPivotalKeyAttribute := opsworkscm.EngineAttribute{ + Name: aws.String("CHEF_PIVOTAL_KEY"), + Value: aws.String(d.Get("chef_pivotal_key").(string)), + } + + chefDeliveryAdminPassword := opsworkscm.EngineAttribute{ + Name: aws.String("CHEF_DELIVERY_ADMIN_PASSWORD"), + Value: aws.String(d.Get("chef_delivery_admin_password").(string)), + } + + req.SetEngineAttributes([]*opsworkscm.EngineAttribute{&chefPivotalKeyAttribute, &chefDeliveryAdminPassword}) + + // TODO: possibly make these optional + // TODO: security_group_ids, optional + // TODO: subnet_ids, optional + + if sshKeyPair, ok := d.GetOk("ssh_key_pair"); ok { + req.KeyPair = aws.String(sshKeyPair.(string)) + } + + log.Printf("[DEBUG] Creating OpsWorks Chef server: %s", req) + + var resp *opsworkscm.CreateServerOutput + err = resource.Retry(20*time.Minute, func() *resource.RetryError { + var cerr error + resp, cerr = client.CreateServer(req) + if cerr != nil { + if opserr, ok := cerr.(awserr.Error); ok { + // TODO: does this also happen with this resource? + // If Terraform is also managing the service IAM role, + // it may have just been created and not yet be + // propagated. + // AWS doesn't provide a machine-readable code for this + // specific error, so we're forced to do fragile message + // matching. + // The full error we're looking for looks something like + // the following: + // Service Role Arn: [...] is not yet propagated, please try again in a couple of minutes + propErr := "not yet propagated" + trustErr := "not the necessary trust relationship" + validateErr := "validate IAM role permission" + if opserr.Code() == "ValidationException" && (strings.Contains(opserr.Message(), trustErr) || strings.Contains(opserr.Message(), propErr) || strings.Contains(opserr.Message(), validateErr)) { + log.Printf("[INFO] Waiting for service IAM role to propagate") + return resource.RetryableError(cerr) + } + } + return resource.NonRetryableError(cerr) + } + return nil + }) + if err != nil { + return err + } + + serverName := *resp.Server.ServerName + d.SetId(serverName) + + return resourceAwsOpsworksChefUpdate(d, meta) +} + +func resourceAwsOpsworksChefUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*AWSClient).opsworkscmconn + + err := resourceAwsOpsworksChefValidate(d) + if err != nil { + return err + } + + // TODO: these are the only values that are allowed to be changed + // according to the API, is there a way to express this in the provider somehow? + req := &opsworkscm.UpdateServerInput{ + BackupRetentionCount: aws.Int64(int64(d.Get("backup_retention_count").(int))), + DisableAutomatedBackup: aws.Bool(d.Get("disable_automated_backup").(bool)), + PreferredBackupWindow: aws.String(d.Get("preferred_backup_window").(string)), + PreferredMaintenanceWindow: aws.String(d.Get("preferred_maintenance_window").(string)), + ServerName: aws.String(d.Get("name").(string)), + } + + log.Printf("[DEBUG] Updating OpsWorks Chef server: %s", req) + + _, err = client.UpdateServer(req) + if err != nil { + return err + } + + return resourceAwsOpsworksChefRead(d, meta) +} + +func resourceAwsOpsworksChefDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*AWSClient).opsworkscmconn + + req := &opsworkscm.DeleteServerInput{ + ServerName: aws.String(d.Id()), + } + + log.Printf("[DEBUG] Deleting OpsWorks Chef server: %s", d.Id()) + + _, err := client.DeleteServer(req) + if err != nil { + return err + } + + return nil +} diff --git a/aws/resource_aws_opsworks_chef_test.go b/aws/resource_aws_opsworks_chef_test.go new file mode 100644 index 00000000000..def46199dfa --- /dev/null +++ b/aws/resource_aws_opsworks_chef_test.go @@ -0,0 +1,180 @@ +package aws + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform/helper/acctest" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/opsworkscm" +) + +func TestAccAWSOpsworksChefImportBasic(t *testing.T) { + name := acctest.RandString(10) + + resourceName := fmt.Sprintf("aws_opsworks_chef.%s", name) + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAwsOpsworksChefDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAwsOpsworksChefConfigCreate(name), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccAwsOpsworksChefConfigCreate(name string) string { + return fmt.Sprintf(` + resource "aws_vpc" "tf-acc" { + cidr_block = "10.3.5.0/24" + tags = { + Name = "terraform-testacc-opsworks-chef-create" + } + } + + // per https://s3.amazonaws.com/opsworks-cm-us-east-1-prod-default-assets/misc/opsworks-cm-roles.yaml + // per https://docs.aws.amazon.com/sdk-for-go/api/service/opsworkscm/ + resource "aws_iam_role" "opsworks_chef_instance_role" { + name = "opsworks_chef_instance_role" + assume_role_policy = "${data.aws_iam_policy_document.opsworks_chef_instance_assumerole.json}" + path = "/" + } + + data "aws_iam_policy_document" "opsworks_chef_instance_assumerole" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } + } + + resource "aws_iam_role_policy_attachment" "opswork_chef_instance_role_ssm" { + role = "${aws_iam_role.opsworks_chef_instance_role.name}" + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM" + } + + resource "aws_iam_role_policy_attachment" "opsworks_chef_instance_role_profile" { + role = "${aws_iam_role.opsworks_chef_instance_role.name}" + policy_arn = "arn:aws:iam::aws:policy/AWSOpsWorksCMInstanceProfileRole" + } + + resource "aws_iam_instance_profile" "opsworks_chef_instance_profile" { + name = "opsworks_chef_instance_profile" + role = "${aws_iam_role.opsworks_chef_instance_role.name}" + } + + resource "aws_iam_role" "opsworks_chef_service" { + name = "opsworks_chef_service_role" + assume_role_policy = "${data.aws_iam_policy_document.opsworks_chef_service_assumerole.json}" + path = "/" + } + + data "aws_iam_policy_document" "opsworks_chef_service_assumerole" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["opsworks-cm.amazonaws.com"] + } + } + } + + resource "aws_iam_role_policy_attachment" "opsworks_chef_service_role" { + role = "${aws_iam_role.opsworks_chef_service.name}" + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSOpsWorksCMServiceRole" + } + + resource "aws_security_group" "opsworks_chef" { + name = "opsworks_chef" + description = "security group for opsworks chef server" + vpc_id = "${aws_vpc.tf-acc.id}" + + ingress { + protocol = "TCP" + from_port = 0 + to_port = 443 + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + protocol = "TCP" + from_port = 0 + to_port = 22 + cidr_blocks = ["0.0.0.0/0"] + } + } + + resource "aws_subnet" "tf-acc" { + vpc_id = "${aws_vpc.tf-acc.id}" + cidr_block = "${aws_vpc.tf-acc.cidr_block}" + availability-zone = "us-west-2a" + tags = { + Name = "tf-acc-opsworks-chef-create" + } + } + + resource "tls_private_key" "opsworks_chef_rsa_key" { + algorithm = "RSA" + } + + resource "random_string" "opsworks_chef_admin_password" { + length = 24 + min_lower = 1 + min_upper = 1 + min_numeric = 1 + min_special = 1 + override_special = "!/@#$%%^&+=_" + } + + resource "aws_opsworks_chef" "%s" { + chef_pivotal_key = "${tls_private_key.opsworks_chef_rsa_key.private_key_pem}" + chef_delivery_admin_password = "${random_string.opsworks_chef_admin_password.result}" + instance_profile_arn = "${aws_iam_instance_profile.opsworks_chef_instance.arn}" + instance_type = "t2.medium" + preferred_backup_window = "Mon:08:00" + preferred_maintenance_window = "Sun:08:00" + security_group_ids = ["${aws_security_group.opsworks_chef.id}"] + service_role_arn = "${aws_iam_role.opsworks_chef_service.arn}" + subnet_ids = ["${aws_subnet.tf-acc.id}"] + } + `, name) +} + +func testAccCheckAwsOpsworksChefDestroy(s *terraform.State) error { + opsworkscmconn := testAccProvider.Meta().(*AWSClient).opsworkscmconn + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_opsworks_chef" { + continue + } + + req := &opsworkscm.DescribeServersInput{ + ServerName: aws.String(rs.Primary.ID), + } + + _, err := opsworkscmconn.DescribeServers(req) + if err != nil { + if awserr, ok := err.(awserr.Error); ok { + if awserr.Code() == "ResourceNotFoundException" { + // not found, all good + return nil + } + } + return err + } + } + // TODO: implement + return fmt.Errorf("fall through error for OpsWorks Chef test") +} diff --git a/aws/validators.go b/aws/validators.go index 162c6af77b2..1123ff537ec 100644 --- a/aws/validators.go +++ b/aws/validators.go @@ -1011,6 +1011,16 @@ func validateOnceADayWindowFormat(v interface{}, k string) (ws []string, errors return } +func validateStartTimeFormat(v interface{}, k string) (ws []string, errors []error) { + validFormat := "^((mon|tue|wed|thu|fri|sat|sun):)?([0-1][0-9]|2[0-3]):[0-5][0-9]$" + value := strings.ToLower(v.(string)) + if !regexp.MustCompile(validFormat).MatchString(value) { + errors = append(errors, fmt.Errorf( + "%q must satisfy the format of \"ddd:hh24:mi\", (\"ddd:\" is optional).", k)) + } + return +} + // Validates that ECS Placement Constraints are set correctly // Takes type, and expression as strings func validateAwsEcsPlacementConstraint(constType, constExpr string) error {