terraform-provider-google/google/resource_google_project.go
Paddy Carver 9ceb644460 Add google_app_engine_application resource.
Deprecate the app_engine sub-block of google_project, and create a
google_app_engine_application resource instead. Also, add some tests for
its behaviour, as well as some documentation for it.

Note that this is largely an implementation of the ideas discussed in
 #2118, except we're not using CustomizeDiff to reject deletions without
our special flag set, because CustomizeDiff apparently doesn't run on
Delete. Who knew? This leaves us rejecting the deletion at apply time,
which is less than ideal, but the only other option I see is to silently
not delete the resource, and that's... not ideal, either.

This also stops the app_engine sub-block on google_project from forcing
new when it's removed, and sets it to computed, so users can safely move
from using the sub-block to using the resource without state surgery or
deleting their entire project. This does mean it's impossible to delete
an App Engine application from a sub-block now, but seeing as that was
the same situation before, and we just papered over it by making the
project recreate itself in that situation, and people Were Not Fans of
that, I'm considering that an acceptable casualty.
2018-10-02 02:59:17 -07:00

704 lines
21 KiB
Go

package google
import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/hashicorp/terraform/helper/schema"
"github.com/hashicorp/terraform/helper/validation"
appengine "google.golang.org/api/appengine/v1"
"google.golang.org/api/cloudbilling/v1"
"google.golang.org/api/cloudresourcemanager/v1"
"google.golang.org/api/googleapi"
)
// resourceGoogleProject returns a *schema.Resource that allows a customer
// to declare a Google Cloud Project resource.
func resourceGoogleProject() *schema.Resource {
return &schema.Resource{
SchemaVersion: 1,
Create: resourceGoogleProjectCreate,
Read: resourceGoogleProjectRead,
Update: resourceGoogleProjectUpdate,
Delete: resourceGoogleProjectDelete,
Importer: &schema.ResourceImporter{
State: resourceProjectImportState,
},
MigrateState: resourceGoogleProjectMigrateState,
CustomizeDiff: resourceGoogleProjectCustomizeDiff,
Schema: map[string]*schema.Schema{
"project_id": &schema.Schema{
Type: schema.TypeString,
Required: true,
ForceNew: true,
ValidateFunc: validateProjectID(),
},
"skip_delete": &schema.Schema{
Type: schema.TypeBool,
Optional: true,
Computed: true,
},
"auto_create_network": &schema.Schema{
Type: schema.TypeBool,
Optional: true,
Default: true,
},
"name": &schema.Schema{
Type: schema.TypeString,
Required: true,
ValidateFunc: validateProjectName(),
},
"org_id": &schema.Schema{
Type: schema.TypeString,
Optional: true,
Computed: true,
},
"folder_id": &schema.Schema{
Type: schema.TypeString,
Optional: true,
Computed: true,
StateFunc: parseFolderId,
},
"policy_data": &schema.Schema{
Type: schema.TypeString,
Optional: true,
Computed: true,
Removed: "Use the 'google_project_iam_policy' resource to define policies for a Google Project",
},
"policy_etag": &schema.Schema{
Type: schema.TypeString,
Computed: true,
Removed: "Use the the 'google_project_iam_policy' resource to define policies for a Google Project",
},
"number": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"billing_account": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"labels": &schema.Schema{
Type: schema.TypeMap,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
Set: schema.HashString,
},
"app_engine": &schema.Schema{
Type: schema.TypeList,
Optional: true,
Computed: true,
Elem: appEngineResource(),
MaxItems: 1,
Deprecated: "Use the google_app_engine_application resource instead.",
},
},
}
}
func appEngineResource() *schema.Resource {
return &schema.Resource{
Schema: map[string]*schema.Schema{
"auth_domain": &schema.Schema{
Type: schema.TypeString,
Optional: true,
Computed: true,
},
"location_id": &schema.Schema{
Type: schema.TypeString,
Optional: true,
Computed: true,
ValidateFunc: validation.StringInSlice([]string{
"northamerica-northeast1",
"us-central",
"us-west2",
"us-east1",
"us-east4",
"southamerica-east1",
"europe-west",
"europe-west2",
"europe-west3",
"asia-northeast1",
"asia-south1",
"australia-southeast1",
}, false),
},
"serving_status": &schema.Schema{
Type: schema.TypeString,
Optional: true,
ValidateFunc: validation.StringInSlice([]string{
"UNSPECIFIED",
"SERVING",
"USER_DISABLED",
"SYSTEM_DISABLED",
}, false),
Computed: true,
},
"feature_settings": &schema.Schema{
Type: schema.TypeList,
Optional: true,
Computed: true,
MaxItems: 1,
Elem: appEngineFeatureSettingsResource(),
},
"name": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"url_dispatch_rule": &schema.Schema{
Type: schema.TypeList,
Computed: true,
Elem: appEngineURLDispatchRuleResource(),
},
"code_bucket": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"default_hostname": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"default_bucket": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"gcr_domain": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
},
}
}
func appEngineURLDispatchRuleResource() *schema.Resource {
return &schema.Resource{
Schema: map[string]*schema.Schema{
"domain": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"path": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"service": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
},
}
}
func appEngineFeatureSettingsResource() *schema.Resource {
return &schema.Resource{
Schema: map[string]*schema.Schema{
"split_health_checks": &schema.Schema{
Type: schema.TypeBool,
Optional: true,
},
},
}
}
func resourceGoogleProjectCustomizeDiff(diff *schema.ResourceDiff, meta interface{}) error {
if old, _ := diff.GetChange("app_engine.0.location_id"); diff.HasChange("app_engine.0.location_id") && old != nil && old.(string) != "" {
// if location_id was already set, and has a new value, that forces a new app
// if location_id wasn't set, don't force a new value, as we're just enabling app engine
return diff.ForceNew("app_engine.0.location_id")
}
return nil
}
func resourceGoogleProjectCreate(d *schema.ResourceData, meta interface{}) error {
config := meta.(*Config)
var pid string
var err error
pid = d.Get("project_id").(string)
log.Printf("[DEBUG]: Creating new project %q", pid)
project := &cloudresourcemanager.Project{
ProjectId: pid,
Name: d.Get("name").(string),
}
if err := getParentResourceId(d, project); err != nil {
return err
}
if _, ok := d.GetOk("labels"); ok {
project.Labels = expandLabels(d)
}
op, err := config.clientResourceManager.Projects.Create(project).Do()
if err != nil {
return fmt.Errorf("error creating project %s (%s): %s. "+
"If you received a 403 error, make sure you have the"+
" `roles/resourcemanager.projectCreator` permission",
project.ProjectId, project.Name, err)
}
d.SetId(pid)
// Wait for the operation to complete
waitErr := resourceManagerOperationWait(config.clientResourceManager, op, "project to create")
if waitErr != nil {
// The resource wasn't actually created
d.SetId("")
return waitErr
}
// Set the billing account
if _, ok := d.GetOk("billing_account"); ok {
err = updateProjectBillingAccount(d, config)
if err != nil {
return err
}
}
// set up App Engine, too
app, err := expandAppEngineApp(d)
if err != nil {
return err
}
if app != nil {
log.Printf("[DEBUG] Enabling App Engine")
// enable the app engine APIs so we can create stuff
if err = enableService("appengine.googleapis.com", project.ProjectId, config); err != nil {
return fmt.Errorf("Error enabling the App Engine Admin API required to configure App Engine applications: %s", err)
}
log.Printf("[DEBUG] Enabled App Engine")
err = createAppEngineApp(config, pid, app)
if err != nil {
return err
}
}
err = resourceGoogleProjectRead(d, meta)
if err != nil {
return err
}
// There's no such thing as "don't auto-create network", only "delete the network
// post-creation" - but that's what it's called in the UI and let's not confuse
// people if we don't have to. The GCP Console is doing the same thing - creating
// a network and deleting it in the background.
if !d.Get("auto_create_network").(bool) {
// The compute API has to be enabled before we can delete a network.
if err = enableService("compute.googleapis.com", project.ProjectId, config); err != nil {
return fmt.Errorf("Error enabling the Compute Engine API required to delete the default network: %s", err)
}
if err = forceDeleteComputeNetwork(project.ProjectId, "default", config); err != nil {
return fmt.Errorf("Error deleting default network in project %s: %s", project.ProjectId, err)
}
}
return nil
}
func createAppEngineApp(config *Config, pid string, app *appengine.Application) error {
app.Id = pid
log.Printf("[DEBUG] Creating App Engine App")
op, err := config.clientAppEngine.Apps.Create(app).Do()
if err != nil {
return fmt.Errorf("Error creating App Engine application: %s", err.Error())
}
// Wait for the operation to complete
waitErr := appEngineOperationWait(config.clientAppEngine, op, pid, "App Engine app to create")
if waitErr != nil {
return waitErr
}
log.Printf("[DEBUG] Created App Engine App")
return nil
}
func resourceGoogleProjectRead(d *schema.ResourceData, meta interface{}) error {
config := meta.(*Config)
pid := d.Id()
// Read the project
p, err := config.clientResourceManager.Projects.Get(pid).Do()
if err != nil {
return handleNotFoundError(err, d, fmt.Sprintf("Project %q", pid))
}
// If the project has been deleted from outside Terraform, remove it from state file.
if p.LifecycleState != "ACTIVE" {
log.Printf("[WARN] Removing project '%s' because its state is '%s' (requires 'ACTIVE').", pid, p.LifecycleState)
d.SetId("")
return nil
}
d.Set("project_id", pid)
d.Set("number", strconv.FormatInt(int64(p.ProjectNumber), 10))
d.Set("name", p.Name)
d.Set("labels", p.Labels)
if p.Parent != nil {
switch p.Parent.Type {
case "organization":
d.Set("org_id", p.Parent.Id)
d.Set("folder_id", "")
case "folder":
d.Set("folder_id", p.Parent.Id)
d.Set("org_id", "")
}
}
// Read the billing account
ba, err := config.clientBilling.Projects.GetBillingInfo(prefixedProject(pid)).Do()
if err != nil && !isApiNotEnabledError(err) {
return fmt.Errorf("Error reading billing account for project %q: %v", prefixedProject(pid), err)
} else if isApiNotEnabledError(err) {
log.Printf("[WARN] Billing info API not enabled, please enable it to read billing info about project %q: %s", pid, err.Error())
} else 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)
}
// read the App Engine app, if one exists
// we don't have the config available for import, so we can't rely on
// that to read it. And honestly, we want to know if an App exists that
// shouldn't. So this tries to read it, sets it to empty if none exists,
// or sets it in state if one does exist.
app, err := config.clientAppEngine.Apps.Get(pid).Do()
if err != nil && !isGoogleApiErrorWithCode(err, 404) && !isApiNotEnabledError(err) {
return fmt.Errorf("Error retrieving App Engine application %q: %s", pid, err.Error())
} else if isGoogleApiErrorWithCode(err, 404) {
d.Set("app_engine", []map[string]interface{}{})
} else if isApiNotEnabledError(err) {
log.Printf("[WARN] App Engine Admin API not enabled, please enable it to read App Engine info about project %q: %s", pid, err.Error())
} else {
appBlocks, err := flattenAppEngineApp(app)
if err != nil {
return fmt.Errorf("Error serializing App Engine app: %s", err.Error())
}
err = d.Set("app_engine", appBlocks)
if err != nil {
return fmt.Errorf("Error setting App Engine application in state. This is a bug, please report it at https://github.com/terraform-providers/terraform-provider-google/issues. Error is:\n%s", err.Error())
}
}
return nil
}
func prefixedProject(pid string) string {
return "projects/" + pid
}
func getParentResourceId(d *schema.ResourceData, p *cloudresourcemanager.Project) error {
orgId := d.Get("org_id").(string)
folderId := d.Get("folder_id").(string)
if orgId != "" && folderId != "" {
return fmt.Errorf("'org_id' and 'folder_id' cannot be both set.")
}
if orgId != "" {
p.Parent = &cloudresourcemanager.ResourceId{
Id: orgId,
Type: "organization",
}
}
if folderId != "" {
p.Parent = &cloudresourcemanager.ResourceId{
Id: parseFolderId(folderId),
Type: "folder",
}
}
return nil
}
func parseFolderId(v interface{}) string {
folderId := v.(string)
if strings.HasPrefix(folderId, "folders/") {
return folderId[8:]
}
return folderId
}
func resourceGoogleProjectUpdate(d *schema.ResourceData, meta interface{}) error {
config := meta.(*Config)
pid := d.Id()
project_name := d.Get("name").(string)
// Read the project
// we need the project even though refresh has already been called
// because the API doesn't support patch, so we need the actual object
p, err := config.clientResourceManager.Projects.Get(pid).Do()
if err != nil {
if v, ok := err.(*googleapi.Error); ok && v.Code == http.StatusNotFound {
return fmt.Errorf("Project %q does not exist.", pid)
}
return fmt.Errorf("Error checking project %q: %s", pid, err)
}
d.Partial(true)
// Project display name has changed
if ok := d.HasChange("name"); ok {
p.Name = project_name
// Do update on project
p, err = config.clientResourceManager.Projects.Update(p.ProjectId, p).Do()
if err != nil {
return fmt.Errorf("Error updating project %q: %s", project_name, err)
}
d.SetPartial("name")
}
// Project parent has changed
if d.HasChange("org_id") || d.HasChange("folder_id") {
if err := getParentResourceId(d, p); err != nil {
return err
}
// Do update on project
p, err = config.clientResourceManager.Projects.Update(p.ProjectId, p).Do()
if err != nil {
return fmt.Errorf("Error updating project %q: %s", project_name, err)
}
d.SetPartial("org_id")
d.SetPartial("folder_id")
}
// Billing account has changed
if ok := d.HasChange("billing_account"); ok {
err = updateProjectBillingAccount(d, config)
if err != nil {
return err
}
}
// Project Labels have changed
if ok := d.HasChange("labels"); ok {
p.Labels = expandLabels(d)
// Do Update on project
p, err = config.clientResourceManager.Projects.Update(p.ProjectId, p).Do()
if err != nil {
return fmt.Errorf("Error updating project %q: %s", project_name, err)
}
d.SetPartial("labels")
}
// App Engine App has changed
if ok := d.HasChange("app_engine"); ok {
app, err := expandAppEngineApp(d)
if err != nil {
return err
}
// ignore if app is now not set; that should force new resource using customizediff
if app != nil {
if old, new := d.GetChange("app_engine.#"); (old == nil || old.(int) < 1) && new != nil && new.(int) > 0 {
err = createAppEngineApp(config, pid, app)
if err != nil {
return err
}
} else {
log.Printf("[DEBUG] Updating App Engine App")
op, err := config.clientAppEngine.Apps.Patch(pid, app).UpdateMask("authDomain,servingStatus,featureSettings.splitHealthChecks").Do()
if err != nil {
return fmt.Errorf("Error creating App Engine application: %s", err.Error())
}
// Wait for the operation to complete
waitErr := appEngineOperationWait(config.clientAppEngine, op, pid, "App Engine app to update")
if waitErr != nil {
return waitErr
}
log.Printf("[DEBUG] Updated App Engine App")
}
d.SetPartial("app_engine")
}
}
d.Partial(false)
return resourceGoogleProjectRead(d, meta)
}
func resourceGoogleProjectDelete(d *schema.ResourceData, meta interface{}) error {
config := meta.(*Config)
// Only delete projects if skip_delete isn't set
if !d.Get("skip_delete").(bool) {
pid := d.Id()
_, err := config.clientResourceManager.Projects.Delete(pid).Do()
if err != nil {
return fmt.Errorf("Error deleting project %q: %s", pid, err)
}
}
d.SetId("")
return nil
}
func resourceProjectImportState(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) {
// Explicitly set to default as a workaround for `ImportStateVerify` tests, and so that users
// don't see a diff immediately after import.
d.Set("auto_create_network", true)
return []*schema.ResourceData{d}, nil
}
// Delete a compute network along with the firewall rules inside it.
func forceDeleteComputeNetwork(projectId, networkName string, config *Config) error {
networkLink := fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/networks/%s", projectId, networkName)
token := ""
for paginate := true; paginate; {
filter := fmt.Sprintf("network eq %s", networkLink)
resp, err := config.clientCompute.Firewalls.List(projectId).Filter(filter).Do()
if err != nil {
return fmt.Errorf("Error listing firewall rules in proj: %s", err)
}
log.Printf("[DEBUG] Found %d firewall rules in %q network", len(resp.Items), networkName)
for _, firewall := range resp.Items {
op, err := config.clientCompute.Firewalls.Delete(projectId, firewall.Name).Do()
if err != nil {
return fmt.Errorf("Error deleting firewall: %s", err)
}
err = computeSharedOperationWait(config.clientCompute, op, projectId, "Deleting Firewall")
if err != nil {
return err
}
}
token = resp.NextPageToken
paginate = token != ""
}
return deleteComputeNetwork(projectId, networkName, config)
}
func updateProjectBillingAccount(d *schema.ResourceData, config *Config) error {
pid := d.Id()
name := d.Get("billing_account").(string)
ba := &cloudbilling.ProjectBillingInfo{}
// If we're unlinking an existing billing account, an empty request does that, not an empty-string billing account.
if name != "" {
ba.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)
}
for retries := 0; retries < 3; retries++ {
ba, err = config.clientBilling.Projects.GetBillingInfo(prefixedProject(pid)).Do()
if err != nil {
return err
}
baName := strings.TrimPrefix(ba.BillingAccountName, "billingAccounts/")
if baName == name {
return nil
}
time.Sleep(3 * time.Second)
}
return fmt.Errorf("Timed out waiting for billing account to return correct value. Waiting for %s, got %s.",
name, strings.TrimPrefix(ba.BillingAccountName, "billingAccounts/"))
}
func expandAppEngineApp(d *schema.ResourceData) (*appengine.Application, error) {
blocks := d.Get("app_engine").([]interface{})
if len(blocks) < 1 {
return nil, nil
}
if len(blocks) > 1 {
return nil, fmt.Errorf("only one app_engine block may be defined per project")
}
result := &appengine.Application{
AuthDomain: d.Get("app_engine.0.auth_domain").(string),
LocationId: d.Get("app_engine.0.location_id").(string),
Id: d.Get("project_id").(string),
GcrDomain: d.Get("app_engine.0.gcr_domain").(string),
ServingStatus: d.Get("app_engine.0.serving_status").(string),
}
featureSettings, err := expandAppEngineFeatureSettings(d, "app_engine.0.")
if err != nil {
return nil, err
}
result.FeatureSettings = featureSettings
return result, nil
}
func flattenAppEngineApp(app *appengine.Application) ([]map[string]interface{}, error) {
result := map[string]interface{}{
"auth_domain": app.AuthDomain,
"code_bucket": app.CodeBucket,
"default_bucket": app.DefaultBucket,
"default_hostname": app.DefaultHostname,
"location_id": app.LocationId,
"name": app.Name,
"serving_status": app.ServingStatus,
}
dispatchRules, err := flattenAppEngineDispatchRules(app.DispatchRules)
if err != nil {
return nil, err
}
result["url_dispatch_rule"] = dispatchRules
featureSettings, err := flattenAppEngineFeatureSettings(app.FeatureSettings)
if err != nil {
return nil, err
}
result["feature_settings"] = featureSettings
return []map[string]interface{}{result}, nil
}
func expandAppEngineFeatureSettings(d *schema.ResourceData, prefix string) (*appengine.FeatureSettings, error) {
blocks := d.Get(prefix + "feature_settings").([]interface{})
if len(blocks) < 1 {
return nil, nil
}
if len(blocks) > 1 {
return nil, fmt.Errorf("only one feature_settings block may be defined per app")
}
return &appengine.FeatureSettings{
SplitHealthChecks: d.Get(prefix + "feature_settings.0.split_health_checks").(bool),
// force send SplitHealthChecks, so if it's set to false it still gets disabled
ForceSendFields: []string{"SplitHealthChecks"},
}, nil
}
func flattenAppEngineFeatureSettings(settings *appengine.FeatureSettings) ([]map[string]interface{}, error) {
if settings == nil {
return []map[string]interface{}{}, nil
}
result := map[string]interface{}{
"split_health_checks": settings.SplitHealthChecks,
}
return []map[string]interface{}{result}, nil
}
func flattenAppEngineDispatchRules(rules []*appengine.UrlDispatchRule) ([]map[string]interface{}, error) {
results := make([]map[string]interface{}, 0, len(rules))
for _, rule := range rules {
results = append(results, map[string]interface{}{
"domain": rule.Domain,
"path": rule.Path,
"service": rule.Service,
})
}
return results, nil
}