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