diff --git a/config.go b/config.go index c824c9ee..063c9379 100644 --- a/config.go +++ b/config.go @@ -13,6 +13,7 @@ import ( "golang.org/x/oauth2" "golang.org/x/oauth2/google" "golang.org/x/oauth2/jwt" + "google.golang.org/api/cloudresourcemanager/v1" "google.golang.org/api/compute/v1" "google.golang.org/api/container/v1" "google.golang.org/api/dns/v1" @@ -28,12 +29,13 @@ type Config struct { Project string Region string - clientCompute *compute.Service - clientContainer *container.Service - clientDns *dns.Service - clientStorage *storage.Service - clientSqlAdmin *sqladmin.Service - clientPubsub *pubsub.Service + clientCompute *compute.Service + clientContainer *container.Service + clientDns *dns.Service + clientPubsub *pubsub.Service + clientResourceManager *cloudresourcemanager.Service + clientStorage *storage.Service + clientSqlAdmin *sqladmin.Service } func (c *Config) loadAndValidate() error { @@ -133,6 +135,13 @@ func (c *Config) loadAndValidate() error { } c.clientPubsub.UserAgent = userAgent + log.Printf("[INFO] Instatiating Google CloudResourceManager Client...") + c.clientResourceManager, err = cloudresourcemanager.New(client) + if err != nil { + return err + } + c.clientPubsub.UserAgent = userAgent + return nil } diff --git a/data_source_google_iam_policy_document.go b/data_source_google_iam_policy_document.go new file mode 100644 index 00000000..10c1ed9b --- /dev/null +++ b/data_source_google_iam_policy_document.go @@ -0,0 +1,81 @@ +package google + +import ( + "encoding/json" + "strconv" + + "github.com/hashicorp/terraform/helper/hashcode" + "github.com/hashicorp/terraform/helper/schema" + "google.golang.org/api/cloudresourcemanager/v1" +) + +func dataSourceGoogleIamPolicy() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGoogleIamPolicyRead, + + Schema: map[string]*schema.Schema{ + "binding": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "role": { + Type: schema.TypeString, + Required: true, + }, + "members": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + }, + }, + }, + "policy": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataSourceGoogleIamPolicyMembers(d *schema.Set) []string { + var members []string + members = make([]string, d.Len()) + + for i, v := range d.List() { + members[i] = v.(string) + } + return members +} + +func dataSourceGoogleIamPolicyRead(d *schema.ResourceData, meta interface{}) error { + doc := &cloudresourcemanager.Policy{} + + var bindings []*cloudresourcemanager.Binding + + bindingStatements := d.Get("binding").(*schema.Set) + bindings = make([]*cloudresourcemanager.Binding, bindingStatements.Len()) + doc.Bindings = bindings + + for i, bindingRaw := range bindingStatements.List() { + bindingStatement := bindingRaw.(map[string]interface{}) + doc.Bindings[i] = &cloudresourcemanager.Binding{ + Role: bindingStatement["role"].(string), + Members: dataSourceGoogleIamPolicyMembers(bindingStatement["members"].(*schema.Set)), + } + } + + jsonDoc, err := json.MarshalIndent(doc, "", " ") + if err != nil { + // should never happen if the above code is correct + return err + } + jsonString := string(jsonDoc) + + d.Set("policy", jsonString) + d.SetId(strconv.Itoa(hashcode.String(jsonString))) + + return nil +} diff --git a/provider.go b/provider.go index 28e1b68e..b439f5a2 100644 --- a/provider.go +++ b/provider.go @@ -56,6 +56,10 @@ func Provider() terraform.ResourceProvider { }, }, + DataSourcesMap: map[string]*schema.Resource{ + "google_iam_policy": dataSourceGoogleIamPolicy(), + }, + ResourcesMap: map[string]*schema.Resource{ "google_compute_autoscaler": resourceComputeAutoscaler(), "google_compute_address": resourceComputeAddress(), @@ -89,6 +93,7 @@ func Provider() terraform.ResourceProvider { "google_sql_database": resourceSqlDatabase(), "google_sql_database_instance": resourceSqlDatabaseInstance(), "google_sql_user": resourceSqlUser(), + "google_project": resourceGoogleProject(), "google_pubsub_topic": resourcePubsubTopic(), "google_pubsub_subscription": resourcePubsubSubscription(), "google_storage_bucket": resourceStorageBucket(), diff --git a/resource_google_project.go b/resource_google_project.go new file mode 100644 index 00000000..0b7c6f5f --- /dev/null +++ b/resource_google_project.go @@ -0,0 +1,271 @@ +package google + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + + "github.com/hashicorp/terraform/helper/schema" + "google.golang.org/api/cloudresourcemanager/v1" + "google.golang.org/api/googleapi" +) + +func resourceGoogleProject() *schema.Resource { + return &schema.Resource{ + Create: resourceGoogleProjectCreate, + Read: resourceGoogleProjectRead, + Update: resourceGoogleProjectUpdate, + Delete: resourceGoogleProjectDelete, + + Schema: map[string]*schema.Schema{ + "project": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "policy": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + + "name": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + + "number": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + + "id": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceGoogleProjectCreate(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + + project, err := getProject(d, config) + if err != nil { + return err + } + + d.SetId(project) + if err := resourceGoogleProjectRead(d, meta); err != nil { + return err + } + + // Apply the IAM policy if it is set + if pString, ok := d.GetOk("policy"); ok { + // The policy string is just a marshaled cloudresourcemanager.Policy. + // Unmarshal it to a struct. + var policy cloudresourcemanager.Policy + if err = json.Unmarshal([]byte(pString.(string)), &policy); err != nil { + return err + } + + // Retrieve existing IAM policy from project. This will be merged + // with the policy defined here. + // TODO(evanbrown): Add an 'authoritative' flag that allows policy + // in manifest to overwrite existing policy. + p, err := getProjectIamPolicy(project, config) + if err != nil { + return err + } + log.Printf("[DEBUG] Got existing bindings from project: %#v", p.Bindings) + + // Merge the existing policy bindings with those defined in this manifest. + p.Bindings = mergeBindings(append(p.Bindings, policy.Bindings...)) + + // Apply the merged policy + log.Printf("[DEBUG] Setting new policy for project: %#v", p) + _, err = config.clientResourceManager.Projects.SetIamPolicy(project, + &cloudresourcemanager.SetIamPolicyRequest{Policy: p}).Do() + + if err != nil { + return fmt.Errorf("Error applying IAM policy for project %q: %s", project, err) + } + } + return nil +} + +func resourceGoogleProjectRead(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + project, err := getProject(d, config) + if err != nil { + return err + } + + // Confirm the project exists. + // TODO(evanbrown): Support project creation + p, err := config.clientResourceManager.Projects.Get(project).Do() + if err != nil { + if v, ok := err.(*googleapi.Error); ok && v.Code == http.StatusNotFound { + return fmt.Errorf("Project %q does not exist. The Google provider does not currently support new project creation.", project) + } + return fmt.Errorf("Error checking project %q: %s", project, err) + } + + d.Set("number", strconv.FormatInt(int64(p.ProjectNumber), 10)) + d.Set("name", p.Name) + + return nil +} + +func resourceGoogleProjectUpdate(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + project, err := getProject(d, config) + if err != nil { + return err + } + + // Policy has changed + if ok := d.HasChange("policy"); ok { + // The policy string is just a marshaled cloudresourcemanager.Policy. + // Unmarshal it to a struct that contains the old and new policies + oldPString, newPString := d.GetChange("policy") + var oldPolicy, newPolicy cloudresourcemanager.Policy + if err = json.Unmarshal([]byte(newPString.(string)), &newPolicy); err != nil { + return err + } + if err = json.Unmarshal([]byte(oldPString.(string)), &oldPolicy); err != nil { + return err + } + + // Find any Roles and Members that were removed (i.e., those that are present + // in the old but absent in the new + oldMap := rolesToMembersMap(oldPolicy.Bindings) + newMap := rolesToMembersMap(newPolicy.Bindings) + deleted := make(map[string]string) + + // Get each role and its associated members in the old state + for role, members := range oldMap { + // The role exists in the new state + if _, ok := newMap[role]; ok { + // Check each memeber + for member, _ := range members { + // Member does not exist in new state, so it was deleted + if _, ok = newMap[role][member]; !ok { + deleted[role] = member + } + } + } else { + // This indicates an entire role was deleted. Mark all members + // for delete. + for member, _ := range members { + deleted[role] = member + } + } + } + log.Printf("[DEBUG] Roles and Members to be deleted: %#v", deleted) + + // Retrieve existing IAM policy from project. This will be merged + // with the policy in the current state + // TODO(evanbrown): Add an 'authoritative' flag that allows policy + // in manifest to overwrite existing policy. + p, err := getProjectIamPolicy(project, config) + if err != nil { + return err + } + log.Printf("[DEBUG] Got existing bindings from project: %#v", p.Bindings) + + // Merge existing policy with policy in the current state + log.Printf("[DEBUG] Merging new bindings from project: %#v", newPolicy.Bindings) + mergedBindings := mergeBindings(append(p.Bindings, newPolicy.Bindings...)) + + // Remove any roles and members that were explicitly deleted + mergedBindingsMap := rolesToMembersMap(mergedBindings) + for role, member := range deleted { + delete(mergedBindingsMap[role], member) + } + + p.Bindings = rolesToMembersBinding(mergedBindingsMap) + log.Printf("[DEBUG] Setting new policy for project: %#v", p) + + dump, _ := json.MarshalIndent(p.Bindings, " ", " ") + log.Printf(string(dump)) + _, err = config.clientResourceManager.Projects.SetIamPolicy(project, + &cloudresourcemanager.SetIamPolicyRequest{Policy: p}).Do() + + if err != nil { + return fmt.Errorf("Error applying IAM policy for project %q: %s", project, err) + } + } + + return nil +} + +func resourceGoogleProjectDelete(d *schema.ResourceData, meta interface{}) error { + d.SetId("") + return nil +} + +func getProjectIamPolicy(project string, config *Config) (*cloudresourcemanager.Policy, error) { + p, err := config.clientResourceManager.Projects.GetIamPolicy(project, + &cloudresourcemanager.GetIamPolicyRequest{}).Do() + + if err != nil { + return nil, fmt.Errorf("Error retrieving IAM policy for project %q: %s", project, err) + } + return p, nil +} + +// Convert a map of roles->members to a list of Binding +func rolesToMembersBinding(m map[string]map[string]bool) []*cloudresourcemanager.Binding { + bindings := make([]*cloudresourcemanager.Binding, 0) + for role, members := range m { + b := cloudresourcemanager.Binding{ + Role: role, + Members: make([]string, 0), + } + for m, _ := range members { + b.Members = append(b.Members, m) + } + bindings = append(bindings, &b) + } + return bindings +} + +// Map a role to a map of members, allowing easy merging of multiple bindings. +func rolesToMembersMap(bindings []*cloudresourcemanager.Binding) map[string]map[string]bool { + bm := make(map[string]map[string]bool) + // Get each binding + for _, b := range bindings { + // Initialize members map + if _, ok := bm[b.Role]; !ok { + bm[b.Role] = make(map[string]bool) + } + // Get each member (user/principal) for the binding + for _, m := range b.Members { + // Add the member + bm[b.Role][m] = true + } + } + return bm +} + +// Merge multiple Bindings such that Bindings with the same Role result in +// a single Binding with combined Members +func mergeBindings(bindings []*cloudresourcemanager.Binding) []*cloudresourcemanager.Binding { + bm := rolesToMembersMap(bindings) + rb := make([]*cloudresourcemanager.Binding, 0) + + for role, members := range bm { + var b cloudresourcemanager.Binding + b.Role = role + b.Members = make([]string, 0) + for m, _ := range members { + b.Members = append(b.Members, m) + } + rb = append(rb, &b) + } + + return rb +} diff --git a/resource_google_project_test.go b/resource_google_project_test.go new file mode 100644 index 00000000..2867530d --- /dev/null +++ b/resource_google_project_test.go @@ -0,0 +1,198 @@ +package google + +import ( + "reflect" + "sort" + "testing" + + "google.golang.org/api/cloudresourcemanager/v1" +) + +type Binding []cloudresourcemanager.Binding + +func (b Binding) Len() int { + return len(b) +} + +func (b Binding) Swap(i, j int) { + b[i], b[j] = b[j], b[i] +} + +func (b Binding) Less(i, j int) bool { + return b[i].Role < b[j].Role +} + +func TestIamMapRolesToMembers(t *testing.T) { + table := []struct { + input []cloudresourcemanager.Binding + expect map[string]map[string]bool + }{ + { + input: []cloudresourcemanager.Binding{ + { + Role: "role-1", + Members: []string{ + "member-1", + "member-2", + }, + }, + }, + expect: map[string]map[string]bool{ + "role-1": map[string]bool{ + "member-1": true, + "member-2": true, + }, + }, + }, + { + input: []cloudresourcemanager.Binding{ + { + Role: "role-1", + Members: []string{ + "member-1", + "member-2", + "member-1", + "member-2", + }, + }, + }, + expect: map[string]map[string]bool{ + "role-1": map[string]bool{ + "member-1": true, + "member-2": true, + }, + }, + }, + { + input: []cloudresourcemanager.Binding{ + { + Role: "role-1", + }, + }, + expect: map[string]map[string]bool{ + "role-1": map[string]bool{}, + }, + }, + } + + for _, test := range table { + got := mapRolesToMembers(test.input) + if !reflect.DeepEqual(got, test.expect) { + t.Errorf("got %+v, expected %+v", got, test.expect) + } + } +} + +func TestIamMergeBindings(t *testing.T) { + table := []struct { + input []cloudresourcemanager.Binding + expect []cloudresourcemanager.Binding + }{ + { + input: []cloudresourcemanager.Binding{ + { + Role: "role-1", + Members: []string{ + "member-1", + "member-2", + }, + }, + { + Role: "role-1", + Members: []string{ + "member-3", + }, + }, + }, + expect: []cloudresourcemanager.Binding{ + { + Role: "role-1", + Members: []string{ + "member-1", + "member-2", + "member-3", + }, + }, + }, + }, + { + input: []cloudresourcemanager.Binding{ + { + Role: "role-1", + Members: []string{ + "member-3", + "member-4", + }, + }, + { + Role: "role-1", + Members: []string{ + "member-2", + "member-1", + }, + }, + { + Role: "role-2", + Members: []string{ + "member-1", + }, + }, + { + Role: "role-1", + Members: []string{ + "member-5", + }, + }, + { + Role: "role-3", + Members: []string{ + "member-1", + }, + }, + { + Role: "role-2", + Members: []string{ + "member-2", + }, + }, + }, + expect: []cloudresourcemanager.Binding{ + { + Role: "role-1", + Members: []string{ + "member-1", + "member-2", + "member-3", + "member-4", + "member-5", + }, + }, + { + Role: "role-2", + Members: []string{ + "member-1", + "member-2", + }, + }, + { + Role: "role-3", + Members: []string{ + "member-1", + }, + }, + }, + }, + } + + for _, test := range table { + got := mergeBindings(test.input) + sort.Sort(Binding(got)) + for i, _ := range got { + sort.Strings(got[i].Members) + } + + if !reflect.DeepEqual(got, test.expect) { + t.Errorf("\ngot %+v\nexpected %+v", got, test.expect) + } + } +}