github.com/openshift/installer@v1.4.17/pkg/asset/installconfig/openstack/validation/cloudinfo.go (about)

     1  package validation
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"net/http"
     7  	"net/url"
     8  
     9  	"github.com/gophercloud/gophercloud/v2"
    10  	"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/availabilityzones"
    11  	"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumetypes"
    12  	"github.com/gophercloud/gophercloud/v2/openstack/common/extensions"
    13  	"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/flavors"
    14  	computequotasets "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/quotasets"
    15  	tokensv2 "github.com/gophercloud/gophercloud/v2/openstack/identity/v2/tokens"
    16  	tokensv3 "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens"
    17  	"github.com/gophercloud/gophercloud/v2/openstack/image/v2/images"
    18  	"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips"
    19  	networkquotasets "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/quotas"
    20  	"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/security/groups"
    21  	"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/networks"
    22  	"github.com/gophercloud/gophercloud/v2/openstack/networking/v2/subnets"
    23  	azutils "github.com/gophercloud/utils/v2/openstack/compute/v2/availabilityzones"
    24  	flavorutils "github.com/gophercloud/utils/v2/openstack/compute/v2/flavors"
    25  	imageutils "github.com/gophercloud/utils/v2/openstack/image/v2/images"
    26  	networkutils "github.com/gophercloud/utils/v2/openstack/networking/v2/networks"
    27  	"github.com/sirupsen/logrus"
    28  
    29  	"github.com/openshift/installer/pkg/quota"
    30  	"github.com/openshift/installer/pkg/types"
    31  	"github.com/openshift/installer/pkg/types/openstack"
    32  	openstackdefaults "github.com/openshift/installer/pkg/types/openstack/defaults"
    33  	"github.com/openshift/installer/pkg/types/openstack/validation/networkextensions"
    34  )
    35  
    36  // CloudInfo caches data fetched from the user's openstack cloud
    37  type CloudInfo struct {
    38  	APIFIP                  *floatingips.FloatingIP
    39  	ExternalNetwork         *networks.Network
    40  	Flavors                 map[string]Flavor
    41  	IngressFIP              *floatingips.FloatingIP
    42  	ControlPlanePortSubnets []*subnets.Subnet
    43  	ControlPlanePortNetwork *networks.Network
    44  	OSImage                 *images.Image
    45  	ComputeZones            []string
    46  	VolumeZones             []string
    47  	VolumeTypes             []string
    48  	NetworkExtensions       []extensions.Extension
    49  	Quotas                  []quota.Quota
    50  	Networks                []string
    51  	SecurityGroups          []string
    52  
    53  	clients *clients
    54  }
    55  
    56  type clients struct {
    57  	networkClient  *gophercloud.ServiceClient
    58  	computeClient  *gophercloud.ServiceClient
    59  	imageClient    *gophercloud.ServiceClient
    60  	identityClient *gophercloud.ServiceClient
    61  	volumeClient   *gophercloud.ServiceClient
    62  }
    63  
    64  // Flavor embeds information from the Gophercloud Flavor struct and adds
    65  // information on whether a flavor is of baremetal type.
    66  type Flavor struct {
    67  	flavors.Flavor
    68  	Baremetal bool
    69  }
    70  
    71  var ci *CloudInfo
    72  
    73  // GetCloudInfo fetches and caches metadata from openstack
    74  func GetCloudInfo(ctx context.Context, ic *types.InstallConfig) (*CloudInfo, error) {
    75  	var err error
    76  
    77  	if ci != nil {
    78  		return ci, nil
    79  	}
    80  
    81  	ci = &CloudInfo{
    82  		clients: &clients{},
    83  		Flavors: map[string]Flavor{},
    84  	}
    85  
    86  	opts := openstackdefaults.DefaultClientOpts(ic.OpenStack.Cloud)
    87  
    88  	ci.clients.networkClient, err = openstackdefaults.NewServiceClient(ctx, "network", opts)
    89  	if err != nil {
    90  		return nil, fmt.Errorf("failed to create a network client: %w", err)
    91  	}
    92  
    93  	ci.clients.computeClient, err = openstackdefaults.NewServiceClient(ctx, "compute", opts)
    94  	if err != nil {
    95  		return nil, fmt.Errorf("failed to create a compute client: %w", err)
    96  	}
    97  
    98  	ci.clients.imageClient, err = openstackdefaults.NewServiceClient(ctx, "image", opts)
    99  	if err != nil {
   100  		return nil, fmt.Errorf("failed to create an image client: %w", err)
   101  	}
   102  
   103  	ci.clients.identityClient, err = openstackdefaults.NewServiceClient(ctx, "identity", opts)
   104  	if err != nil {
   105  		return nil, fmt.Errorf("failed to create an identity client: %w", err)
   106  	}
   107  
   108  	ci.clients.volumeClient, err = openstackdefaults.NewServiceClient(ctx, "volume", opts)
   109  	if err != nil {
   110  		return nil, fmt.Errorf("failed to create a volume client: %w", err)
   111  	}
   112  
   113  	err = ci.collectInfo(ctx, ic)
   114  	if err != nil {
   115  		logrus.Warnf("Failed to generate OpenStack cloud info: %v", err)
   116  		return nil, nil
   117  	}
   118  
   119  	return ci, nil
   120  }
   121  
   122  // I see no reason to artificially split this function into chunks just to make
   123  // the linter happy
   124  //
   125  //nolint:gocyclo
   126  func (ci *CloudInfo) collectInfo(ctx context.Context, ic *types.InstallConfig) error {
   127  	var err error
   128  
   129  	ci.ExternalNetwork, err = ci.getNetworkByName(ctx, ic.OpenStack.ExternalNetwork)
   130  	if err != nil {
   131  		return fmt.Errorf("failed to fetch external network info: %w", err)
   132  	}
   133  
   134  	// Fetch the image info if the user provided a Glance image name
   135  	imagePtr := ic.OpenStack.ClusterOSImage
   136  	if imagePtr != "" {
   137  		if _, err := url.ParseRequestURI(imagePtr); err != nil {
   138  			ci.OSImage, err = ci.getImage(ctx, imagePtr)
   139  			if err != nil {
   140  				return err
   141  			}
   142  		}
   143  	}
   144  
   145  	// Get flavor info
   146  	if ic.Platform.OpenStack.DefaultMachinePlatform != nil {
   147  		if flavorName := ic.Platform.OpenStack.DefaultMachinePlatform.FlavorName; flavorName != "" {
   148  			if _, seen := ci.Flavors[flavorName]; !seen {
   149  				flavor, err := ci.getFlavor(ctx, flavorName)
   150  				if !gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
   151  					if err != nil {
   152  						return err
   153  					}
   154  					ci.Flavors[flavorName] = flavor
   155  				}
   156  			}
   157  		}
   158  	}
   159  
   160  	if ic.ControlPlane != nil && ic.ControlPlane.Platform.OpenStack != nil {
   161  		if flavorName := ic.ControlPlane.Platform.OpenStack.FlavorName; flavorName != "" {
   162  			if _, seen := ci.Flavors[flavorName]; !seen {
   163  				flavor, err := ci.getFlavor(ctx, flavorName)
   164  				if !gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
   165  					if err != nil {
   166  						return err
   167  					}
   168  					ci.Flavors[flavorName] = flavor
   169  				}
   170  			}
   171  		}
   172  	}
   173  
   174  	for _, machine := range ic.Compute {
   175  		if machine.Platform.OpenStack != nil {
   176  			if flavorName := machine.Platform.OpenStack.FlavorName; flavorName != "" {
   177  				if _, seen := ci.Flavors[flavorName]; !seen {
   178  					flavor, err := ci.getFlavor(ctx, flavorName)
   179  					if !gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
   180  						if err != nil {
   181  							return err
   182  						}
   183  						ci.Flavors[flavorName] = flavor
   184  					}
   185  				}
   186  			}
   187  		}
   188  	}
   189  	if ic.OpenStack.ControlPlanePort != nil {
   190  		controlPlanePort := ic.OpenStack.ControlPlanePort
   191  		ci.ControlPlanePortSubnets, err = ci.getSubnets(ctx, controlPlanePort)
   192  		if err != nil {
   193  			return err
   194  		}
   195  
   196  		ci.ControlPlanePortNetwork, err = ci.getNetwork(ctx, controlPlanePort)
   197  		if err != nil {
   198  			return err
   199  		}
   200  	}
   201  
   202  	ci.APIFIP, err = ci.getFloatingIP(ctx, ic.OpenStack.APIFloatingIP)
   203  	if err != nil {
   204  		return err
   205  	}
   206  
   207  	ci.IngressFIP, err = ci.getFloatingIP(ctx, ic.OpenStack.IngressFloatingIP)
   208  	if err != nil {
   209  		return err
   210  	}
   211  
   212  	ci.ComputeZones, err = ci.getComputeZones(ctx)
   213  	if err != nil {
   214  		return err
   215  	}
   216  
   217  	ci.VolumeZones, err = ci.getVolumeZones(ctx)
   218  	if err != nil {
   219  		return err
   220  	}
   221  
   222  	ci.VolumeTypes, err = ci.getVolumeTypes(ctx)
   223  	if err != nil {
   224  		return err
   225  	}
   226  
   227  	ci.Quotas, err = loadQuotas(ctx, ci)
   228  	if err != nil {
   229  		switch {
   230  		case gophercloud.ResponseCodeIs(err, http.StatusForbidden):
   231  			logrus.Warnf("Missing permissions to fetch Quotas and therefore will skip checking them: %v", err)
   232  		case gophercloud.ResponseCodeIs(err, http.StatusNotFound):
   233  			logrus.Warnf("Quota API is not available and therefore will skip checking them: %v", err)
   234  		default:
   235  			return fmt.Errorf("failed to load Quota: %w", err)
   236  		}
   237  	}
   238  
   239  	ci.NetworkExtensions, err = networkextensions.Get(ctx, ci.clients.networkClient)
   240  	if err != nil {
   241  		return fmt.Errorf("failed to fetch network extensions: %w", err)
   242  	}
   243  
   244  	ci.Networks, err = ci.getNetworks(ctx)
   245  	if err != nil {
   246  		return err
   247  	}
   248  
   249  	ci.SecurityGroups, err = ci.getSecurityGroups(ctx)
   250  	if err != nil {
   251  		return err
   252  	}
   253  
   254  	return nil
   255  }
   256  
   257  func (ci *CloudInfo) getSubnets(ctx context.Context, controlPlanePort *openstack.PortTarget) ([]*subnets.Subnet, error) {
   258  	controlPlaneSubnets := make([]*subnets.Subnet, 0, len(controlPlanePort.FixedIPs))
   259  	for _, fixedIP := range controlPlanePort.FixedIPs {
   260  		page, err := subnets.List(ci.clients.networkClient, subnets.ListOpts{ID: fixedIP.Subnet.ID, Name: fixedIP.Subnet.Name}).AllPages(ctx)
   261  		if err != nil {
   262  			return controlPlaneSubnets, err
   263  		}
   264  		subnetList, err := subnets.ExtractSubnets(page)
   265  		if err != nil {
   266  			return controlPlaneSubnets, err
   267  		}
   268  		if len(subnetList) == 1 {
   269  			controlPlaneSubnets = append(controlPlaneSubnets, &subnetList[0])
   270  		} else if len(subnetList) > 1 {
   271  			return controlPlaneSubnets, fmt.Errorf("found multiple subnets")
   272  		}
   273  	}
   274  	return controlPlaneSubnets, nil
   275  }
   276  
   277  func (ci *CloudInfo) getFlavor(ctx context.Context, flavorName string) (Flavor, error) {
   278  	flavorID, err := flavorutils.IDFromName(ctx, ci.clients.computeClient, flavorName)
   279  	if err != nil {
   280  		return Flavor{}, err
   281  	}
   282  
   283  	flavor, err := flavors.Get(ctx, ci.clients.computeClient, flavorID).Extract()
   284  	if err != nil {
   285  		return Flavor{}, err
   286  	}
   287  
   288  	var baremetal bool
   289  	{
   290  		const baremetalProperty = "baremetal"
   291  
   292  		m, err := flavors.GetExtraSpec(ctx, ci.clients.computeClient, flavorID, baremetalProperty).Extract()
   293  		if err != nil && !gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
   294  			return Flavor{}, err
   295  		}
   296  
   297  		if m != nil && m[baremetalProperty] == "true" {
   298  			baremetal = true
   299  		}
   300  	}
   301  
   302  	// NOTE(mdbooth): The dereference of flavor is safe here because
   303  	// flavors.Get().Extract() should have raised an error above if the flavor
   304  	// was not found.
   305  	return Flavor{
   306  		Flavor:    *flavor,
   307  		Baremetal: baremetal,
   308  	}, nil
   309  }
   310  
   311  // getNetworks returns all the network IDs available on the cloud.
   312  func (ci *CloudInfo) getNetworks(ctx context.Context) ([]string, error) {
   313  	pages, err := networks.List(ci.clients.networkClient, nil).AllPages(ctx)
   314  	if err != nil {
   315  		return nil, err
   316  	}
   317  
   318  	networks, err := networks.ExtractNetworks(pages)
   319  	if err != nil {
   320  		return nil, err
   321  	}
   322  
   323  	networkIDs := make([]string, len(networks))
   324  	for i := range networks {
   325  		networkIDs[i] = networks[i].ID
   326  	}
   327  
   328  	return networkIDs, nil
   329  }
   330  
   331  // getSecurityGroups returns all the security group IDs available on the cloud.
   332  func (ci *CloudInfo) getSecurityGroups(ctx context.Context) ([]string, error) {
   333  	pages, err := groups.List(ci.clients.networkClient, groups.ListOpts{}).AllPages(ctx)
   334  	if err != nil {
   335  		return nil, err
   336  	}
   337  
   338  	groups, err := groups.ExtractGroups(pages)
   339  	if err != nil {
   340  		return nil, err
   341  	}
   342  
   343  	sgIDs := make([]string, len(groups))
   344  	for i := range groups {
   345  		sgIDs[i] = groups[i].ID
   346  	}
   347  
   348  	return sgIDs, nil
   349  }
   350  
   351  func (ci *CloudInfo) getNetworkByName(ctx context.Context, networkName string) (*networks.Network, error) {
   352  	if networkName == "" {
   353  		return nil, nil
   354  	}
   355  	networkID, err := networkutils.IDFromName(ctx, ci.clients.networkClient, networkName)
   356  	if err != nil {
   357  		if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
   358  			return nil, nil
   359  		}
   360  		return nil, err
   361  	}
   362  
   363  	network, err := networks.Get(ctx, ci.clients.networkClient, networkID).Extract()
   364  	if err != nil {
   365  		return nil, err
   366  	}
   367  
   368  	return network, nil
   369  }
   370  
   371  func (ci *CloudInfo) getNetwork(ctx context.Context, controlPlanePort *openstack.PortTarget) (*networks.Network, error) {
   372  	networkName := controlPlanePort.Network.Name
   373  	networkID := controlPlanePort.Network.ID
   374  	if networkName == "" && networkID == "" {
   375  		return nil, nil
   376  	}
   377  	opts := networks.ListOpts{}
   378  	if networkID != "" {
   379  		opts.ID = controlPlanePort.Network.ID
   380  	}
   381  	if networkName != "" {
   382  		opts.Name = controlPlanePort.Network.Name
   383  	}
   384  	allPages, err := networks.List(ci.clients.networkClient, opts).AllPages(ctx)
   385  	if err != nil {
   386  		return nil, err
   387  	}
   388  
   389  	allNetworks, err := networks.ExtractNetworks(allPages)
   390  	if err != nil {
   391  		return nil, err
   392  	}
   393  
   394  	if len(allNetworks) == 0 {
   395  		return nil, nil
   396  	} else if len(allNetworks) > 1 {
   397  		return nil, fmt.Errorf("found multiple networks")
   398  	}
   399  
   400  	return &allNetworks[0], nil
   401  }
   402  
   403  func (ci *CloudInfo) getFloatingIP(ctx context.Context, fip string) (*floatingips.FloatingIP, error) {
   404  	if fip != "" {
   405  		opts := floatingips.ListOpts{
   406  			FloatingIP: fip,
   407  		}
   408  		allPages, err := floatingips.List(ci.clients.networkClient, opts).AllPages(ctx)
   409  		if err != nil {
   410  			return nil, err
   411  		}
   412  
   413  		allFIPs, err := floatingips.ExtractFloatingIPs(allPages)
   414  		if err != nil {
   415  			return nil, err
   416  		}
   417  
   418  		if len(allFIPs) == 0 {
   419  			return nil, nil
   420  		}
   421  		return &allFIPs[0], nil
   422  	}
   423  	return nil, nil
   424  }
   425  
   426  func (ci *CloudInfo) getImage(ctx context.Context, imageName string) (*images.Image, error) {
   427  	imageID, err := imageutils.IDFromName(ctx, ci.clients.imageClient, imageName)
   428  	if err != nil {
   429  		if gophercloud.ResponseCodeIs(err, http.StatusNotFound) {
   430  			return nil, nil
   431  		}
   432  		return nil, err
   433  	}
   434  
   435  	image, err := images.Get(ctx, ci.clients.imageClient, imageID).Extract()
   436  	if err != nil {
   437  		return nil, err
   438  	}
   439  
   440  	return image, nil
   441  }
   442  
   443  func (ci *CloudInfo) getComputeZones(ctx context.Context) ([]string, error) {
   444  	zones, err := azutils.ListAvailableAvailabilityZones(ctx, ci.clients.computeClient)
   445  	if err != nil {
   446  		return nil, fmt.Errorf("failed to list compute availability zones: %w", err)
   447  	}
   448  
   449  	if len(zones) == 0 {
   450  		return nil, fmt.Errorf("could not find an available compute availability zone")
   451  	}
   452  
   453  	return zones, nil
   454  }
   455  
   456  func (ci *CloudInfo) getVolumeZones(ctx context.Context) ([]string, error) {
   457  	allPages, err := availabilityzones.List(ci.clients.volumeClient).AllPages(ctx)
   458  	if err != nil {
   459  		return nil, fmt.Errorf("failed to list volume availability zones: %w", err)
   460  	}
   461  
   462  	availabilityZoneInfo, err := availabilityzones.ExtractAvailabilityZones(allPages)
   463  	if err != nil {
   464  		return nil, fmt.Errorf("failed to parse response with volume availability zone list: %w", err)
   465  	}
   466  
   467  	if len(availabilityZoneInfo) == 0 {
   468  		return nil, fmt.Errorf("could not find an available volume availability zone")
   469  	}
   470  
   471  	var zones []string
   472  	for _, zone := range availabilityZoneInfo {
   473  		if zone.ZoneState.Available {
   474  			zones = append(zones, zone.ZoneName)
   475  		}
   476  	}
   477  
   478  	return zones, nil
   479  }
   480  
   481  func (ci *CloudInfo) getVolumeTypes(ctx context.Context) ([]string, error) {
   482  	allPages, err := volumetypes.List(ci.clients.volumeClient, volumetypes.ListOpts{}).AllPages(ctx)
   483  	if err != nil {
   484  		return nil, fmt.Errorf("failed to list volume types: %w", err)
   485  	}
   486  
   487  	volumeTypeInfo, err := volumetypes.ExtractVolumeTypes(allPages)
   488  	if err != nil {
   489  		return nil, fmt.Errorf("failed to parse response with volume types list: %w", err)
   490  	}
   491  
   492  	if len(volumeTypeInfo) == 0 {
   493  		return nil, fmt.Errorf("could not find an available block storage volume type")
   494  	}
   495  
   496  	var types []string
   497  	for _, volumeType := range volumeTypeInfo {
   498  		types = append(types, volumeType.Name)
   499  	}
   500  
   501  	return types, nil
   502  }
   503  
   504  // loadQuotas loads the quota information for a project and provided services. It provides information
   505  // about the usage and limit for each resource quota.
   506  func loadQuotas(ctx context.Context, ci *CloudInfo) ([]quota.Quota, error) {
   507  	var quotas []quota.Quota
   508  
   509  	projectID, err := getProjectID(ci)
   510  	if err != nil {
   511  		return nil, fmt.Errorf("failed to get keystone project ID: %w", err)
   512  	}
   513  
   514  	computeRecords, err := getComputeLimits(ctx, ci, projectID)
   515  	if err != nil {
   516  		return nil, fmt.Errorf("failed to get compute quota records: %w", err)
   517  	}
   518  	quotas = append(quotas, computeRecords...)
   519  
   520  	networkRecords, err := getNetworkLimits(ctx, ci, projectID)
   521  	if err != nil {
   522  		return nil, fmt.Errorf("failed to get network quota records: %w", err)
   523  	}
   524  	quotas = append(quotas, networkRecords...)
   525  
   526  	return quotas, nil
   527  }
   528  
   529  func getComputeLimits(ctx context.Context, ci *CloudInfo, projectID string) ([]quota.Quota, error) {
   530  	qs, err := computequotasets.GetDetail(ctx, ci.clients.computeClient, projectID).Extract()
   531  	if err != nil {
   532  		return nil, fmt.Errorf("failed to get QuotaSets from OpenStack Compute API: %w", err)
   533  	}
   534  
   535  	var quotas []quota.Quota
   536  	addQuota := func(name string, quotaDetail computequotasets.QuotaDetail) {
   537  		quotas = append(quotas, quota.Quota{
   538  			Service:   "compute",
   539  			Name:      name,
   540  			InUse:     int64(quotaDetail.InUse),
   541  			Limit:     int64(quotaDetail.Limit - quotaDetail.Reserved),
   542  			Unlimited: quotaDetail.Limit < 0,
   543  		})
   544  	}
   545  	addQuota("Cores", qs.Cores)
   546  	addQuota("Instances", qs.Instances)
   547  	addQuota("RAM", qs.RAM)
   548  
   549  	return quotas, nil
   550  }
   551  
   552  func getNetworkLimits(ctx context.Context, ci *CloudInfo, projectID string) ([]quota.Quota, error) {
   553  	qs, err := networkquotasets.GetDetail(ctx, ci.clients.networkClient, projectID).Extract()
   554  	if err != nil {
   555  		return nil, fmt.Errorf("failed to get QuotaSets from OpenStack Network API: %w", err)
   556  	}
   557  
   558  	var quotas []quota.Quota
   559  	addQuota := func(name string, quotaDetail networkquotasets.QuotaDetail) {
   560  		quotas = append(quotas, quota.Quota{
   561  			Service:   "network",
   562  			Name:      name,
   563  			InUse:     int64(quotaDetail.Used),
   564  			Limit:     int64(quotaDetail.Limit - quotaDetail.Reserved),
   565  			Unlimited: quotaDetail.Limit < 0,
   566  		})
   567  	}
   568  	addQuota("Port", qs.Port)
   569  	addQuota("Router", qs.Router)
   570  	addQuota("Subnet", qs.Subnet)
   571  	addQuota("Network", qs.Network)
   572  	addQuota("SecurityGroup", qs.SecurityGroup)
   573  	addQuota("SecurityGroupRule", qs.SecurityGroupRule)
   574  
   575  	return quotas, nil
   576  }
   577  
   578  func getProjectID(ci *CloudInfo) (string, error) {
   579  	authResult := ci.clients.identityClient.GetAuthResult()
   580  	if authResult == nil {
   581  		return "", fmt.Errorf("client did not use openstack.Authenticate()")
   582  	}
   583  
   584  	switch authResult.(type) {
   585  	case tokensv2.CreateResult:
   586  		// Gophercloud has support for v2, but keystone has deprecated
   587  		// and it's not even documented.
   588  		return "", fmt.Errorf("extracting project ID using the keystone v2 API is not supported")
   589  
   590  	case tokensv3.CreateResult:
   591  		v3Result := authResult.(tokensv3.CreateResult)
   592  		project, err := v3Result.ExtractProject()
   593  		if err != nil {
   594  			return "", fmt.Errorf("extracting project from v3 authResult: %w", err)
   595  		} else if project == nil {
   596  			return "", fmt.Errorf("token is not scoped to a project")
   597  		}
   598  		return project.ID, nil
   599  
   600  	default:
   601  		return "", fmt.Errorf("unsupported AuthResult type: %T", authResult)
   602  	}
   603  }