github.com/vnpaycloud-console/gophercloud/v2@v2.0.5/openstack/config/clouds/clouds.go (about)

     1  // package clouds provides a parser for OpenStack credentials stored in a clouds.yaml file.
     2  //
     3  // Example use:
     4  //
     5  //	ctx := context.Background()
     6  //	ao, eo, tlsConfig, err := clouds.Parse()
     7  //	if err != nil {
     8  //		panic(err)
     9  //	}
    10  //
    11  //	providerClient, err := config.NewProviderClient(ctx, ao, config.WithTLSConfig(tlsConfig))
    12  //	if err != nil {
    13  //		panic(err)
    14  //	}
    15  //
    16  //	networkClient, err := openstack.NewNetworkV2(providerClient, eo)
    17  //	if err != nil {
    18  //		panic(err)
    19  //	}
    20  package clouds
    21  
    22  import (
    23  	"crypto/tls"
    24  	"encoding/json"
    25  	"fmt"
    26  	"os"
    27  	"path"
    28  	"reflect"
    29  
    30  	"github.com/vnpaycloud-console/gophercloud/v2"
    31  	"gopkg.in/yaml.v2"
    32  )
    33  
    34  // Parse fetches a clouds.yaml file from disk and returns the parsed
    35  // credentials.
    36  //
    37  // By default this function mimics the behaviour of python-openstackclient, which is:
    38  //
    39  //   - if the environment variable `OS_CLIENT_CONFIG_FILE` is set and points to a
    40  //     clouds.yaml, use that location as the only search location for `clouds.yaml` and `secure.yaml`;
    41  //   - otherwise, the search locations for `clouds.yaml` and `secure.yaml` are:
    42  //     1. the current working directory (on Linux: `./`)
    43  //     2. the directory `openstack` under the standatd user config location for
    44  //     the operating system (on Linux: `${XDG_CONFIG_HOME:-$HOME/.config}/openstack/`)
    45  //     3. on Linux, `/etc/openstack/`
    46  //
    47  // Once `clouds.yaml` is found in a search location, the same location is used to search for `secure.yaml`.
    48  //
    49  // Like in python-openstackclient, relative paths in the `clouds.yaml` section
    50  // `cacert` are interpreted as relative the the current directory, and not to
    51  // the `clouds.yaml` location.
    52  //
    53  // Search locations, as well as individual `clouds.yaml` properties, can be
    54  // overwritten with functional options.
    55  func Parse(opts ...ParseOption) (gophercloud.AuthOptions, gophercloud.EndpointOpts, *tls.Config, error) {
    56  	options := cloudOpts{
    57  		cloudName:    os.Getenv("OS_CLOUD"),
    58  		region:       os.Getenv("OS_REGION_NAME"),
    59  		endpointType: os.Getenv("OS_INTERFACE"),
    60  		locations: func() []string {
    61  			if path := os.Getenv("OS_CLIENT_CONFIG_FILE"); path != "" {
    62  				return []string{path}
    63  			}
    64  			return nil
    65  		}(),
    66  	}
    67  
    68  	for _, apply := range opts {
    69  		apply(&options)
    70  	}
    71  
    72  	if options.cloudName == "" {
    73  		return gophercloud.AuthOptions{}, gophercloud.EndpointOpts{}, nil, fmt.Errorf("the empty string \"\" is not a valid cloud name")
    74  	}
    75  
    76  	// Set the defaults and open the files for reading. This code only runs
    77  	// if no override has been set, because it is fallible.
    78  	if options.cloudsyamlReader == nil {
    79  		if len(options.locations) < 1 {
    80  			cwd, err := os.Getwd()
    81  			if err != nil {
    82  				return gophercloud.AuthOptions{}, gophercloud.EndpointOpts{}, nil, fmt.Errorf("failed to get the current working directory: %w", err)
    83  			}
    84  			userConfig, err := os.UserConfigDir()
    85  			if err != nil {
    86  				return gophercloud.AuthOptions{}, gophercloud.EndpointOpts{}, nil, fmt.Errorf("failed to get the user config directory: %w", err)
    87  			}
    88  			options.locations = []string{path.Join(cwd, "clouds.yaml"), path.Join(userConfig, "openstack", "clouds.yaml"), path.Join("/etc", "openstack", "clouds.yaml")}
    89  		}
    90  
    91  		for _, cloudsPath := range options.locations {
    92  			f, err := os.Open(cloudsPath)
    93  			if err != nil {
    94  				continue
    95  			}
    96  			defer f.Close()
    97  			options.cloudsyamlReader = f
    98  
    99  			if options.secureyamlReader == nil {
   100  				securePath := path.Join(path.Dir(cloudsPath), "secure.yaml")
   101  				secureF, err := os.Open(securePath)
   102  				if err == nil {
   103  					defer secureF.Close()
   104  					options.secureyamlReader = secureF
   105  				}
   106  			}
   107  			break
   108  		}
   109  		if options.cloudsyamlReader == nil {
   110  			return gophercloud.AuthOptions{}, gophercloud.EndpointOpts{}, nil, fmt.Errorf("clouds file not found. Search locations were: %v", options.locations)
   111  		}
   112  	}
   113  
   114  	// Parse the YAML payloads.
   115  	var clouds Clouds
   116  	if err := yaml.NewDecoder(options.cloudsyamlReader).Decode(&clouds); err != nil {
   117  		return gophercloud.AuthOptions{}, gophercloud.EndpointOpts{}, nil, err
   118  	}
   119  
   120  	cloud, ok := clouds.Clouds[options.cloudName]
   121  	if !ok {
   122  		return gophercloud.AuthOptions{}, gophercloud.EndpointOpts{}, nil, fmt.Errorf("cloud %q not found in clouds.yaml", options.cloudName)
   123  	}
   124  
   125  	if options.secureyamlReader != nil {
   126  		var secureClouds Clouds
   127  		if err := yaml.NewDecoder(options.secureyamlReader).Decode(&secureClouds); err != nil {
   128  			return gophercloud.AuthOptions{}, gophercloud.EndpointOpts{}, nil, fmt.Errorf("failed to parse secure.yaml: %w", err)
   129  		}
   130  
   131  		if secureCloud, ok := secureClouds.Clouds[options.cloudName]; ok {
   132  			// If secureCloud has content and it differs from the cloud entry,
   133  			// merge the two together.
   134  			if !reflect.DeepEqual((gophercloud.AuthOptions{}), secureClouds) && !reflect.DeepEqual(clouds, secureClouds) {
   135  				var err error
   136  				cloud, err = mergeClouds(secureCloud, cloud)
   137  				if err != nil {
   138  					return gophercloud.AuthOptions{}, gophercloud.EndpointOpts{}, nil, fmt.Errorf("unable to merge information from clouds.yaml and secure.yaml")
   139  				}
   140  			}
   141  		}
   142  	}
   143  
   144  	tlsConfig, err := computeTLSConfig(cloud, options)
   145  	if err != nil {
   146  		return gophercloud.AuthOptions{}, gophercloud.EndpointOpts{}, nil, fmt.Errorf("unable to compute TLS configuration: %w", err)
   147  	}
   148  
   149  	endpointType := coalesce(options.endpointType, cloud.EndpointType, cloud.Interface)
   150  
   151  	var scope *gophercloud.AuthScope
   152  	if trustID := cloud.AuthInfo.TrustID; trustID != "" {
   153  		scope = &gophercloud.AuthScope{
   154  			TrustID: trustID,
   155  		}
   156  	}
   157  
   158  	return gophercloud.AuthOptions{
   159  			IdentityEndpoint:            coalesce(options.authURL, cloud.AuthInfo.AuthURL),
   160  			Username:                    coalesce(options.username, cloud.AuthInfo.Username),
   161  			UserID:                      coalesce(options.userID, cloud.AuthInfo.UserID),
   162  			Password:                    coalesce(options.password, cloud.AuthInfo.Password),
   163  			DomainID:                    coalesce(options.domainID, cloud.AuthInfo.UserDomainID, cloud.AuthInfo.ProjectDomainID, cloud.AuthInfo.DomainID),
   164  			DomainName:                  coalesce(options.domainName, cloud.AuthInfo.UserDomainName, cloud.AuthInfo.ProjectDomainName, cloud.AuthInfo.DomainName),
   165  			TenantID:                    coalesce(options.projectID, cloud.AuthInfo.ProjectID),
   166  			TenantName:                  coalesce(options.projectName, cloud.AuthInfo.ProjectName),
   167  			TokenID:                     coalesce(options.token, cloud.AuthInfo.Token),
   168  			Scope:                       coalesce(options.scope, scope),
   169  			ApplicationCredentialID:     coalesce(options.applicationCredentialID, cloud.AuthInfo.ApplicationCredentialID),
   170  			ApplicationCredentialName:   coalesce(options.applicationCredentialName, cloud.AuthInfo.ApplicationCredentialName),
   171  			ApplicationCredentialSecret: coalesce(options.applicationCredentialSecret, cloud.AuthInfo.ApplicationCredentialSecret),
   172  		}, gophercloud.EndpointOpts{
   173  			Region:       coalesce(options.region, cloud.RegionName),
   174  			Availability: computeAvailability(endpointType),
   175  		},
   176  		tlsConfig,
   177  		nil
   178  }
   179  
   180  // computeAvailability is a helper method to determine the endpoint type
   181  // requested by the user.
   182  func computeAvailability(endpointType string) gophercloud.Availability {
   183  	if endpointType == "internal" || endpointType == "internalURL" {
   184  		return gophercloud.AvailabilityInternal
   185  	}
   186  	if endpointType == "admin" || endpointType == "adminURL" {
   187  		return gophercloud.AvailabilityAdmin
   188  	}
   189  	return gophercloud.AvailabilityPublic
   190  }
   191  
   192  // coalesce returns the first argument that is not the zero value for its type,
   193  // or the zero value for its type.
   194  func coalesce[T comparable](items ...T) T {
   195  	var t T
   196  	for _, item := range items {
   197  		if item != t {
   198  			return item
   199  		}
   200  	}
   201  	return t
   202  }
   203  
   204  // mergeClouds merges two Clouds recursively (the AuthInfo also gets merged).
   205  // In case both Clouds define a value, the value in the 'override' cloud takes precedence
   206  func mergeClouds(override, cloud Cloud) (Cloud, error) {
   207  	overrideJson, err := json.Marshal(override)
   208  	if err != nil {
   209  		return Cloud{}, err
   210  	}
   211  	cloudJson, err := json.Marshal(cloud)
   212  	if err != nil {
   213  		return Cloud{}, err
   214  	}
   215  	var overrideInterface any
   216  	err = json.Unmarshal(overrideJson, &overrideInterface)
   217  	if err != nil {
   218  		return Cloud{}, err
   219  	}
   220  	var cloudInterface any
   221  	err = json.Unmarshal(cloudJson, &cloudInterface)
   222  	if err != nil {
   223  		return Cloud{}, err
   224  	}
   225  	var mergedCloud Cloud
   226  	mergedInterface := mergeInterfaces(overrideInterface, cloudInterface)
   227  	mergedJson, err := json.Marshal(mergedInterface)
   228  	if err != nil {
   229  		return Cloud{}, err
   230  	}
   231  	err = json.Unmarshal(mergedJson, &mergedCloud)
   232  	if err != nil {
   233  		return Cloud{}, err
   234  	}
   235  	return mergedCloud, nil
   236  }
   237  
   238  // merges two interfaces. In cases where a value is defined for both 'overridingInterface' and
   239  // 'inferiorInterface' the value in 'overridingInterface' will take precedence.
   240  func mergeInterfaces(overridingInterface, inferiorInterface any) any {
   241  	switch overriding := overridingInterface.(type) {
   242  	case map[string]any:
   243  		interfaceMap, ok := inferiorInterface.(map[string]any)
   244  		if !ok {
   245  			return overriding
   246  		}
   247  		for k, v := range interfaceMap {
   248  			if overridingValue, ok := overriding[k]; ok {
   249  				overriding[k] = mergeInterfaces(overridingValue, v)
   250  			} else {
   251  				overriding[k] = v
   252  			}
   253  		}
   254  	case []any:
   255  		list, ok := inferiorInterface.([]any)
   256  		if !ok {
   257  			return overriding
   258  		}
   259  
   260  		return append(overriding, list...)
   261  	case nil:
   262  		// mergeClouds(nil, map[string]interface{...}) -> map[string]interface{...}
   263  		v, ok := inferiorInterface.(map[string]any)
   264  		if ok {
   265  			return v
   266  		}
   267  	}
   268  	// We don't want to override with empty values
   269  	if reflect.DeepEqual(overridingInterface, nil) || reflect.DeepEqual(reflect.Zero(reflect.TypeOf(overridingInterface)).Interface(), overridingInterface) {
   270  		return inferiorInterface
   271  	} else {
   272  		return overridingInterface
   273  	}
   274  }