blob: e4bdc8ee4c0a8cab0c1abcdee99aff222e991cb1 [file] [log] [blame]
// 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
}