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 }