github.com/opentofu/opentofu@v1.7.1/internal/backend/remote-state/cos/backend.go (about)

     1  // Copyright (c) The OpenTofu Authors
     2  // SPDX-License-Identifier: MPL-2.0
     3  // Copyright (c) 2023 HashiCorp, Inc.
     4  // SPDX-License-Identifier: MPL-2.0
     5  
     6  package cos
     7  
     8  import (
     9  	"context"
    10  	"fmt"
    11  	"net/http"
    12  	"net/url"
    13  	"os"
    14  	"strconv"
    15  	"strings"
    16  	"time"
    17  
    18  	"github.com/opentofu/opentofu/internal/backend"
    19  	"github.com/opentofu/opentofu/internal/encryption"
    20  	"github.com/opentofu/opentofu/internal/legacy/helper/schema"
    21  	"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
    22  	"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
    23  	sts "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sts/v20180813"
    24  	tag "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tag/v20180813"
    25  	"github.com/tencentyun/cos-go-sdk-v5"
    26  )
    27  
    28  // Default value from environment variable
    29  const (
    30  	PROVIDER_SECRET_ID                    = "TENCENTCLOUD_SECRET_ID"
    31  	PROVIDER_SECRET_KEY                   = "TENCENTCLOUD_SECRET_KEY"
    32  	PROVIDER_SECURITY_TOKEN               = "TENCENTCLOUD_SECURITY_TOKEN"
    33  	PROVIDER_REGION                       = "TENCENTCLOUD_REGION"
    34  	PROVIDER_ASSUME_ROLE_ARN              = "TENCENTCLOUD_ASSUME_ROLE_ARN"
    35  	PROVIDER_ASSUME_ROLE_SESSION_NAME     = "TENCENTCLOUD_ASSUME_ROLE_SESSION_NAME"
    36  	PROVIDER_ASSUME_ROLE_SESSION_DURATION = "TENCENTCLOUD_ASSUME_ROLE_SESSION_DURATION"
    37  )
    38  
    39  // Backend implements "backend".Backend for tencentCloud cos
    40  type Backend struct {
    41  	*schema.Backend
    42  	encryption encryption.StateEncryption
    43  	credential *common.Credential
    44  
    45  	cosContext context.Context
    46  	cosClient  *cos.Client
    47  	tagClient  *tag.Client
    48  	stsClient  *sts.Client
    49  
    50  	region  string
    51  	bucket  string
    52  	prefix  string
    53  	key     string
    54  	encrypt bool
    55  	acl     string
    56  }
    57  
    58  // New creates a new backend for TencentCloud cos remote state.
    59  func New(enc encryption.StateEncryption) backend.Backend {
    60  	s := &schema.Backend{
    61  		Schema: map[string]*schema.Schema{
    62  			"secret_id": {
    63  				Type:        schema.TypeString,
    64  				Optional:    true,
    65  				DefaultFunc: schema.EnvDefaultFunc(PROVIDER_SECRET_ID, nil),
    66  				Description: "Secret id of Tencent Cloud",
    67  			},
    68  			"secret_key": {
    69  				Type:        schema.TypeString,
    70  				Optional:    true,
    71  				DefaultFunc: schema.EnvDefaultFunc(PROVIDER_SECRET_KEY, nil),
    72  				Description: "Secret key of Tencent Cloud",
    73  				Sensitive:   true,
    74  			},
    75  			"security_token": {
    76  				Type:        schema.TypeString,
    77  				Optional:    true,
    78  				DefaultFunc: schema.EnvDefaultFunc(PROVIDER_SECURITY_TOKEN, nil),
    79  				Description: "TencentCloud Security Token of temporary access credentials. It can be sourced from the `TENCENTCLOUD_SECURITY_TOKEN` environment variable. Notice: for supported products, please refer to: [temporary key supported products](https://intl.cloud.tencent.com/document/product/598/10588).",
    80  				Sensitive:   true,
    81  			},
    82  			"region": {
    83  				Type:         schema.TypeString,
    84  				Required:     true,
    85  				DefaultFunc:  schema.EnvDefaultFunc(PROVIDER_REGION, nil),
    86  				Description:  "The region of the COS bucket",
    87  				InputDefault: "ap-guangzhou",
    88  			},
    89  			"bucket": {
    90  				Type:        schema.TypeString,
    91  				Required:    true,
    92  				Description: "The name of the COS bucket",
    93  			},
    94  			"prefix": {
    95  				Type:        schema.TypeString,
    96  				Optional:    true,
    97  				Description: "The directory for saving the state file in bucket",
    98  				ValidateFunc: func(v interface{}, s string) ([]string, []error) {
    99  					prefix := v.(string)
   100  					if strings.HasPrefix(prefix, "/") || strings.HasPrefix(prefix, "./") {
   101  						return nil, []error{fmt.Errorf("prefix must not start with '/' or './'")}
   102  					}
   103  					return nil, nil
   104  				},
   105  			},
   106  			"key": {
   107  				Type:        schema.TypeString,
   108  				Optional:    true,
   109  				Description: "The path for saving the state file in bucket",
   110  				Default:     "terraform.tfstate",
   111  				ValidateFunc: func(v interface{}, s string) ([]string, []error) {
   112  					if strings.HasPrefix(v.(string), "/") || strings.HasSuffix(v.(string), "/") {
   113  						return nil, []error{fmt.Errorf("key can not start and end with '/'")}
   114  					}
   115  					return nil, nil
   116  				},
   117  			},
   118  			"encrypt": {
   119  				Type:        schema.TypeBool,
   120  				Optional:    true,
   121  				Description: "Whether to enable server side encryption of the state file",
   122  				Default:     true,
   123  			},
   124  			"acl": {
   125  				Type:        schema.TypeString,
   126  				Optional:    true,
   127  				Description: "Object ACL to be applied to the state file",
   128  				Default:     "private",
   129  				ValidateFunc: func(v interface{}, s string) ([]string, []error) {
   130  					value := v.(string)
   131  					if value != "private" && value != "public-read" {
   132  						return nil, []error{fmt.Errorf(
   133  							"acl value invalid, expected %s or %s, got %s",
   134  							"private", "public-read", value)}
   135  					}
   136  					return nil, nil
   137  				},
   138  			},
   139  			"accelerate": {
   140  				Type:        schema.TypeBool,
   141  				Optional:    true,
   142  				Description: "Whether to enable global Acceleration",
   143  				Default:     false,
   144  			},
   145  			"assume_role": {
   146  				Type:        schema.TypeSet,
   147  				Optional:    true,
   148  				MaxItems:    1,
   149  				Description: "The `assume_role` block. If provided, tofu will attempt to assume this role using the supplied credentials.",
   150  				Elem: &schema.Resource{
   151  					Schema: map[string]*schema.Schema{
   152  						"role_arn": {
   153  							Type:        schema.TypeString,
   154  							Required:    true,
   155  							DefaultFunc: schema.EnvDefaultFunc(PROVIDER_ASSUME_ROLE_ARN, nil),
   156  							Description: "The ARN of the role to assume. It can be sourced from the `TENCENTCLOUD_ASSUME_ROLE_ARN`.",
   157  						},
   158  						"session_name": {
   159  							Type:        schema.TypeString,
   160  							Required:    true,
   161  							DefaultFunc: schema.EnvDefaultFunc(PROVIDER_ASSUME_ROLE_SESSION_NAME, nil),
   162  							Description: "The session name to use when making the AssumeRole call. It can be sourced from the `TENCENTCLOUD_ASSUME_ROLE_SESSION_NAME`.",
   163  						},
   164  						"session_duration": {
   165  							Type:     schema.TypeInt,
   166  							Required: true,
   167  							DefaultFunc: func() (interface{}, error) {
   168  								if v := os.Getenv(PROVIDER_ASSUME_ROLE_SESSION_DURATION); v != "" {
   169  									return strconv.Atoi(v)
   170  								}
   171  								return 7200, nil
   172  							},
   173  							ValidateFunc: validateIntegerInRange(0, 43200),
   174  							Description:  "The duration of the session when making the AssumeRole call. Its value ranges from 0 to 43200(seconds), and default is 7200 seconds. It can be sourced from the `TENCENTCLOUD_ASSUME_ROLE_SESSION_DURATION`.",
   175  						},
   176  						"policy": {
   177  							Type:        schema.TypeString,
   178  							Optional:    true,
   179  							Description: "A more restrictive policy when making the AssumeRole call. Its content must not contains `principal` elements. Notice: more syntax references, please refer to: [policies syntax logic](https://intl.cloud.tencent.com/document/product/598/10603).",
   180  						},
   181  					},
   182  				},
   183  			},
   184  		},
   185  	}
   186  
   187  	result := &Backend{Backend: s, encryption: enc}
   188  	result.Backend.ConfigureFunc = result.configure
   189  
   190  	return result
   191  }
   192  
   193  func validateIntegerInRange(min, max int64) schema.SchemaValidateFunc {
   194  	return func(v interface{}, k string) (ws []string, errors []error) {
   195  		value := int64(v.(int))
   196  		if value < min {
   197  			errors = append(errors, fmt.Errorf(
   198  				"%q cannot be lower than %d: %d", k, min, value))
   199  		}
   200  		if value > max {
   201  			errors = append(errors, fmt.Errorf(
   202  				"%q cannot be higher than %d: %d", k, max, value))
   203  		}
   204  		return
   205  	}
   206  }
   207  
   208  // configure init cos client
   209  func (b *Backend) configure(ctx context.Context) error {
   210  	if b.cosClient != nil {
   211  		return nil
   212  	}
   213  
   214  	b.cosContext = ctx
   215  	data := schema.FromContextBackendConfig(b.cosContext)
   216  
   217  	b.region = data.Get("region").(string)
   218  	b.bucket = data.Get("bucket").(string)
   219  	b.prefix = data.Get("prefix").(string)
   220  	b.key = data.Get("key").(string)
   221  	b.encrypt = data.Get("encrypt").(bool)
   222  	b.acl = data.Get("acl").(string)
   223  
   224  	var (
   225  		u   *url.URL
   226  		err error
   227  	)
   228  	accelerate := data.Get("accelerate").(bool)
   229  	if accelerate {
   230  		u, err = url.Parse(fmt.Sprintf("https://%s.cos.accelerate.myqcloud.com", b.bucket))
   231  	} else {
   232  		u, err = url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com", b.bucket, b.region))
   233  	}
   234  	if err != nil {
   235  		return err
   236  	}
   237  
   238  	secretId := data.Get("secret_id").(string)
   239  	secretKey := data.Get("secret_key").(string)
   240  	securityToken := data.Get("security_token").(string)
   241  
   242  	// init credential by AKSK & TOKEN
   243  	b.credential = common.NewTokenCredential(secretId, secretKey, securityToken)
   244  	// update credential if assume role exist
   245  	err = handleAssumeRole(data, b)
   246  	if err != nil {
   247  		return err
   248  	}
   249  
   250  	b.cosClient = cos.NewClient(
   251  		&cos.BaseURL{BucketURL: u},
   252  		&http.Client{
   253  			Timeout: 60 * time.Second,
   254  			Transport: &cos.AuthorizationTransport{
   255  				SecretID:     b.credential.SecretId,
   256  				SecretKey:    b.credential.SecretKey,
   257  				SessionToken: b.credential.Token,
   258  			},
   259  		},
   260  	)
   261  
   262  	b.tagClient = b.UseTagClient()
   263  	return err
   264  }
   265  
   266  func handleAssumeRole(data *schema.ResourceData, b *Backend) error {
   267  	assumeRoleList := data.Get("assume_role").(*schema.Set).List()
   268  	if len(assumeRoleList) == 1 {
   269  		assumeRole := assumeRoleList[0].(map[string]interface{})
   270  		assumeRoleArn := assumeRole["role_arn"].(string)
   271  		assumeRoleSessionName := assumeRole["session_name"].(string)
   272  		assumeRoleSessionDuration := assumeRole["session_duration"].(int)
   273  		assumeRolePolicy := assumeRole["policy"].(string)
   274  
   275  		err := b.updateCredentialWithSTS(assumeRoleArn, assumeRoleSessionName, assumeRoleSessionDuration, assumeRolePolicy)
   276  		if err != nil {
   277  			return err
   278  		}
   279  	}
   280  	return nil
   281  }
   282  
   283  func (b *Backend) updateCredentialWithSTS(assumeRoleArn, assumeRoleSessionName string, assumeRoleSessionDuration int, assumeRolePolicy string) error {
   284  	// assume role by STS
   285  	request := sts.NewAssumeRoleRequest()
   286  	request.RoleArn = &assumeRoleArn
   287  	request.RoleSessionName = &assumeRoleSessionName
   288  	duration := uint64(assumeRoleSessionDuration)
   289  	request.DurationSeconds = &duration
   290  	if assumeRolePolicy != "" {
   291  		policy := url.QueryEscape(assumeRolePolicy)
   292  		request.Policy = &policy
   293  	}
   294  
   295  	response, err := b.UseStsClient().AssumeRole(request)
   296  	if err != nil {
   297  		return err
   298  	}
   299  	// update credentials by result of assume role
   300  	b.credential = common.NewTokenCredential(
   301  		*response.Response.Credentials.TmpSecretId,
   302  		*response.Response.Credentials.TmpSecretKey,
   303  		*response.Response.Credentials.Token,
   304  	)
   305  
   306  	return nil
   307  }
   308  
   309  // UseStsClient returns sts client for service
   310  func (b *Backend) UseStsClient() *sts.Client {
   311  	if b.stsClient != nil {
   312  		return b.stsClient
   313  	}
   314  	cpf := b.NewClientProfile(300)
   315  	b.stsClient, _ = sts.NewClient(b.credential, b.region, cpf)
   316  	b.stsClient.WithHttpTransport(&LogRoundTripper{})
   317  
   318  	return b.stsClient
   319  }
   320  
   321  // UseTagClient returns tag client for service
   322  func (b *Backend) UseTagClient() *tag.Client {
   323  	if b.tagClient != nil {
   324  		return b.tagClient
   325  	}
   326  	cpf := b.NewClientProfile(300)
   327  	cpf.Language = "en-US"
   328  	b.tagClient, _ = tag.NewClient(b.credential, b.region, cpf)
   329  	return b.tagClient
   330  }
   331  
   332  // NewClientProfile returns a new ClientProfile
   333  func (b *Backend) NewClientProfile(timeout int) *profile.ClientProfile {
   334  	cpf := profile.NewClientProfile()
   335  
   336  	// all request use method POST
   337  	cpf.HttpProfile.ReqMethod = "POST"
   338  	// request timeout
   339  	cpf.HttpProfile.ReqTimeout = timeout
   340  
   341  	return cpf
   342  }