| // Copyright (c) HashiCorp, Inc. |
| // SPDX-License-Identifier: MPL-2.0 |
| package servicemanagement |
| |
| import ( |
| "context" |
| "encoding/base64" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "log" |
| "regexp" |
| "strconv" |
| "strings" |
| "time" |
| |
| "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" |
| "github.com/hashicorp/terraform-provider-google-beta/google-beta/tpgresource" |
| transport_tpg "github.com/hashicorp/terraform-provider-google-beta/google-beta/transport" |
| "google.golang.org/api/servicemanagement/v1" |
| ) |
| |
| func ResourceEndpointsService() *schema.Resource { |
| return &schema.Resource{ |
| Create: resourceEndpointsServiceCreate, |
| Read: resourceEndpointsServiceRead, |
| Delete: resourceEndpointsServiceDelete, |
| Update: resourceEndpointsServiceUpdate, |
| |
| // Migrates protoc_output -> protoc_output_base64. |
| SchemaVersion: 1, |
| MigrateState: migrateEndpointsService, |
| |
| Timeouts: &schema.ResourceTimeout{ |
| Create: schema.DefaultTimeout(10 * time.Minute), |
| Update: schema.DefaultTimeout(10 * time.Minute), |
| Delete: schema.DefaultTimeout(10 * time.Minute), |
| }, |
| |
| Schema: map[string]*schema.Schema{ |
| "service_name": { |
| Type: schema.TypeString, |
| Required: true, |
| ForceNew: true, |
| Description: `The name of the service. Usually of the form $apiname.endpoints.$projectid.cloud.goog.`, |
| }, |
| "openapi_config": { |
| Type: schema.TypeString, |
| Optional: true, |
| ConflictsWith: []string{"grpc_config", "protoc_output_base64"}, |
| Description: `The full text of the OpenAPI YAML configuration as described here. Either this, or both of grpc_config and protoc_output_base64 must be specified.`, |
| }, |
| "grpc_config": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: `The full text of the Service Config YAML file (Example located here). If provided, must also provide protoc_output_base64. open_api config must not be provided.`, |
| }, |
| "protoc_output_base64": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: `The full contents of the Service Descriptor File generated by protoc. This should be a compiled .pb file, base64-encoded.`, |
| }, |
| "project": { |
| Type: schema.TypeString, |
| Optional: true, |
| Computed: true, |
| ForceNew: true, |
| Description: `The project ID that the service belongs to. If not provided, provider project is used.`, |
| }, |
| "config_id": { |
| Type: schema.TypeString, |
| Computed: true, |
| Description: `The autogenerated ID for the configuration that is rolled out as part of the creation of this resource. Must be provided to compute engine instances as a tag.`, |
| }, |
| "apis": { |
| Type: schema.TypeList, |
| Computed: true, |
| Description: `A list of API objects.`, |
| Elem: &schema.Resource{ |
| Schema: map[string]*schema.Schema{ |
| "name": { |
| Type: schema.TypeString, |
| Computed: true, |
| Description: `The FQDN of the API as described in the provided config.`, |
| }, |
| "syntax": { |
| Type: schema.TypeString, |
| Computed: true, |
| Description: `SYNTAX_PROTO2 or SYNTAX_PROTO3.`, |
| }, |
| "version": { |
| Type: schema.TypeString, |
| Computed: true, |
| Description: `A version string for this api. If specified, will have the form major-version.minor-version, e.g. 1.10.`, |
| }, |
| "methods": { |
| Type: schema.TypeList, |
| Computed: true, |
| Description: `A list of Method objects.`, |
| Elem: &schema.Resource{ |
| Schema: map[string]*schema.Schema{ |
| "name": { |
| Type: schema.TypeString, |
| Computed: true, |
| Description: `The simple name of this method as described in the provided config.`, |
| }, |
| "syntax": { |
| Type: schema.TypeString, |
| Computed: true, |
| Description: `SYNTAX_PROTO2 or SYNTAX_PROTO3.`, |
| }, |
| "request_type": { |
| Type: schema.TypeString, |
| Computed: true, |
| Description: `The type URL for the request to this API.`, |
| }, |
| "response_type": { |
| Type: schema.TypeString, |
| Computed: true, |
| Description: `The type URL for the response from this API.`, |
| }, |
| }, |
| }, |
| }, |
| }, |
| }, |
| }, |
| "dns_address": { |
| Type: schema.TypeString, |
| Computed: true, |
| Description: `The address at which the service can be found - usually the same as the service name.`, |
| }, |
| "endpoints": { |
| Type: schema.TypeList, |
| Computed: true, |
| Description: `A list of Endpoint objects.`, |
| Elem: &schema.Resource{ |
| Schema: map[string]*schema.Schema{ |
| "name": { |
| Type: schema.TypeString, |
| Computed: true, |
| Description: `The simple name of the endpoint as described in the config.`, |
| }, |
| "address": { |
| Type: schema.TypeString, |
| Computed: true, |
| Description: `The FQDN of the endpoint as described in the config.`, |
| }, |
| }, |
| }, |
| }, |
| }, |
| CustomizeDiff: predictServiceId, |
| UseJSONNumber: true, |
| } |
| } |
| |
| func predictServiceId(_ context.Context, d *schema.ResourceDiff, meta interface{}) error { |
| if !d.HasChange("openapi_config") && !d.HasChange("grpc_config") && !d.HasChange("protoc_output_base64") { |
| return nil |
| } |
| if !d.NewValueKnown("openapi_config") || !d.NewValueKnown("grpc_config") || !d.NewValueKnown("protoc_output_base64") { |
| d.SetNewComputed("config_id") |
| return nil |
| } |
| baseDate := time.Now().Format("2006-01-02") |
| oldConfigId := d.Get("config_id").(string) |
| if match, err := regexp.MatchString(`\d\d\d\d-\d\d-\d\dr\d*`, oldConfigId); !match || err != nil { |
| // If we do not match the expected format, we will guess |
| // wrong and that is worse than not guessing. |
| return nil |
| } |
| if strings.HasPrefix(oldConfigId, baseDate) { |
| n, err := strconv.Atoi(strings.Split(oldConfigId, "r")[1]) |
| if err != nil { |
| return err |
| } |
| if err := d.SetNew("config_id", fmt.Sprintf("%sr%d", baseDate, n+1)); err != nil { |
| return err |
| } |
| } else { |
| if err := d.SetNew("config_id", baseDate+"r0"); err != nil { |
| return err |
| } |
| } |
| return nil |
| } |
| |
| func getEndpointServiceOpenAPIConfigSource(configText string) *servicemanagement.ConfigSource { |
| // We need to provide a ConfigSource object to the API whenever submitting a |
| // new config. A ConfigSource contains a ConfigFile which contains the b64 |
| // encoded contents of the file. OpenAPI requires only one file. |
| configfile := servicemanagement.ConfigFile{ |
| FileContents: base64.StdEncoding.EncodeToString([]byte(configText)), |
| FileType: "OPEN_API_YAML", |
| FilePath: "heredoc.yaml", |
| } |
| return &servicemanagement.ConfigSource{ |
| Files: []*servicemanagement.ConfigFile{&configfile}, |
| } |
| } |
| |
| func getEndpointServiceGRPCConfigSource(serviceConfig, protoConfig string) *servicemanagement.ConfigSource { |
| // gRPC requires both the file specifying the service and the compiled protobuf, |
| // but they can be in any order. |
| ymlConfigfile := servicemanagement.ConfigFile{ |
| FileContents: base64.StdEncoding.EncodeToString([]byte(serviceConfig)), |
| FileType: "SERVICE_CONFIG_YAML", |
| FilePath: "heredoc.yaml", |
| } |
| protoConfigfile := servicemanagement.ConfigFile{ |
| FileContents: protoConfig, |
| FileType: "FILE_DESCRIPTOR_SET_PROTO", |
| FilePath: "api_def.pb", |
| } |
| return &servicemanagement.ConfigSource{ |
| Files: []*servicemanagement.ConfigFile{&ymlConfigfile, &protoConfigfile}, |
| } |
| } |
| |
| func resourceEndpointsServiceCreate(d *schema.ResourceData, meta interface{}) error { |
| config := meta.(*transport_tpg.Config) |
| userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) |
| if err != nil { |
| return err |
| } |
| |
| project, err := tpgresource.GetProject(d, config) |
| if err != nil { |
| return err |
| } |
| |
| // If the service doesn't exist, we'll need to create it, but if it does, it |
| // will be reused. This is unusual for Terraform, but it causes the behavior |
| // that users will want and accept. Users of Endpoints are not thinking in |
| // terms of services, configs, and rollouts - they just want the setup declared |
| // in their config to happen. The fact that a service may need to be created |
| // is not interesting to them. Consequently, we create this service if necessary |
| // so that we can perform the rollout without further disruption, which is the |
| // action that a user running `terraform apply` is going to want. |
| serviceName := d.Get("service_name").(string) |
| log.Printf("[DEBUG] Create Endpoint Service %q", serviceName) |
| |
| log.Printf("[DEBUG] Checking for existing ManagedService %q", serviceName) |
| _, err = config.NewServiceManClient(userAgent).Services.Get(serviceName).Do() |
| if err != nil { |
| log.Printf("[DEBUG] Creating new ServiceManagement ManagedService %q", serviceName) |
| op, err := config.NewServiceManClient(userAgent).Services.Create( |
| &servicemanagement.ManagedService{ |
| ProducerProjectId: project, |
| ServiceName: serviceName, |
| }).Do() |
| if err != nil { |
| return err |
| } |
| |
| _, err = ServiceManagementOperationWaitTime(config, op, "Creating new ManagedService.", userAgent, d.Timeout(schema.TimeoutCreate)) |
| if err != nil { |
| return err |
| } |
| } |
| |
| // Use update to set other fields like config. |
| err = resourceEndpointsServiceUpdate(d, meta) |
| if err != nil { |
| return err |
| } |
| |
| d.SetId(serviceName) |
| return resourceEndpointsServiceRead(d, meta) |
| } |
| |
| func expandEndpointServiceConfigSource(d *schema.ResourceData, meta interface{}) (*servicemanagement.ConfigSource, error) { |
| if openapiConfig, ok := d.GetOk("openapi_config"); ok { |
| return getEndpointServiceOpenAPIConfigSource(openapiConfig.(string)), nil |
| } |
| |
| grpcConfig, gok := d.GetOk("grpc_config") |
| protocOutput, pok := d.GetOk("protoc_output_base64") |
| if gok && pok { |
| return getEndpointServiceGRPCConfigSource(grpcConfig.(string), protocOutput.(string)), nil |
| } |
| |
| return nil, errors.New("Could not parse config - either openapi_config or both grpc_config and protoc_output_base64 must be set.") |
| } |
| |
| func resourceEndpointsServiceUpdate(d *schema.ResourceData, meta interface{}) error { |
| // This update is not quite standard for a terraform resource. Instead of |
| // using the go client library to send an HTTP request to update something |
| // serverside, we have to push a new configuration, wait for it to be |
| // parsed and loaded, then create and push a rollout and wait for that |
| // rollout to be completed. |
| // There's a lot of moving parts there, and all of them have knobs that can |
| // be tweaked if the user is using gcloud. In the interest of simplicity, |
| // we currently only support full rollouts - anyone trying to do incremental |
| // rollouts or A/B testing is going to need a more precise tool than this resource. |
| config := meta.(*transport_tpg.Config) |
| userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) |
| if err != nil { |
| return err |
| } |
| |
| serviceName := d.Get("service_name").(string) |
| |
| log.Printf("[DEBUG] Updating ManagedService %q", serviceName) |
| |
| cfgSource, err := expandEndpointServiceConfigSource(d, meta) |
| if err != nil { |
| return err |
| } |
| |
| log.Printf("[DEBUG] Updating ManagedService %q", serviceName) |
| // The difference between "submit" and "create" is that submit parses the config |
| // you provide, where "create" requires the config in a pre-parsed format. |
| // "submit" will be a lot more flexible for users and will always be up-to-date |
| // with any new features that arise - this is why you provide a YAML config |
| // instead of providing the config in HCL. |
| log.Printf("[DEBUG] Submitting config for ManagedService %q", serviceName) |
| op, err := config.NewServiceManClient(userAgent).Services.Configs.Submit( |
| serviceName, |
| &servicemanagement.SubmitConfigSourceRequest{ |
| ConfigSource: cfgSource, |
| }).Do() |
| if err != nil { |
| return err |
| } |
| s, err := ServiceManagementOperationWaitTime(config, op, "Submitting service config.", userAgent, d.Timeout(schema.TimeoutUpdate)) |
| if err != nil { |
| return err |
| } |
| var serviceConfig servicemanagement.SubmitConfigSourceResponse |
| if err := json.Unmarshal(s, &serviceConfig); err != nil { |
| return err |
| } |
| |
| // Next, we create a new rollout with the new config value, and wait for it to complete. |
| rollout := servicemanagement.Rollout{ |
| ServiceName: serviceName, |
| TrafficPercentStrategy: &servicemanagement.TrafficPercentStrategy{ |
| Percentages: map[string]float64{serviceConfig.ServiceConfig.Id: 100.0}, |
| }, |
| } |
| |
| log.Printf("[DEBUG] Creating new rollout for ManagedService %q", serviceName) |
| op, err = config.NewServiceManClient(userAgent).Services.Rollouts.Create(serviceName, &rollout).Do() |
| if err != nil { |
| return err |
| } |
| _, err = ServiceManagementOperationWaitTime(config, op, "Performing service rollout.", userAgent, d.Timeout(schema.TimeoutUpdate)) |
| if err != nil { |
| return err |
| } |
| |
| return resourceEndpointsServiceRead(d, meta) |
| } |
| |
| func resourceEndpointsServiceDelete(d *schema.ResourceData, meta interface{}) error { |
| config := meta.(*transport_tpg.Config) |
| userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) |
| if err != nil { |
| return err |
| } |
| |
| log.Printf("[DEBUG] Deleting ManagedService %q", d.Id()) |
| |
| op, err := config.NewServiceManClient(userAgent).Services.Delete(d.Get("service_name").(string)).Do() |
| if err != nil { |
| return err |
| } |
| _, err = ServiceManagementOperationWaitTime(config, op, "Deleting service.", userAgent, d.Timeout(schema.TimeoutDelete)) |
| d.SetId("") |
| return err |
| } |
| |
| func resourceEndpointsServiceRead(d *schema.ResourceData, meta interface{}) error { |
| config := meta.(*transport_tpg.Config) |
| userAgent, err := tpgresource.GenerateUserAgentString(d, config.UserAgent) |
| if err != nil { |
| return err |
| } |
| |
| log.Printf("[DEBUG] Reading ManagedService %q", d.Id()) |
| |
| service, err := config.NewServiceManClient(userAgent).Services.GetConfig(d.Get("service_name").(string)).Do() |
| if err != nil { |
| return err |
| } |
| |
| if err := d.Set("config_id", service.Id); err != nil { |
| return fmt.Errorf("Error setting config_id: %s", err) |
| } |
| if err := d.Set("dns_address", service.Name); err != nil { |
| return fmt.Errorf("Error setting dns_address: %s", err) |
| } |
| if err := d.Set("apis", flattenServiceManagementAPIs(service.Apis)); err != nil { |
| return fmt.Errorf("Error setting apis: %s", err) |
| } |
| if err := d.Set("endpoints", flattenServiceManagementEndpoints(service.Endpoints)); err != nil { |
| return fmt.Errorf("Error setting endpoints: %s", err) |
| } |
| |
| return nil |
| } |
| |
| func flattenServiceManagementAPIs(apis []*servicemanagement.Api) []map[string]interface{} { |
| flattened := make([]map[string]interface{}, len(apis)) |
| for i, a := range apis { |
| flattened[i] = map[string]interface{}{ |
| "name": a.Name, |
| "version": a.Version, |
| "syntax": a.Syntax, |
| "methods": flattenServiceManagementMethods(a.Methods), |
| } |
| } |
| return flattened |
| } |
| |
| func flattenServiceManagementMethods(methods []*servicemanagement.Method) []map[string]interface{} { |
| flattened := make([]map[string]interface{}, len(methods)) |
| for i, m := range methods { |
| flattened[i] = map[string]interface{}{ |
| "name": m.Name, |
| "syntax": m.Syntax, |
| "request_type": m.RequestTypeUrl, |
| "response_type": m.ResponseTypeUrl, |
| } |
| } |
| return flattened |
| } |
| |
| func flattenServiceManagementEndpoints(endpoints []*servicemanagement.Endpoint) []map[string]interface{} { |
| flattened := make([]map[string]interface{}, len(endpoints)) |
| for i, e := range endpoints { |
| flattened[i] = map[string]interface{}{ |
| "name": e.Name, |
| "address": e.Target, |
| } |
| } |
| return flattened |
| } |