diff --git a/google/appengine_operation.go b/google/appengine_operation.go new file mode 100644 index 00000000..08614782 --- /dev/null +++ b/google/appengine_operation.go @@ -0,0 +1,74 @@ +package google + +import ( + "fmt" + "log" + "strconv" + "time" + + "github.com/hashicorp/terraform/helper/resource" + + "google.golang.org/api/appengine/v1" +) + +type AppEngineOperationWaiter struct { + Service *appengine.APIService + Op *appengine.Operation + AppId string +} + +func (w *AppEngineOperationWaiter) RefreshFunc() resource.StateRefreshFunc { + return func() (interface{}, string, error) { + op, err := w.Service.Apps.Operations.Get(w.AppId, w.Op.Name).Do() + if err != nil { + return nil, "", err + } + + log.Printf("[DEBUG] Got %v when asking for operation %q", op.Done, w.Op.Name) + return op, strconv.FormatBool(op.Done), nil + } +} + +func (w *AppEngineOperationWaiter) Conf() *resource.StateChangeConf { + return &resource.StateChangeConf{ + Pending: []string{"false"}, + Target: []string{"true"}, + Refresh: w.RefreshFunc(), + } +} + +// AppEngineOperationError wraps appengine.Status and implements the +// error interface so it can be returned. +type AppEngineOperationError appengine.Status + +func (e AppEngineOperationError) Error() string { + return e.Message +} + +func appEngineOperationWait(client *appengine.APIService, op *appengine.Operation, appId, activity string) error { + return appEngineOperationWaitTime(client, op, appId, activity, 4) +} + +func appEngineOperationWaitTime(client *appengine.APIService, op *appengine.Operation, appId, activity string, timeoutMin int) error { + w := &AppEngineOperationWaiter{ + Service: client, + Op: op, + AppId: appId, + } + + state := w.Conf() + state.Delay = 10 * time.Second + state.Timeout = time.Duration(timeoutMin) * time.Minute + state.MinTimeout = 2 * time.Second + opRaw, err := state.WaitForState() + if err != nil { + return fmt.Errorf("Error waiting for %s: %s", activity, err) + } + + resultOp := opRaw.(*appengine.Operation) + if resultOp.Error != nil { + return AppEngineOperationError(*resultOp.Error) + } + + return nil +} diff --git a/google/config.go b/google/config.go index 6f8113c1..fd6d318d 100644 --- a/google/config.go +++ b/google/config.go @@ -15,6 +15,7 @@ import ( "golang.org/x/oauth2" "golang.org/x/oauth2/google" "golang.org/x/oauth2/jwt" + appengine "google.golang.org/api/appengine/v1" "google.golang.org/api/bigquery/v2" "google.golang.org/api/cloudbilling/v1" "google.golang.org/api/cloudfunctions/v1" @@ -76,6 +77,7 @@ type Config struct { clientBigQuery *bigquery.Service clientCloudFunctions *cloudfunctions.Service clientCloudIoT *cloudiot.Service + clientAppEngine *appengine.APIService bigtableClientFactory *BigtableClientFactory } @@ -315,6 +317,13 @@ func (c *Config) loadAndValidate() error { } c.clientCloudIoT.UserAgent = userAgent + log.Printf("[INFO] Instantiating App Engine Client...") + c.clientAppEngine, err = appengine.New(client) + if err != nil { + return err + } + c.clientAppEngine.UserAgent = userAgent + return nil } diff --git a/google/resource_google_project.go b/google/resource_google_project.go index acdd28f3..c4fc3179 100644 --- a/google/resource_google_project.go +++ b/google/resource_google_project.go @@ -1,6 +1,7 @@ package google import ( + "crypto/sha256" "fmt" "log" "net/http" @@ -9,6 +10,8 @@ import ( "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" @@ -28,7 +31,8 @@ func resourceGoogleProject() *schema.Resource { Importer: &schema.ResourceImporter{ State: resourceProjectImportState, }, - MigrateState: resourceGoogleProjectMigrateState, + MigrateState: resourceGoogleProjectMigrateState, + CustomizeDiff: resourceGoogleProjectCustomizeDiff, Schema: map[string]*schema.Schema{ "project_id": &schema.Schema{ @@ -86,10 +90,161 @@ func resourceGoogleProject() *schema.Resource { Elem: &schema.Schema{Type: schema.TypeString}, Set: schema.HashString, }, + "app_engine": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + Elem: appEngineResource(), + MaxItems: 1, + }, }, } } +func appEngineResource() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + "url_dispatch_rule": &schema.Schema{ + Type: schema.TypeList, + Computed: true, + Elem: appEngineURLDispatchRuleResource(), + }, + "auth_domain": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "location_id": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice([]string{ + "northamerica-northeast1", + "us-central", + "us-east1", + "us-east4", + "southamerica-east1", + "europe-west", + "europe-west2", + "europe-west3", + "asia-northeast1", + "asia-south1", + "australia-southeast1", + }, false), + }, + "code_bucket": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + "default_cookie_expiration_seconds": &schema.Schema{ + Type: schema.TypeFloat, + Optional: true, + }, + "serving_status": &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, + }, + "iap": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: appEngineIAPResource(), + }, + "gcr_domain": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "feature_settings": &schema.Schema{ + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: appEngineFeatureSettingsResource(), + }, + }, + } +} + +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 appEngineIAPResource() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": &schema.Schema{ + Type: schema.TypeBool, + Required: true, + }, + "oauth2_client_id": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "oauth2_client_secret": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Computed: true, + Sensitive: true, + }, + "oauth2_client_secret_sha256": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + Sensitive: 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 { + // don't need to check if changed, the call is a no-op/error if there's no change + diff.ForceNew("app_engine.#") + + // force a change to client secret if it doesn't match its sha + if !diff.HasChange("app_engine.0.iap.0.oauth2_client_secret") { + sha := sha256.Sum256([]byte(diff.Get("app_engine.0.iap.0.oauth2_client_secret").(string))) + if string(sha[:]) != diff.Get("app_engine.0.iap.0.oauth2_client_secret_sha256").(string) { + diff.SetNew("app_engine.0.iap.0.oauth2_client_secret", diff.Get("app_engine.0.iap.0.oauth2_client_secret")) + } + } + return nil +} + func resourceGoogleProjectCreate(d *schema.ResourceData, meta interface{}) error { config := meta.(*Config) @@ -153,6 +308,30 @@ func resourceGoogleProjectCreate(d *schema.ResourceData, meta interface{}) error return fmt.Errorf("Error deleting default network in project %s: %s", project.ProjectId, err) } } + + // set up App Engine, too + if len(d.Get("app_engine").([]interface{})) > 0 { + // 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) + } + app, err := expandAppEngineApp(d) + if err != nil { + return err + } + if app != nil { + 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, app.Id, "App Engine app to create") + if waitErr != nil { + return waitErr + } + } + } return nil } @@ -207,6 +386,22 @@ func resourceGoogleProjectRead(d *schema.ResourceData, meta interface{}) error { } d.Set("billing_account", _ba) } + + // Read the App Engine app + if d.Get("app_engine.#").(int) > 0 { + app, err := config.clientAppEngine.Apps.Get(pid).Do() + if err != nil { + return fmt.Errorf("Error retrieving App Engine application %q: %s", pid, err.Error()) + } + 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") + } + } return nil } @@ -308,6 +503,25 @@ func resourceGoogleProjectUpdate(d *schema.ResourceData, meta interface{}) error if err != nil { return fmt.Errorf("Error updating project %q: %s", project_name, err) } + d.SetPartial("labels") + } + + if d.HasChange("app_engine") { + // don't need to worry about case where block is removed; custom diff does that + // for us + app, err := expandAppEngineApp(d) + if err != nil { + return err + } + op, err := config.clientAppEngine.Apps.Patch(p.ProjectId, app).Do() + if err != nil { + return fmt.Errorf("Error updating App Engine application %q: %s", p.ProjectId, err.Error()) + } + waitErr := appEngineOperationWait(config.clientAppEngine, op, p.ProjectId, "App Engine app to update") + if waitErr != nil { + return waitErr + } + d.SetPartial("app_engine") } d.Partial(false) @@ -399,3 +613,142 @@ func updateProjectBillingAccount(d *schema.ResourceData, config *Config) error { } return nil } + +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("gcr_domain").(string), + } + if v, ok := d.GetOkExists("app_engine.0.default_cookie_expiration_seconds"); ok { + result.DefaultCookieExpiration = strconv.FormatFloat(v.(float64), 'f', 9, 64) + "s" + } + iap, err := expandAppEngineIAP(d, "app_engine.0.") + if err != nil { + return nil, err + } + result.Iap = iap + 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, + } + if app.DefaultCookieExpiration != "" { + seconds := strings.TrimSuffix(app.DefaultCookieExpiration, "s") + exp, err := strconv.ParseFloat(seconds, 64) + if err != nil { + return nil, fmt.Errorf("invalid default cookie expiration: %s", err.Error()) + } + result["default_cookie_expiration_seconds"] = exp + } + dispatchRules, err := flattenAppEngineDispatchRules(app.DispatchRules) + if err != nil { + return nil, err + } + result["dispatch_rule"] = dispatchRules + featureSettings, err := flattenAppEngineFeatureSettings(app.FeatureSettings) + if err != nil { + return nil, err + } + result["feature_settings"] = featureSettings + iap, err := flattenAppEngineIAP(app.Iap) + if err != nil { + return nil, err + } + result["iap"] = iap + return []map[string]interface{}{result}, nil +} + +func expandAppEngineIAP(d *schema.ResourceData, prefix string) (*appengine.IdentityAwareProxy, error) { + blocks := d.Get(prefix + "iap").([]interface{}) + if len(blocks) < 1 { + return nil, nil + } + if len(blocks) > 1 { + return nil, fmt.Errorf("only one iap block may be defined per app") + } + if d.Get(prefix+"iap.0.oauth2_client_id").(string) == "" && d.Get(prefix+"iap.0.enabled").(bool) { + return nil, fmt.Errorf("oauth2_client_id must be set if the IAP is enabled") + } + if d.Get(prefix+"iap.0.oauth2_client_secret") == "" && d.Get(prefix+"iap.0.enabled").(bool) { + return nil, fmt.Errorf("oauth2_client_secret must be set if the IAP is enabled") + } + return &appengine.IdentityAwareProxy{ + Enabled: d.Get(prefix + "iap.0.enabled").(bool), + Oauth2ClientId: d.Get(prefix + "iap.0.oauth2_client_id").(string), + Oauth2ClientSecret: d.Get(prefix + "iap.0.oauth2_client_secret").(string), + // force send enabled, so if it's set to false, IAP still gets turned off + ForceSendFields: []string{"Enabled"}, + }, nil +} + +func flattenAppEngineIAP(iap *appengine.IdentityAwareProxy) ([]map[string]interface{}, error) { + if iap == nil { + return []map[string]interface{}{}, nil + } + result := map[string]interface{}{ + "enabled": iap.Enabled, + "oauth2_client_id": iap.Oauth2ClientId, + "oauth2_client_secret_sha256": iap.Oauth2ClientSecretSha256, + // don't set client secret, it's not returned by the API + } + 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 +}