diff --git a/data_source_google_iam_policy_document.go b/data_source_google_iam_policy_document.go index 10c1ed9b..79cdabd5 100644 --- a/data_source_google_iam_policy_document.go +++ b/data_source_google_iam_policy_document.go @@ -9,10 +9,21 @@ import ( "google.golang.org/api/cloudresourcemanager/v1" ) +// dataSourceGoogleIamPolicy returns a *schema.Resource that allows a customer +// to express a Google Cloud IAM policy in a data resource. This is an example +// of how the schema would be used in a config: +// +// data "google_iam_policy" "admin" { +// binding { +// role = "roles/storage.objectViewer" +// members = [ +// "user:evanbrown@google.com", +// ] +// } +// } func dataSourceGoogleIamPolicy() *schema.Resource { return &schema.Resource{ Read: dataSourceGoogleIamPolicyRead, - Schema: map[string]*schema.Schema{ "binding": { Type: schema.TypeSet, @@ -40,6 +51,45 @@ func dataSourceGoogleIamPolicy() *schema.Resource { } } +// dataSourceGoogleIamPolicyRead reads a data source from config and writes it +// to state. +func dataSourceGoogleIamPolicyRead(d *schema.ResourceData, meta interface{}) error { + var policy cloudresourcemanager.Policy + var bindings []*cloudresourcemanager.Binding + + // The schema supports multiple binding{} blocks + bset := d.Get("binding").(*schema.Set) + + // All binding{} blocks will be converted and stored in an array + bindings = make([]*cloudresourcemanager.Binding, bset.Len()) + policy.Bindings = bindings + + // Convert each config binding into a cloudresourcemanager.Binding + for i, v := range bset.List() { + binding := v.(map[string]interface{}) + policy.Bindings[i] = &cloudresourcemanager.Binding{ + Role: binding["role"].(string), + Members: dataSourceGoogleIamPolicyMembers(binding["members"].(*schema.Set)), + } + } + + // Marshal cloudresourcemanager.Policy to JSON suitable for storing in state + pjson, err := json.Marshal(&policy) + if err != nil { + // should never happen if the above code is correct + return err + } + pstring := string(pjson) + + d.Set("policy", pstring) + d.SetId(strconv.Itoa(hashcode.String(pstring))) + + return nil +} + +// dataSourceGoogleIamPolicyMembers converts a set of members in a binding +// (a member is a principal, usually an e-mail address) into an array of +// string. func dataSourceGoogleIamPolicyMembers(d *schema.Set) []string { var members []string members = make([]string, d.Len()) @@ -49,33 +99,3 @@ func dataSourceGoogleIamPolicyMembers(d *schema.Set) []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/resource_google_project.go b/resource_google_project.go index 8adc7582..b922951b 100644 --- a/resource_google_project.go +++ b/resource_google_project.go @@ -12,6 +12,17 @@ import ( "google.golang.org/api/googleapi" ) +// resourceGoogleProject returns a *schema.Resource that allows a customer +// to declare a Google Cloud Project resource. // +// Only the 'policy' property of a project may be updated. All other properties +// are computed. +// +// This example shows a project with a policy declared in config: +// +// resource "google_project" "my-project" { +// project = "a-project-id" +// policy = "${data.google_iam_policy.admin.policy}" +// } func resourceGoogleProject() *schema.Resource { return &schema.Resource{ Create: resourceGoogleProjectCreate, @@ -49,6 +60,9 @@ func resourceGoogleProject() *schema.Resource { } } +// This resource supports creation, but not in the traditional sense. +// A new Google Cloud Project can not be created. Instead, an existing Project +// is initialized and made available as a Terraform resource. func resourceGoogleProjectCreate(d *schema.ResourceData, meta interface{}) error { config := meta.(*Config) @@ -142,8 +156,8 @@ func resourceGoogleProjectUpdate(d *schema.ResourceData, meta interface{}) error newPString = "{}" } - oldPStringf, _ := json.MarshalIndent(oldPString, " ", " ") - newPStringf, _ := json.MarshalIndent(newPString, " ", " ") + oldPStringf, _ := json.MarshalIndent(oldPString, "", " ") + newPStringf, _ := json.MarshalIndent(newPString, "", " ") log.Printf("[DEBUG]: Old policy: %v\nNew policy: %v", string(oldPStringf), string(newPStringf)) var oldPolicy, newPolicy cloudresourcemanager.Policy @@ -158,24 +172,28 @@ func resourceGoogleProjectUpdate(d *schema.ResourceData, meta interface{}) error // in the old but absent in the new oldMap := rolesToMembersMap(oldPolicy.Bindings) newMap := rolesToMembersMap(newPolicy.Bindings) - deleted := make(map[string]string) + deleted := make(map[string]map[string]bool) // Get each role and its associated members in the old state for role, members := range oldMap { + // Initialize map for role + if _, ok := deleted[role]; !ok { + deleted[role] = make(map[string]bool) + } // 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 + deleted[role][member] = true } } } else { // This indicates an entire role was deleted. Mark all members // for delete. for member, _ := range members { - deleted[role] = member + deleted[role][member] = true } } } @@ -197,8 +215,10 @@ func resourceGoogleProjectUpdate(d *schema.ResourceData, meta interface{}) error // Remove any roles and members that were explicitly deleted mergedBindingsMap := rolesToMembersMap(mergedBindings) - for role, member := range deleted { - delete(mergedBindingsMap[role], member) + for role, members := range deleted { + for member, _ := range members { + delete(mergedBindingsMap[role], member) + } } p.Bindings = rolesToMembersBinding(mergedBindingsMap) @@ -222,6 +242,7 @@ func resourceGoogleProjectDelete(d *schema.ResourceData, meta interface{}) error return nil } +// Retrieve the existing IAM Policy for a Project func getProjectIamPolicy(project string, config *Config) (*cloudresourcemanager.Policy, error) { p, err := config.clientResourceManager.Projects.GetIamPolicy(project, &cloudresourcemanager.GetIamPolicyRequest{}).Do() diff --git a/resource_google_project_test.go b/resource_google_project_test.go index 769da956..c5b4ad7c 100644 --- a/resource_google_project_test.go +++ b/resource_google_project_test.go @@ -1,25 +1,186 @@ package google import ( + "encoding/json" + "fmt" + "os" "reflect" "sort" "testing" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" "google.golang.org/api/cloudresourcemanager/v1" ) -type Binding []*cloudresourcemanager.Binding +var ( + projectId = multiEnvSearch([]string{ + "GOOGLE_PROJECT", + "GCLOUD_PROJECT", + "CLOUDSDK_CORE_PROJECT", + }) +) -func (b Binding) Len() int { - return len(b) +func multiEnvSearch(ks []string) string { + for _, k := range ks { + if v := os.Getenv(k); v != "" { + return v + } + } + return "" } -func (b Binding) Swap(i, j int) { - b[i], b[j] = b[j], b[i] +// Test that a Project resource can be created and destroyed +func TestAccGoogleProject_associate(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: fmt.Sprintf(testAccGoogleProject_basic, projectId), + Check: resource.ComposeTestCheckFunc( + testAccCheckGoogleProjectExists("google_project.acceptance"), + ), + }, + }, + }) } -func (b Binding) Less(i, j int) bool { - return b[i].Role < b[j].Role +// Test that a Project resource can be created, an IAM Policy +// associated with it, and then destroyed +func TestAccGoogleProject_iamPolicy1(t *testing.T) { + var policy *cloudresourcemanager.Policy + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckGoogleProjectDestroy, + Steps: []resource.TestStep{ + // First step inventories the project's existing IAM policy + resource.TestStep{ + Config: fmt.Sprintf(testAccGoogleProject_basic, projectId), + Check: resource.ComposeTestCheckFunc( + testAccGoogleProjectExistingPolicy(policy), + ), + }, + // Second step applies an IAM policy from a data source. The application + // merges policies, so we validate the expected state. + resource.TestStep{ + Config: fmt.Sprintf(testAccGoogleProject_policy1, projectId), + Check: resource.ComposeTestCheckFunc( + testAccCheckGoogleProjectExists("google_project.acceptance"), + testAccCheckGoogleProjectIamPolicyIsMerged("google_project.acceptance", "data.google_iam_policy.admin", policy), + ), + }, + // Finally, remove the custom IAM policy from config and apply, then + // confirm that the project is in its original state. + resource.TestStep{ + Config: fmt.Sprintf(testAccGoogleProject_basic, projectId), + }, + }, + }) +} + +func testAccCheckGoogleProjectDestroy(s *terraform.State) error { + return nil +} + +// Retrieve the existing policy (if any) for a GCP Project +func testAccGoogleProjectExistingPolicy(p *cloudresourcemanager.Policy) resource.TestCheckFunc { + return func(s *terraform.State) error { + c := testAccProvider.Meta().(*Config) + var err error + p, err = getProjectIamPolicy(projectId, c) + if err != nil { + return fmt.Errorf("Failed to retrieve IAM Policy for project %q: %s", projectId, err) + } + if len(p.Bindings) == 0 { + return fmt.Errorf("Refuse to run test against project with zero IAM Bindings. This is likely an error in the test code that is not properly identifying the IAM policy of a project.") + } + return nil + } +} + +func testAccCheckGoogleProjectExists(r string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[r] + if !ok { + return fmt.Errorf("Not found: %s", r) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + + if rs.Primary.ID != projectId { + return fmt.Errorf("Expected project %q to match ID %q in state", projectId, rs.Primary.ID) + } + + return nil + } +} + +func testAccCheckGoogleProjectIamPolicyIsMerged(projectRes, policyRes string, original *cloudresourcemanager.Policy) resource.TestCheckFunc { + return func(s *terraform.State) error { + // Get the project resource + project, ok := s.RootModule().Resources[projectRes] + if !ok { + return fmt.Errorf("Not found: %s", projectRes) + } + // The project ID should match the config's project ID + if project.Primary.ID != projectId { + return fmt.Errorf("Expected project %q to match ID %q in state", projectId, project.Primary.ID) + } + + var projectP, policyP cloudresourcemanager.Policy + // The project should have a policy + ps, ok := project.Primary.Attributes["policy"] + if !ok { + return fmt.Errorf("Project resource %q did not have a 'policy' attribute", project.Primary.ID) + } + if err := json.Unmarshal([]byte(ps), &projectP); err != nil { + return err + } + + // The data policy resource should have a policy + policy, ok := s.RootModule().Resources[policyRes] + if !ok { + return fmt.Errorf("Not found: %s", policyRes) + } + ps, ok = policy.Primary.Attributes["policy"] + if !ok { + return fmt.Errorf("Policy resource %q did not have a 'policy' attribute", policy.Primary.ID) + } + if err := json.Unmarshal([]byte(ps), &policyP); err != nil { + return err + } + + // The bindings in both policies should be identical + if !reflect.DeepEqual(derefBindings(projectP.Bindings), derefBindings(policyP.Bindings)) { + return fmt.Errorf("Project and data source policies do not match: project policy is %+v, data resource policy is %+v", derefBindings(projectP.Bindings), derefBindings(policyP.Bindings)) + } + return nil + + // Merge the project policy in Terrafomr state with the policy the project had before the config was applied + expected := make([]*cloudresourcemanager.Binding, 0) + expected = append(expected, original.Bindings...) + expected = append(expected, projectP.Bindings...) + expectedM := mergeBindings(expected) + + // Retrieve the actual policy from the project + c := testAccProvider.Meta().(*Config) + actual, err := getProjectIamPolicy(projectId, c) + if err != nil { + return fmt.Errorf("Failed to retrieve IAM Policy for project %q: %s", projectId, err) + } + actualM := mergeBindings(actual.Bindings) + + // The bindings should match, indicating the policy was successfully applied and merged + if !reflect.DeepEqual(derefBindings(actualM), derefBindings(expectedM)) { + return fmt.Errorf("Actual and expected project policies do not match: actual policy is %+v, expected policy is %+v", derefBindings(actualM), derefBindings(expectedM)) + } + + return nil + } } func TestIamRolesToMembersBinding(t *testing.T) { @@ -148,15 +309,6 @@ func TestIamRolesToMembersMap(t *testing.T) { } } -func derefBindings(b []*cloudresourcemanager.Binding) []cloudresourcemanager.Binding { - db := make([]cloudresourcemanager.Binding, len(b)) - - for i, v := range b { - db[i] = *v - } - return db -} - func TestIamMergeBindings(t *testing.T) { table := []struct { input []*cloudresourcemanager.Binding @@ -270,3 +422,52 @@ func TestIamMergeBindings(t *testing.T) { } } } + +func derefBindings(b []*cloudresourcemanager.Binding) []cloudresourcemanager.Binding { + db := make([]cloudresourcemanager.Binding, len(b)) + + for i, v := range b { + db[i] = *v + } + return db +} + +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 +} + +var testAccGoogleProject_basic = ` +resource "google_project" "acceptance" { + project = "%v" +}` + +var testAccGoogleProject_policy1 = ` +resource "google_project" "acceptance" { + project = "%v" + policy = "${data.google_iam_policy.admin.policy}" +} + +data "google_iam_policy" "admin" { + binding { + role = "roles/storage.objectViewer" + members = [ + "user:evanbrown@google.com", + ] + } + binding { + role = "roles/compute.instanceAdmin" + members = [ + "user:evanbrown@google.com", + "user:evandbrown@gmail.com", + ] + } + +}`