| // Copyright (c) HashiCorp, Inc. |
| // SPDX-License-Identifier: BUSL-1.1 |
| |
| package cos |
| |
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "io" |
| "io/ioutil" |
| "log" |
| "net/http" |
| "net/url" |
| "os" |
| "regexp" |
| "runtime" |
| "strconv" |
| "strings" |
| "time" |
| |
| "github.com/hashicorp/terraform/internal/backend" |
| "github.com/hashicorp/terraform/internal/legacy/helper/schema" |
| "github.com/mitchellh/go-homedir" |
| "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" |
| "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" |
| sts "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sts/v20180813" |
| tag "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tag/v20180813" |
| "github.com/tencentyun/cos-go-sdk-v5" |
| ) |
| |
| // Default value from environment variable |
| const ( |
| PROVIDER_SECRET_ID = "TENCENTCLOUD_SECRET_ID" |
| PROVIDER_SECRET_KEY = "TENCENTCLOUD_SECRET_KEY" |
| PROVIDER_SECURITY_TOKEN = "TENCENTCLOUD_SECURITY_TOKEN" |
| PROVIDER_REGION = "TENCENTCLOUD_REGION" |
| PROVIDER_ENDPOINT = "TENCENTCLOUD_ENDPOINT" |
| PROVIDER_DOMAIN = "TENCENTCLOUD_DOMAIN" |
| PROVIDER_ASSUME_ROLE_ARN = "TENCENTCLOUD_ASSUME_ROLE_ARN" |
| PROVIDER_ASSUME_ROLE_SESSION_NAME = "TENCENTCLOUD_ASSUME_ROLE_SESSION_NAME" |
| PROVIDER_ASSUME_ROLE_SESSION_DURATION = "TENCENTCLOUD_ASSUME_ROLE_SESSION_DURATION" |
| PROVIDER_ASSUME_ROLE_EXTERNAL_ID = "TENCENTCLOUD_ASSUME_ROLE_EXTERNAL_ID" |
| PROVIDER_SHARED_CREDENTIALS_DIR = "TENCENTCLOUD_SHARED_CREDENTIALS_DIR" |
| PROVIDER_PROFILE = "TENCENTCLOUD_PROFILE" |
| PROVIDER_CAM_ROLE_NAME = "TENCENTCLOUD_CAM_ROLE_NAME" |
| ) |
| |
| const ( |
| DEFAULT_PROFILE = "default" |
| ) |
| |
| // Backend implements "backend".Backend for tencentCloud cos |
| type Backend struct { |
| *schema.Backend |
| credential *common.Credential |
| |
| cosContext context.Context |
| cosClient *cos.Client |
| tagClient *tag.Client |
| stsClient *sts.Client |
| |
| region string |
| bucket string |
| prefix string |
| key string |
| encrypt bool |
| acl string |
| domain string |
| } |
| |
| type CAMResponse struct { |
| TmpSecretId string `json:"TmpSecretId"` |
| TmpSecretKey string `json:"TmpSecretKey"` |
| ExpiredTime int64 `json:"ExpiredTime"` |
| Expiration string `json:"Expiration"` |
| Token string `json:"Token"` |
| Code string `json:"Code"` |
| } |
| |
| // New creates a new backend for TencentCloud cos remote state. |
| func New() backend.Backend { |
| s := &schema.Backend{ |
| Schema: map[string]*schema.Schema{ |
| "secret_id": { |
| Type: schema.TypeString, |
| Optional: true, |
| DefaultFunc: schema.EnvDefaultFunc(PROVIDER_SECRET_ID, nil), |
| Description: "Secret id of Tencent Cloud", |
| }, |
| "secret_key": { |
| Type: schema.TypeString, |
| Optional: true, |
| DefaultFunc: schema.EnvDefaultFunc(PROVIDER_SECRET_KEY, nil), |
| Description: "Secret key of Tencent Cloud", |
| Sensitive: true, |
| }, |
| "security_token": { |
| Type: schema.TypeString, |
| Optional: true, |
| DefaultFunc: schema.EnvDefaultFunc(PROVIDER_SECURITY_TOKEN, nil), |
| 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).", |
| Sensitive: true, |
| }, |
| "region": { |
| Type: schema.TypeString, |
| Required: true, |
| DefaultFunc: schema.EnvDefaultFunc(PROVIDER_REGION, nil), |
| Description: "The region of the COS bucket", |
| InputDefault: "ap-guangzhou", |
| }, |
| "bucket": { |
| Type: schema.TypeString, |
| Required: true, |
| Description: "The name of the COS bucket", |
| }, |
| "endpoint": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "The custom endpoint for the COS API, e.g. http://cos-internal.{Region}.tencentcos.cn. Both HTTP and HTTPS are accepted.", |
| DefaultFunc: schema.EnvDefaultFunc(PROVIDER_ENDPOINT, nil), |
| }, |
| "domain": { |
| Type: schema.TypeString, |
| Optional: true, |
| DefaultFunc: schema.EnvDefaultFunc(PROVIDER_DOMAIN, nil), |
| Description: "The root domain of the API request. Default is tencentcloudapi.com.", |
| }, |
| "prefix": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "The directory for saving the state file in bucket", |
| ValidateFunc: func(v interface{}, s string) ([]string, []error) { |
| prefix := v.(string) |
| if strings.HasPrefix(prefix, "/") || strings.HasPrefix(prefix, "./") { |
| return nil, []error{fmt.Errorf("prefix must not start with '/' or './'")} |
| } |
| return nil, nil |
| }, |
| }, |
| "key": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "The path for saving the state file in bucket", |
| Default: "terraform.tfstate", |
| ValidateFunc: func(v interface{}, s string) ([]string, []error) { |
| if strings.HasPrefix(v.(string), "/") || strings.HasSuffix(v.(string), "/") { |
| return nil, []error{fmt.Errorf("key can not start and end with '/'")} |
| } |
| return nil, nil |
| }, |
| }, |
| "encrypt": { |
| Type: schema.TypeBool, |
| Optional: true, |
| Description: "Whether to enable server side encryption of the state file", |
| Default: true, |
| }, |
| "acl": { |
| Type: schema.TypeString, |
| Optional: true, |
| Description: "Object ACL to be applied to the state file", |
| Default: "private", |
| ValidateFunc: func(v interface{}, s string) ([]string, []error) { |
| value := v.(string) |
| if value != "private" && value != "public-read" { |
| return nil, []error{fmt.Errorf( |
| "acl value invalid, expected %s or %s, got %s", |
| "private", "public-read", value)} |
| } |
| return nil, nil |
| }, |
| }, |
| "accelerate": { |
| Type: schema.TypeBool, |
| Optional: true, |
| Description: "Whether to enable global Acceleration", |
| Default: false, |
| }, |
| "assume_role": { |
| Type: schema.TypeSet, |
| Optional: true, |
| MaxItems: 1, |
| Description: "The `assume_role` block. If provided, terraform will attempt to assume this role using the supplied credentials.", |
| Elem: &schema.Resource{ |
| Schema: map[string]*schema.Schema{ |
| "role_arn": { |
| Type: schema.TypeString, |
| Required: true, |
| DefaultFunc: schema.EnvDefaultFunc(PROVIDER_ASSUME_ROLE_ARN, nil), |
| Description: "The ARN of the role to assume. It can be sourced from the `TENCENTCLOUD_ASSUME_ROLE_ARN`.", |
| }, |
| "session_name": { |
| Type: schema.TypeString, |
| Required: true, |
| DefaultFunc: schema.EnvDefaultFunc(PROVIDER_ASSUME_ROLE_SESSION_NAME, nil), |
| Description: "The session name to use when making the AssumeRole call. It can be sourced from the `TENCENTCLOUD_ASSUME_ROLE_SESSION_NAME`.", |
| }, |
| "session_duration": { |
| Type: schema.TypeInt, |
| Required: true, |
| DefaultFunc: func() (interface{}, error) { |
| if v := os.Getenv(PROVIDER_ASSUME_ROLE_SESSION_DURATION); v != "" { |
| return strconv.Atoi(v) |
| } |
| return 7200, nil |
| }, |
| ValidateFunc: validateIntegerInRange(0, 43200), |
| 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`.", |
| }, |
| "policy": { |
| Type: schema.TypeString, |
| Optional: true, |
| 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).", |
| }, |
| "external_id": { |
| Type: schema.TypeString, |
| Optional: true, |
| DefaultFunc: schema.EnvDefaultFunc(PROVIDER_ASSUME_ROLE_EXTERNAL_ID, nil), |
| Description: "External role ID, which can be obtained by clicking the role name in the CAM console. It can contain 2-128 letters, digits, and symbols (=,.@:/-). Regex: [\\w+=,.@:/-]*. It can be sourced from the `TENCENTCLOUD_ASSUME_ROLE_EXTERNAL_ID`.", |
| }, |
| }, |
| }, |
| }, |
| "shared_credentials_dir": { |
| Type: schema.TypeString, |
| Optional: true, |
| DefaultFunc: schema.EnvDefaultFunc(PROVIDER_SHARED_CREDENTIALS_DIR, nil), |
| Description: "The directory of the shared credentials. It can also be sourced from the `TENCENTCLOUD_SHARED_CREDENTIALS_DIR` environment variable. If not set this defaults to ~/.tccli.", |
| }, |
| "profile": { |
| Type: schema.TypeString, |
| Optional: true, |
| DefaultFunc: schema.EnvDefaultFunc(PROVIDER_PROFILE, nil), |
| Description: "The profile name as set in the shared credentials. It can also be sourced from the `TENCENTCLOUD_PROFILE` environment variable. If not set, the default profile created with `tccli configure` will be used.", |
| }, |
| "cam_role_name": { |
| Type: schema.TypeString, |
| Optional: true, |
| DefaultFunc: schema.EnvDefaultFunc(PROVIDER_CAM_ROLE_NAME, nil), |
| Description: "The name of the CVM instance CAM role. It can be sourced from the `TENCENTCLOUD_CAM_ROLE_NAME` environment variable.", |
| }, |
| }, |
| } |
| |
| result := &Backend{Backend: s} |
| result.Backend.ConfigureFunc = result.configure |
| |
| return result |
| } |
| |
| func validateIntegerInRange(min, max int64) schema.SchemaValidateFunc { |
| return func(v interface{}, k string) (ws []string, errors []error) { |
| value := int64(v.(int)) |
| if value < min { |
| errors = append(errors, fmt.Errorf( |
| "%q cannot be lower than %d: %d", k, min, value)) |
| } |
| if value > max { |
| errors = append(errors, fmt.Errorf( |
| "%q cannot be higher than %d: %d", k, max, value)) |
| } |
| return |
| } |
| } |
| |
| // configure init cos client |
| func (b *Backend) configure(ctx context.Context) error { |
| if b.cosClient != nil { |
| return nil |
| } |
| |
| b.cosContext = ctx |
| data := schema.FromContextBackendConfig(b.cosContext) |
| |
| b.region = data.Get("region").(string) |
| b.bucket = data.Get("bucket").(string) |
| b.prefix = data.Get("prefix").(string) |
| b.key = data.Get("key").(string) |
| b.encrypt = data.Get("encrypt").(bool) |
| b.acl = data.Get("acl").(string) |
| |
| var ( |
| u *url.URL |
| err error |
| ) |
| accelerate := data.Get("accelerate").(bool) |
| if accelerate { |
| u, err = url.Parse(fmt.Sprintf("https://%s.cos.accelerate.myqcloud.com", b.bucket)) |
| } else { |
| u, err = url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com", b.bucket, b.region)) |
| } |
| if err != nil { |
| return err |
| } |
| |
| if v, ok := data.GetOk("domain"); ok { |
| b.domain = v.(string) |
| log.Printf("[DEBUG] Backend: set domain for TencentCloud API client. Domain: [%s]", b.domain) |
| } |
| // set url as endpoint when provided |
| // "http://{Bucket}.cos-internal.{Region}.tencentcos.cn" |
| if v, ok := data.GetOk("endpoint"); ok { |
| endpoint := v.(string) |
| |
| re := regexp.MustCompile(`^(http(s)?)://cos-internal\.([^.]+)\.tencentcos\.cn$`) |
| matches := re.FindStringSubmatch(endpoint) |
| if len(matches) != 4 { |
| return fmt.Errorf("Invalid URL: %v must be: %v", endpoint, "http(s)://cos-internal.{Region}.tencentcos.cn") |
| } |
| |
| protocol := matches[1] |
| region := matches[3] |
| |
| // URL after converting |
| newUrl := fmt.Sprintf("%s://%s.cos-internal.%s.tencentcos.cn", protocol, b.bucket, region) |
| u, err = url.Parse(newUrl) |
| log.Printf("[DEBUG] Backend: set COS URL as: [%s]", newUrl) |
| } |
| if err != nil { |
| return err |
| } |
| |
| var getProviderConfig = func(key string) string { |
| var str string |
| value, err := getConfigFromProfile(data, key) |
| if err == nil && value != nil { |
| str = value.(string) |
| } |
| |
| return str |
| } |
| |
| var ( |
| secretId string |
| secretKey string |
| securityToken string |
| ) |
| |
| // get auth from tf/env |
| if v, ok := data.GetOk("secret_id"); ok { |
| secretId = v.(string) |
| } |
| |
| if v, ok := data.GetOk("secret_key"); ok { |
| secretKey = v.(string) |
| } |
| |
| if v, ok := data.GetOk("security_token"); ok { |
| securityToken = v.(string) |
| } |
| |
| // get auth from tccli |
| if secretId == "" && secretKey == "" && securityToken == "" { |
| secretId = getProviderConfig("secretId") |
| secretKey = getProviderConfig("secretKey") |
| securityToken = getProviderConfig("token") |
| } |
| |
| // get auth from CAM role name |
| if v, ok := data.GetOk("cam_role_name"); ok { |
| camRoleName := v.(string) |
| if camRoleName != "" { |
| camResp, err := getAuthFromCAM(camRoleName) |
| if err != nil { |
| return err |
| } |
| |
| secretId = camResp.TmpSecretId |
| secretKey = camResp.TmpSecretKey |
| securityToken = camResp.Token |
| } |
| } |
| |
| // init credential by AKSK & TOKEN |
| b.credential = common.NewTokenCredential(secretId, secretKey, securityToken) |
| // update credential if assume role exist |
| err = handleAssumeRole(data, b) |
| if err != nil { |
| return err |
| } |
| |
| b.cosClient = cos.NewClient( |
| &cos.BaseURL{BucketURL: u}, |
| &http.Client{ |
| Timeout: 60 * time.Second, |
| Transport: &cos.AuthorizationTransport{ |
| SecretID: b.credential.SecretId, |
| SecretKey: b.credential.SecretKey, |
| SessionToken: b.credential.Token, |
| }, |
| }, |
| ) |
| |
| b.tagClient = b.UseTagClient() |
| return err |
| } |
| |
| func handleAssumeRole(data *schema.ResourceData, b *Backend) error { |
| var ( |
| assumeRoleArn string |
| assumeRoleSessionName string |
| assumeRoleSessionDuration int |
| assumeRolePolicy string |
| assumeRoleExternalId string |
| ) |
| |
| // get assume role from credential |
| if providerConfig["role-arn"] != nil { |
| assumeRoleArn = providerConfig["role-arn"].(string) |
| } |
| |
| if providerConfig["role-session-name"] != nil { |
| assumeRoleSessionName = providerConfig["role-session-name"].(string) |
| } |
| |
| if assumeRoleArn != "" && assumeRoleSessionName != "" { |
| assumeRoleSessionDuration = 7200 |
| } |
| |
| // get assume role from env |
| envRoleArn := os.Getenv(PROVIDER_ASSUME_ROLE_ARN) |
| envSessionName := os.Getenv(PROVIDER_ASSUME_ROLE_SESSION_NAME) |
| if envRoleArn != "" && envSessionName != "" { |
| assumeRoleArn = envRoleArn |
| assumeRoleSessionName = envSessionName |
| if envSessionDuration := os.Getenv(PROVIDER_ASSUME_ROLE_SESSION_DURATION); envSessionDuration != "" { |
| var err error |
| assumeRoleSessionDuration, err = strconv.Atoi(envSessionDuration) |
| if err != nil { |
| return err |
| } |
| } |
| |
| if assumeRoleSessionDuration == 0 { |
| assumeRoleSessionDuration = 7200 |
| } |
| |
| assumeRoleExternalId = os.Getenv(PROVIDER_ASSUME_ROLE_EXTERNAL_ID) |
| } |
| |
| // get assume role from tf |
| assumeRoleList := data.Get("assume_role").(*schema.Set).List() |
| if len(assumeRoleList) == 1 { |
| assumeRole := assumeRoleList[0].(map[string]interface{}) |
| assumeRoleArn = assumeRole["role_arn"].(string) |
| assumeRoleSessionName = assumeRole["session_name"].(string) |
| assumeRoleSessionDuration = assumeRole["session_duration"].(int) |
| assumeRolePolicy = assumeRole["policy"].(string) |
| assumeRoleExternalId = assumeRole["external_id"].(string) |
| } |
| |
| if assumeRoleArn != "" && assumeRoleSessionName != "" { |
| err := b.updateCredentialWithSTS(assumeRoleArn, assumeRoleSessionName, assumeRoleSessionDuration, assumeRolePolicy, assumeRoleExternalId) |
| if err != nil { |
| return err |
| } |
| } |
| |
| return nil |
| } |
| |
| func (b *Backend) updateCredentialWithSTS(assumeRoleArn, assumeRoleSessionName string, assumeRoleSessionDuration int, assumeRolePolicy string, assumeRoleExternalId string) error { |
| // assume role by STS |
| request := sts.NewAssumeRoleRequest() |
| request.RoleArn = &assumeRoleArn |
| request.RoleSessionName = &assumeRoleSessionName |
| duration := uint64(assumeRoleSessionDuration) |
| request.DurationSeconds = &duration |
| if assumeRolePolicy != "" { |
| policy := url.QueryEscape(assumeRolePolicy) |
| request.Policy = &policy |
| } |
| |
| if assumeRoleExternalId != "" { |
| request.ExternalId = &assumeRoleExternalId |
| } |
| |
| response, err := b.UseStsClient().AssumeRole(request) |
| if err != nil { |
| return err |
| } |
| // update credentials by result of assume role |
| b.credential = common.NewTokenCredential( |
| *response.Response.Credentials.TmpSecretId, |
| *response.Response.Credentials.TmpSecretKey, |
| *response.Response.Credentials.Token, |
| ) |
| |
| return nil |
| } |
| |
| // UseStsClient returns sts client for service |
| func (b *Backend) UseStsClient() *sts.Client { |
| if b.stsClient != nil { |
| return b.stsClient |
| } |
| cpf := b.NewClientProfile(300) |
| b.stsClient, _ = sts.NewClient(b.credential, b.region, cpf) |
| b.stsClient.WithHttpTransport(&LogRoundTripper{}) |
| |
| return b.stsClient |
| } |
| |
| // UseTagClient returns tag client for service |
| func (b *Backend) UseTagClient() *tag.Client { |
| if b.tagClient != nil { |
| return b.tagClient |
| } |
| cpf := b.NewClientProfile(300) |
| cpf.Language = "en-US" |
| b.tagClient, _ = tag.NewClient(b.credential, b.region, cpf) |
| return b.tagClient |
| } |
| |
| // NewClientProfile returns a new ClientProfile |
| func (b *Backend) NewClientProfile(timeout int) *profile.ClientProfile { |
| cpf := profile.NewClientProfile() |
| |
| // all request use method POST |
| cpf.HttpProfile.ReqMethod = "POST" |
| // request timeout |
| cpf.HttpProfile.ReqTimeout = timeout |
| // request domain |
| cpf.HttpProfile.RootDomain = b.domain |
| |
| return cpf |
| } |
| |
| func getAuthFromCAM(roleName string) (camResp *CAMResponse, err error) { |
| url := fmt.Sprintf("http://metadata.tencentyun.com/latest/meta-data/cam/security-credentials/%s", roleName) |
| log.Printf("[CRITAL] Request CAM security credentials url: %s\n", url) |
| // maximum waiting time |
| client := &http.Client{Timeout: 2 * time.Second} |
| req, err := http.NewRequest("GET", url, nil) |
| if err != nil { |
| return |
| } |
| |
| resp, err := client.Do(req) |
| if err != nil { |
| log.Printf("[CRITAL] Request CAM security credentials resp err: %s", err.Error()) |
| return |
| } |
| |
| defer resp.Body.Close() |
| |
| body, err := io.ReadAll(resp.Body) |
| if err != nil { |
| log.Printf("[CRITAL] Request CAM security credentials body read err: %s", err.Error()) |
| return |
| } |
| |
| err = json.Unmarshal(body, &camResp) |
| if err != nil { |
| log.Printf("[CRITAL] Request CAM security credentials resp json err: %s", err.Error()) |
| return |
| } |
| |
| return |
| } |
| |
| var providerConfig map[string]interface{} |
| |
| func getConfigFromProfile(d *schema.ResourceData, ProfileKey string) (interface{}, error) { |
| if providerConfig == nil { |
| var ( |
| profile string |
| sharedCredentialsDir string |
| credentialPath string |
| configurePath string |
| ) |
| |
| if v, ok := d.GetOk("profile"); ok { |
| profile = v.(string) |
| } else { |
| profile = DEFAULT_PROFILE |
| } |
| |
| if v, ok := d.GetOk("shared_credentials_dir"); ok { |
| sharedCredentialsDir = v.(string) |
| } |
| |
| tmpSharedCredentialsDir, err := homedir.Expand(sharedCredentialsDir) |
| if err != nil { |
| return nil, err |
| } |
| |
| if tmpSharedCredentialsDir == "" { |
| credentialPath = fmt.Sprintf("%s/.tccli/%s.credential", os.Getenv("HOME"), profile) |
| configurePath = fmt.Sprintf("%s/.tccli/%s.configure", os.Getenv("HOME"), profile) |
| if runtime.GOOS == "windows" { |
| credentialPath = fmt.Sprintf("%s/.tccli/%s.credential", os.Getenv("USERPROFILE"), profile) |
| configurePath = fmt.Sprintf("%s/.tccli/%s.configure", os.Getenv("USERPROFILE"), profile) |
| } |
| } else { |
| credentialPath = fmt.Sprintf("%s/%s.credential", tmpSharedCredentialsDir, profile) |
| configurePath = fmt.Sprintf("%s/%s.configure", tmpSharedCredentialsDir, profile) |
| } |
| |
| providerConfig = make(map[string]interface{}) |
| _, err = os.Stat(credentialPath) |
| if !os.IsNotExist(err) { |
| data, err := ioutil.ReadFile(credentialPath) |
| if err != nil { |
| return nil, err |
| } |
| |
| config := map[string]interface{}{} |
| err = json.Unmarshal(data, &config) |
| if err != nil { |
| return nil, err |
| } |
| |
| for k, v := range config { |
| if strValue, ok := v.(string); ok { |
| providerConfig[k] = strings.TrimSpace(strValue) |
| } |
| } |
| } |
| |
| _, err = os.Stat(configurePath) |
| if !os.IsNotExist(err) { |
| data, err := ioutil.ReadFile(configurePath) |
| if err != nil { |
| return nil, err |
| } |
| |
| config := map[string]interface{}{} |
| err = json.Unmarshal(data, &config) |
| if err != nil { |
| return nil, err |
| } |
| |
| outerLoop: |
| for k, v := range config { |
| if k == "_sys_param" { |
| tmpMap := v.(map[string]interface{}) |
| for tmpK, tmpV := range tmpMap { |
| if tmpK == "region" { |
| providerConfig[tmpK] = strings.TrimSpace(tmpV.(string)) |
| break outerLoop |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| return providerConfig[ProfileKey], nil |
| } |