From 9ceb6444608d3590d0dd9415447a891dc5805e43 Mon Sep 17 00:00:00 2001 From: Paddy Carver Date: Tue, 2 Oct 2018 02:59:17 -0700 Subject: [PATCH] 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. --- google/provider.go | 1 + google/resource_app_engine_application.go | 285 ++++++++++++++++++ .../resource_app_engine_application_test.go | 123 ++++++++ google/resource_google_project.go | 15 +- .../r/app_engine_application.html.markdown | 77 +++++ website/google.erb | 9 + 6 files changed, 502 insertions(+), 8 deletions(-) create mode 100644 google/resource_app_engine_application.go create mode 100644 google/resource_app_engine_application_test.go create mode 100755 website/docs/r/app_engine_application.html.markdown diff --git a/google/provider.go b/google/provider.go index 333d4c7f..6c8dfcda 100644 --- a/google/provider.go +++ b/google/provider.go @@ -107,6 +107,7 @@ func Provider() terraform.ResourceProvider { GeneratedRedisResourcesMap, GeneratedResourceManagerResourcesMap, map[string]*schema.Resource{ + "google_app_engine_application": resourceAppEngineApplication(), "google_bigquery_dataset": resourceBigQueryDataset(), "google_bigquery_table": resourceBigQueryTable(), "google_bigtable_instance": resourceBigtableInstance(), diff --git a/google/resource_app_engine_application.go b/google/resource_app_engine_application.go new file mode 100644 index 00000000..aa579185 --- /dev/null +++ b/google/resource_app_engine_application.go @@ -0,0 +1,285 @@ +package google + +import ( + "fmt" + "log" + + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/helper/validation" + appengine "google.golang.org/api/appengine/v1" +) + +func resourceAppEngineApplication() *schema.Resource { + return &schema.Resource{ + Create: resourceAppEngineApplicationCreate, + Read: resourceAppEngineApplicationRead, + Update: resourceAppEngineApplicationUpdate, + Delete: resourceAppEngineApplicationDelete, + + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "project": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateFunc: validateProjectID(), + }, + "auth_domain": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Computed: true, + }, + "location_id": &schema.Schema{ + Type: schema.TypeString, + Required: 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: appEngineApplicationFeatureSettingsResource(), + }, + "ack_delete_noop": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + "name": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + "url_dispatch_rule": &schema.Schema{ + Type: schema.TypeList, + Computed: true, + Elem: appEngineApplicationURLDispatchRuleResource(), + }, + "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 appEngineApplicationURLDispatchRuleResource() *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 appEngineApplicationFeatureSettingsResource() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + "split_health_checks": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + }, + } +} + +func resourceAppEngineApplicationCreate(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + + project, err := getProject(d, config) + if err != nil { + return err + } + app, err := expandAppEngineApplication(d, project) + if err != nil { + return err + } + app.Id = project + 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()) + } + + d.SetId(project) + + // Wait for the operation to complete + waitErr := appEngineOperationWait(config.clientAppEngine, op, project, "App Engine app to create") + if waitErr != nil { + return waitErr + } + log.Printf("[DEBUG] Created App Engine App") + + return resourceAppEngineApplicationRead(d, meta) +} + +func resourceAppEngineApplicationRead(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + pid := d.Id() + + app, err := config.clientAppEngine.Apps.Get(pid).Do() + if err != nil && !isGoogleApiErrorWithCode(err, 404) { + return fmt.Errorf("Error retrieving App Engine application %q: %s", pid, err.Error()) + } else if isGoogleApiErrorWithCode(err, 404) { + log.Printf("[WARN] App Engine application %q not found, removing from state", pid) + d.SetId("") + return nil + } + d.Set("auth_domain", app.AuthDomain) + d.Set("code_bucket", app.CodeBucket) + d.Set("default_bucket", app.DefaultBucket) + d.Set("default_hostname", app.DefaultHostname) + d.Set("location_id", app.LocationId) + d.Set("name", app.Name) + d.Set("serving_status", app.ServingStatus) + d.Set("project", pid) + dispatchRules, err := flattenAppEngineApplicationDispatchRules(app.DispatchRules) + if err != nil { + return err + } + err = d.Set("url_dispatch_rule", dispatchRules) + if err != nil { + return fmt.Errorf("Error setting dispatch rules 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()) + } + featureSettings, err := flattenAppEngineApplicationFeatureSettings(app.FeatureSettings) + if err != nil { + return err + } + err = d.Set("feature_settings", featureSettings) + if err != nil { + return fmt.Errorf("Error setting feature settings 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 resourceAppEngineApplicationUpdate(d *schema.ResourceData, meta interface{}) error { + config := meta.(*Config) + pid := d.Id() + app, err := expandAppEngineApplication(d, pid) + if err != nil { + return err + } + 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 updating 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") + + return resourceAppEngineApplicationRead(d, meta) +} + +func resourceAppEngineApplicationDelete(d *schema.ResourceData, meta interface{}) error { + // only delete app engine applications if the user has acknowledged it does nothing + if !d.Get("ack_delete_noop").(bool) { + return fmt.Errorf("App Engine applications cannot be destroyed once created. The project must be deleted to delete the application. To acknowledge this limitation and let Terraform think it deleted your application--even though it won't--set the `ack_delete_noop` field to true, and this error will go away.") + } + return nil +} + +func expandAppEngineApplication(d *schema.ResourceData, project string) (*appengine.Application, error) { + result := &appengine.Application{ + AuthDomain: d.Get("auth_domain").(string), + LocationId: d.Get("location_id").(string), + Id: project, + GcrDomain: d.Get("gcr_domain").(string), + ServingStatus: d.Get("serving_status").(string), + } + featureSettings, err := expandAppEngineApplicationFeatureSettings(d) + if err != nil { + return nil, err + } + result.FeatureSettings = featureSettings + return result, nil +} + +func expandAppEngineApplicationFeatureSettings(d *schema.ResourceData) (*appengine.FeatureSettings, error) { + blocks := d.Get("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("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 flattenAppEngineApplicationFeatureSettings(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 flattenAppEngineApplicationDispatchRules(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 +} diff --git a/google/resource_app_engine_application_test.go b/google/resource_app_engine_application_test.go new file mode 100644 index 00000000..ae807c24 --- /dev/null +++ b/google/resource_app_engine_application_test.go @@ -0,0 +1,123 @@ +package google + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform/helper/acctest" + "github.com/hashicorp/terraform/helper/resource" +) + +func TestAccAppEngineApplication_basic(t *testing.T) { + t.Parallel() + + org := getTestOrgFromEnv(t) + pid := acctest.RandomWithPrefix("tf-test") + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccAppEngineApplication_basic(pid, org), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("google_app_engine_application.acceptance", "url_dispatch_rule.#"), + resource.TestCheckResourceAttrSet("google_app_engine_application.acceptance", "name"), + resource.TestCheckResourceAttrSet("google_app_engine_application.acceptance", "code_bucket"), + resource.TestCheckResourceAttrSet("google_app_engine_application.acceptance", "default_hostname"), + resource.TestCheckResourceAttrSet("google_app_engine_application.acceptance", "default_bucket"), + ), + }, + { + ResourceName: "google_app_engine_application.acceptance", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"ack_delete_noop"}, + }, + { + Config: testAccAppEngineApplication_update(pid, org), + }, + { + ResourceName: "google_app_engine_application.acceptance", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"ack_delete_noop"}, + }, + }, + }) +} + +func TestAccAppEngineApplication_delete(t *testing.T) { + t.Parallel() + + org := getTestOrgFromEnv(t) + pid := acctest.RandomWithPrefix("tf-test") + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccAppEngineApplication_noDelete(pid, org), + }, + { + Config: testAccAppEngineApplication_noDelete(pid, org), + Destroy: true, + ExpectError: regexp.MustCompile("set the `ack_delete_noop` field to true"), + }, + { + // revert back to the same config, but with delete set, so the project can get deleted + Config: testAccAppEngineApplication_basic(pid, org), + }, + }, + }) +} + +func testAccAppEngineApplication_basic(pid, org string) string { + return fmt.Sprintf(` +resource "google_project" "acceptance" { + project_id = "%s" + name = "%s" + org_id = "%s" +} + +resource "google_app_engine_application" "acceptance" { + project = "${google_project.acceptance.project_id}" + auth_domain = "hashicorptest.com" + location_id = "us-central" + serving_status = "SERVING" + ack_delete_noop = true +}`, pid, pid, org) +} + +func testAccAppEngineApplication_update(pid, org string) string { + return fmt.Sprintf(` +resource "google_project" "acceptance" { + project_id = "%s" + name = "%s" + org_id = "%s" +} + +resource "google_app_engine_application" "acceptance" { + project = "${google_project.acceptance.project_id}" + auth_domain = "tf-test.club" + location_id = "us-central" + serving_status = "USER_DISABLED" + ack_delete_noop = true +}`, pid, pid, org) +} + +func testAccAppEngineApplication_noDelete(pid, org string) string { + return fmt.Sprintf(` +resource "google_project" "acceptance" { + project_id = "%s" + name = "%s" + org_id = "%s" +} + +resource "google_app_engine_application" "acceptance" { + project = "${google_project.acceptance.project_id}" + auth_domain = "hashicorptest.com" + location_id = "us-central" + serving_status = "SERVING" +}`, pid, pid, org) +} diff --git a/google/resource_google_project.go b/google/resource_google_project.go index 837f10d1..9dbc0308 100644 --- a/google/resource_google_project.go +++ b/google/resource_google_project.go @@ -92,10 +92,12 @@ func resourceGoogleProject() *schema.Resource { Set: schema.HashString, }, "app_engine": &schema.Schema{ - Type: schema.TypeList, - Optional: true, - Elem: appEngineResource(), - MaxItems: 1, + Type: schema.TypeList, + Optional: true, + Computed: true, + Elem: appEngineResource(), + MaxItems: 1, + Deprecated: "Use the google_app_engine_application resource instead.", }, }, } @@ -206,10 +208,7 @@ func appEngineFeatureSettingsResource() *schema.Resource { } func resourceGoogleProjectCustomizeDiff(diff *schema.ResourceDiff, meta interface{}) error { - if old, new := diff.GetChange("app_engine.#"); old != nil && new != nil && old.(int) > 0 && new.(int) < 1 { - // if we're going from app_engine set to unset, we need to delete the project, app_engine has no delete - return diff.ForceNew("app_engine") - } else if old, _ := diff.GetChange("app_engine.0.location_id"); diff.HasChange("app_engine.0.location_id") && old != nil && old.(string) != "" { + 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") diff --git a/website/docs/r/app_engine_application.html.markdown b/website/docs/r/app_engine_application.html.markdown new file mode 100755 index 00000000..bf0972e6 --- /dev/null +++ b/website/docs/r/app_engine_application.html.markdown @@ -0,0 +1,77 @@ +--- +layout: "google" +page_title: "Google: google_app_engine_application" +sidebar_current: "docs-google-app-engine-application" +description: |- + Allows management of an App Engine application. +--- + +# google\_app_engine_application + +Allows creation and management of an App Engine application. + +~> App Engine applications cannot be deleted once they're created; you have to delete the + entire project to delete the application. Terraform will force you to set the `ack_delete_noop` + field to `true` to acknowledge this limitation before you can successfully delete an App Engine + application. There's no harm in leaving the `ack_delete_noop` field set to true at all times. + +## Example Usage + +```hcl +resource "google_project" "my_project" { + name = "My Project" + project_id = "your-project-id" + org_id = "1234567" +} + +resource "google_app_engine_application" "app" { + project = "${google_project.my_project.project_id}" + location_id = "us-central' + ack_delete_noop = true +} +``` + +## Argument Reference + +The following arguments are supported: + +* `location_id` - (Required) The [location](https://cloud.google.com/appengine/docs/locations) + to serve the app from. + +* `ack_delete_noop` - (Optional) Set to true to allow Terraform to "delete" your application without error. + Has no bearing except to indicate that you're aware that when Terraform says it deletes an application, + the application has not actually been deleted. To delete an application, the entire project must be deleted. + +* `auth_domain` - (Optional) The domain to authenticate users with when using App Engine's User API. + +* `serving_status` - (Optional) The serving status of the app. Note that this can't be updated at the moment. + +* `feature_settings` - (Optional) A block of optional settings to configure specific App Engine features: + + * `split_health_checks` - (Optional) Set to false to use the legacy health check instead of the readiness + and liveness checks. + +## Attributes Reference + +In addition to the arguments listed above, the following computed attributes are +exported: + +* `name` - Unique name of the app, usually `apps/{PROJECT_ID}` + +* `url_dispatch_rule` - A list of dispatch rule blocks. Each block has a `domain`, `path`, and `service` field. + +* `code_bucket` - The GCS bucket code is being stored in for this app. + +* `default_hostname` - The default hostname for this app. + +* `default_bucket` - The GCS bucket content is being stored in for this app. + +* `gcr_domain` - The GCR domain used for storing managed Docker images for this app. + +## Import + +Applications can be imported using the ID of the project the application belongs to, e.g. + +``` +$ terraform import google_app_engine_application.app your-project-id +``` diff --git a/website/google.erb b/website/google.erb index d5c89174..f2ec9714 100644 --- a/website/google.erb +++ b/website/google.erb @@ -132,6 +132,15 @@ + > + Google App Engine Resources + + + > Google BigQuery Resources