providers/google: google_project supports billing account (#11653)

* Vendor google.golang.org/api/cloudbilling/v1

* providers/google: Add cloudbilling client

* providers/google: google_project supports billing account

This change allows a Terraform user to set and update the billing
account associated with their project.

* providers/google: Testing project billing account

This change adds optional acceptance tests for project billing accounts.
GOOGLE_PROJECT_BILLING_ACCOUNT and GOOGLE_PROJECT_BILLING_ACCOUNT_2
must be set in the environment for the tests to run; otherwise, they
will be skipped.

Also includes a few code cleanups per review.

* providers/google: Improve project billing error message
This commit is contained in:
Evan Brown 2017-02-20 09:32:24 -08:00 committed by Paul Stack
parent 108ef0e4f7
commit bf10e5519d
4 changed files with 182 additions and 0 deletions

View File

@ -13,6 +13,7 @@ import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/jwt"
"google.golang.org/api/cloudbilling/v1"
"google.golang.org/api/cloudresourcemanager/v1"
"google.golang.org/api/compute/v1"
"google.golang.org/api/container/v1"
@ -31,6 +32,7 @@ type Config struct {
Project string
Region string
clientBilling *cloudbilling.Service
clientCompute *compute.Service
clientContainer *container.Service
clientDns *dns.Service
@ -160,6 +162,13 @@ func (c *Config) loadAndValidate() error {
}
c.clientServiceMan.UserAgent = userAgent
log.Printf("[INFO] Instantiating Google Cloud Billing Client...")
c.clientBilling, err = cloudbilling.New(client)
if err != nil {
return err
}
c.clientBilling.UserAgent = userAgent
return nil
}

View File

@ -6,8 +6,10 @@ import (
"log"
"net/http"
"strconv"
"strings"
"github.com/hashicorp/terraform/helper/schema"
"google.golang.org/api/cloudbilling/v1"
"google.golang.org/api/cloudresourcemanager/v1"
"google.golang.org/api/googleapi"
)
@ -86,6 +88,10 @@ func resourceGoogleProject() *schema.Resource {
Type: schema.TypeString,
Computed: true,
},
"billing_account": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
},
}
}
@ -172,6 +178,22 @@ func resourceGoogleProjectCreate(d *schema.ResourceData, meta interface{}) error
}
}
// Set the billing account
if v, ok := d.GetOk("billing_account"); ok {
name := v.(string)
ba := cloudbilling.ProjectBillingInfo{
BillingAccountName: "billingAccounts/" + name,
}
_, err = config.clientBilling.Projects.UpdateBillingInfo(prefixedProject(pid), &ba).Do()
if err != nil {
d.Set("billing_account", "")
if _err, ok := err.(*googleapi.Error); ok {
return fmt.Errorf("Error setting billing account %q for project %q: %v", name, prefixedProject(pid), _err)
}
return fmt.Errorf("Error setting billing account %q for project %q: %v", name, prefixedProject(pid), err)
}
}
return resourceGoogleProjectRead(d, meta)
}
@ -196,9 +218,30 @@ func resourceGoogleProjectRead(d *schema.ResourceData, meta interface{}) error {
d.Set("org_id", p.Parent.Id)
}
// Read the billing account
ba, err := config.clientBilling.Projects.GetBillingInfo(prefixedProject(pid)).Do()
if err != nil {
return fmt.Errorf("Error reading billing account for project %q: %v", prefixedProject(pid), err)
}
if ba.BillingAccountName != "" {
// BillingAccountName is contains the resource name of the billing account
// associated with the project, if any. For example,
// `billingAccounts/012345-567890-ABCDEF`. We care about the ID and not
// the `billingAccounts/` prefix, so we need to remove that. If the
// prefix ever changes, we'll validate to make sure it's something we
// recognize.
_ba := strings.TrimPrefix(ba.BillingAccountName, "billingAccounts/")
if ba.BillingAccountName == _ba {
return fmt.Errorf("Error parsing billing account for project %q. Expected value to begin with 'billingAccounts/' but got %s", prefixedProject(pid), ba.BillingAccountName)
}
d.Set("billing_account", _ba)
}
return nil
}
func prefixedProject(pid string) string {
return "projects/" + pid
}
func resourceGoogleProjectUpdate(d *schema.ResourceData, meta interface{}) error {
config := meta.(*Config)
pid := d.Id()
@ -224,6 +267,21 @@ func resourceGoogleProjectUpdate(d *schema.ResourceData, meta interface{}) error
}
}
// Billing account has changed
if ok := d.HasChange("billing_account"); ok {
name := d.Get("billing_account").(string)
ba := cloudbilling.ProjectBillingInfo{
BillingAccountName: "billingAccounts/" + name,
}
_, err = config.clientBilling.Projects.UpdateBillingInfo(prefixedProject(pid), &ba).Do()
if err != nil {
d.Set("billing_account", "")
if _err, ok := err.(*googleapi.Error); ok {
return fmt.Errorf("Error updating billing account %q for project %q: %v", name, prefixedProject(pid), _err)
}
return fmt.Errorf("Error updating billing account %q for project %q: %v", name, prefixedProject(pid), err)
}
}
return updateProjectIamPolicy(d, config, pid)
}

View File

@ -624,3 +624,13 @@ resource "google_project" "acceptance" {
org_id = "%s"
}`, pid, name, org)
}
func testAccGoogleProject_createBilling(pid, name, org, billing string) string {
return fmt.Sprintf(`
resource "google_project" "acceptance" {
project_id = "%s"
name = "%s"
org_id = "%s"
billing_account = "%s"
}`, pid, name, org, billing)
}

View File

@ -3,6 +3,7 @@ package google
import (
"fmt"
"os"
"strings"
"testing"
"github.com/hashicorp/terraform/helper/acctest"
@ -48,6 +49,76 @@ func TestAccGoogleProject_create(t *testing.T) {
})
}
// Test that a Project resource can be created with an associated
// billing account
func TestAccGoogleProject_createBilling(t *testing.T) {
skipIfEnvNotSet(t,
[]string{
"GOOGLE_ORG",
"GOOGLE_BILLING_ACCOUNT",
}...,
)
billingId := os.Getenv("GOOGLE_BILLING_ACCOUNT")
pid := "terraform-" + acctest.RandString(10)
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
Steps: []resource.TestStep{
// This step creates a new project with a billing account
resource.TestStep{
Config: testAccGoogleProject_createBilling(pid, pname, org, billingId),
Check: resource.ComposeTestCheckFunc(
testAccCheckGoogleProjectHasBillingAccount("google_project.acceptance", pid, billingId),
),
},
},
})
}
// Test that a Project resource can be created and updated
// with billing account information
func TestAccGoogleProject_updateBilling(t *testing.T) {
skipIfEnvNotSet(t,
[]string{
"GOOGLE_ORG",
"GOOGLE_BILLING_ACCOUNT",
"GOOGLE_BILLING_ACCOUNT_2",
}...,
)
billingId := os.Getenv("GOOGLE_BILLING_ACCOUNT")
billingId2 := os.Getenv("GOOGLE_BILLING_ACCOUNT_2")
pid := "terraform-" + acctest.RandString(10)
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
Steps: []resource.TestStep{
// This step creates a new project without a billing account
resource.TestStep{
Config: testAccGoogleProject_create(pid, pname, org),
Check: resource.ComposeTestCheckFunc(
testAccCheckGoogleProjectExists("google_project.acceptance", pid),
),
},
// Update to include a billing account
resource.TestStep{
Config: testAccGoogleProject_createBilling(pid, pname, org, billingId),
Check: resource.ComposeTestCheckFunc(
testAccCheckGoogleProjectHasBillingAccount("google_project.acceptance", pid, billingId),
),
},
// Update to a different billing account
resource.TestStep{
Config: testAccGoogleProject_createBilling(pid, pname, org, billingId2),
Check: resource.ComposeTestCheckFunc(
testAccCheckGoogleProjectHasBillingAccount("google_project.acceptance", pid, billingId2),
),
},
},
})
}
// Test that a Project resource merges the IAM policies that already
// exist, and won't lock people out.
func TestAccGoogleProject_merge(t *testing.T) {
@ -95,6 +166,32 @@ func testAccCheckGoogleProjectExists(r, pid string) resource.TestCheckFunc {
}
}
func testAccCheckGoogleProjectHasBillingAccount(r, pid, billingId string) resource.TestCheckFunc {
return func(s *terraform.State) error {
rs, ok := s.RootModule().Resources[r]
if !ok {
return fmt.Errorf("Not found: %s", r)
}
// State should match expected
if rs.Primary.Attributes["billing_account"] != billingId {
return fmt.Errorf("Billing ID in state (%s) does not match expected value (%s)", rs.Primary.Attributes["billing_account"], billingId)
}
// Actual value in API should match state and expected
// Read the billing account
config := testAccProvider.Meta().(*Config)
ba, err := config.clientBilling.Projects.GetBillingInfo(prefixedProject(pid)).Do()
if err != nil {
return fmt.Errorf("Error reading billing account for project %q: %v", prefixedProject(pid), err)
}
if billingId != strings.TrimPrefix(ba.BillingAccountName, "billingAccounts/") {
return fmt.Errorf("Billing ID returned by API (%s) did not match expected value (%s)", ba.BillingAccountName, billingId)
}
return nil
}
}
func testAccCheckGoogleProjectHasMoreBindingsThan(pid string, count int) resource.TestCheckFunc {
return func(s *terraform.State) error {
policy, err := getProjectIamPolicy(pid, testAccProvider.Meta().(*Config))
@ -167,3 +264,11 @@ resource "google_project" "acceptance" {
org_id = "%s"
}`, pid, name, org)
}
func skipIfEnvNotSet(t *testing.T, envs ...string) {
for _, k := range envs {
if os.Getenv(k) == "" {
t.Skipf("Environment variable %s is not set", k)
}
}
}