github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/provider/oci/images.go (about) 1 // Copyright 2018 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package oci 5 6 import ( 7 "context" 8 "sort" 9 "strconv" 10 "strings" 11 "sync" 12 "time" 13 14 "github.com/juju/errors" 15 ociCore "github.com/oracle/oci-go-sdk/v65/core" 16 17 "github.com/juju/juju/core/arch" 18 corearch "github.com/juju/juju/core/arch" 19 corebase "github.com/juju/juju/core/base" 20 coreconstraints "github.com/juju/juju/core/constraints" 21 "github.com/juju/juju/environs/imagemetadata" 22 "github.com/juju/juju/environs/instances" 23 ) 24 25 const ( 26 BareMetal InstanceType = "metal" 27 VirtualMachine InstanceType = "vm" 28 GPUMachine InstanceType = "gpu" 29 30 // ImageTypeVM should be run on a virtual instance 31 ImageTypeVM ImageType = "vm" 32 // ImageTypeBM should be run on bare metal 33 ImageTypeBM ImageType = "metal" 34 // ImageTypeGPU should be run on an instance with attached GPUs 35 ImageTypeGPU ImageType = "gpu" 36 // ImageTypeGeneric should work on any type of instance (bare metal or virtual) 37 ImageTypeGeneric ImageType = "generic" 38 39 centOS = "CentOS" 40 ubuntuOS = "Canonical Ubuntu" 41 42 staleImageCacheTimeoutInMinutes = 30 43 ) 44 45 var globalImageCache = &ImageCache{} 46 var cacheMutex = &sync.Mutex{} 47 48 type InstanceType string 49 50 func (i InstanceType) String() string { 51 return string(i) 52 } 53 54 type ImageType string 55 56 type ImageVersion struct { 57 TimeStamp time.Time 58 Revision int 59 } 60 61 func setImageCache(cache *ImageCache) { 62 cacheMutex.Lock() 63 defer cacheMutex.Unlock() 64 65 globalImageCache = cache 66 } 67 68 func NewImageVersion(img ociCore.Image) (ImageVersion, error) { 69 var imgVersion ImageVersion 70 if img.DisplayName == nil { 71 return imgVersion, errors.Errorf("image does not have a display name") 72 } 73 fields := strings.Split(*img.DisplayName, "-") 74 if len(fields) < 2 { 75 return imgVersion, errors.Errorf("invalid image display name %q", *img.DisplayName) 76 } 77 timeStamp, err := time.Parse("2006.01.02", fields[len(fields)-2]) 78 if err != nil { 79 return imgVersion, errors.Annotatef(err, "parsing time for %q", *img.DisplayName) 80 } 81 82 revision, err := strconv.Atoi(fields[len(fields)-1]) 83 84 if err != nil { 85 return imgVersion, errors.Annotatef(err, "parsing revision for %q", *img.DisplayName) 86 } 87 88 imgVersion.TimeStamp = timeStamp 89 imgVersion.Revision = revision 90 return imgVersion, nil 91 } 92 93 // InstanceImage aggregates information pertinent to provider supplied 94 // images (eg: shapes it can run on, type of instance it can run on, etc) 95 type InstanceImage struct { 96 // ImageType determines which type of image this is. Valid values are: 97 // vm, baremetal and generic 98 ImageType ImageType 99 // Id is the provider ID of the image 100 Id string 101 // Base is the os base. 102 Base corebase.Base 103 // Version is the version of the image 104 Version ImageVersion 105 // Raw stores the core.Image object 106 Raw ociCore.Image 107 // CompartmentId is the compartment Id where this image is available 108 CompartmentId *string 109 // InstanceTypes holds a list of shapes compatible with this image 110 InstanceTypes []instances.InstanceType 111 // IsMinimal is true when the image is a Minimal image. Can only be 112 // true for ubuntu OS. 113 IsMinimal bool 114 } 115 116 func (i *InstanceImage) SetInstanceTypes(types []instances.InstanceType) { 117 i.InstanceTypes = types 118 } 119 120 // byVersion sorts shapes by version number 121 type byVersion []InstanceImage 122 123 func (t byVersion) Len() int { 124 return len(t) 125 } 126 127 func (t byVersion) Swap(i, j int) { 128 t[i], t[j] = t[j], t[i] 129 } 130 131 func (t byVersion) Less(i, j int) bool { 132 // Sort in reverse order. Newer versions first in array 133 if t[i].Version.TimeStamp.Before(t[j].Version.TimeStamp) { 134 return false 135 } 136 if t[i].Version.TimeStamp.Equal(t[j].Version.TimeStamp) { 137 if t[i].Version.Revision < t[j].Version.Revision { 138 return false 139 } 140 } 141 return true 142 } 143 144 // Alias type representing list of InstanceImage separated by buckets of Base 145 // and architecture for each Base. 146 type imageMap map[corebase.Base]map[string][]InstanceImage 147 148 // ImageCache holds a cache of all provider images for a fixed 149 // amount of time before it becomes stale 150 type ImageCache struct { 151 images imageMap 152 lastRefresh time.Time 153 } 154 155 func (i *ImageCache) ImageMap() imageMap { 156 return i.images 157 } 158 159 // SetLastRefresh sets the lastRefresh attribute of ImageCache 160 // This is used mostly for testing purposes 161 func (i *ImageCache) SetLastRefresh(t time.Time) { 162 i.lastRefresh = t 163 } 164 165 func (i *ImageCache) SetImages(images imageMap) { 166 i.images = images 167 } 168 169 func (i *ImageCache) isStale() bool { 170 threshold := i.lastRefresh.Add(staleImageCacheTimeoutInMinutes * time.Minute) 171 now := time.Now() 172 if now.After(threshold) { 173 return true 174 } 175 return false 176 } 177 178 // ImageMetadata returns an array of imagemetadata.ImageMetadata for 179 // all images that are currently in cache, matching the provided base 180 // If defaultVirtType is specified, all generic images will inherit the 181 // value of defaultVirtType. 182 func (i ImageCache) ImageMetadata(base corebase.Base, arch string, defaultVirtType string) []*imagemetadata.ImageMetadata { 183 var metadata []*imagemetadata.ImageMetadata 184 185 images, ok := i.images[base][arch] 186 if !ok { 187 return metadata 188 } 189 for _, val := range images { 190 if val.ImageType == ImageTypeGeneric { 191 if defaultVirtType != "" { 192 val.ImageType = ImageType(defaultVirtType) 193 } else { 194 val.ImageType = ImageTypeVM 195 } 196 } 197 imgMeta := &imagemetadata.ImageMetadata{ 198 Id: val.Id, 199 Arch: arch, 200 // TODO(gsamfira): add region name? 201 // RegionName: region, 202 Version: val.Base.Channel.Track, 203 VirtType: string(val.ImageType), 204 } 205 metadata = append(metadata, imgMeta) 206 } 207 208 return metadata 209 } 210 211 // SupportedShapes returns the InstanceTypes available for images matching 212 // the supplied base 213 func (i ImageCache) SupportedShapes(base corebase.Base, arch string) []instances.InstanceType { 214 matches := map[string]int{} 215 ret := []instances.InstanceType{} 216 // TODO(gsamfira): Find a better way for this. 217 images, ok := i.images[base][arch] 218 if !ok { 219 return ret 220 } 221 for _, img := range images { 222 for _, instType := range img.InstanceTypes { 223 if _, ok := matches[instType.Name]; !ok { 224 matches[instType.Name] = 1 225 ret = append(ret, instType) 226 } 227 } 228 } 229 return ret 230 } 231 232 // TODO - display names for Images no longer contain vm, bm. 233 // Find a better way to determine image type. One bit of useful info 234 // is "-aarch64-" indicates arm64 images today. 235 // 236 // DisplayName: &"Canonical-Ubuntu-22.04-aarch64-2023.03.18-0", 237 // DisplayName: &"CentOS-7-2023.01.31-0", 238 // DisplayName: &"Canonical-Ubuntu-22.04-2023.03.18-0", 239 // DisplayName: &"Canonical-Ubuntu-22.04-Minimal-2023.01.30-0", 240 // DisplayName: &"Oracle-Linux-7.9-Gen2-GPU-2022.12.16-0", 241 func getImageType(img ociCore.Image) ImageType { 242 if img.DisplayName == nil { 243 return ImageTypeGeneric 244 } 245 name := strings.ToLower(*img.DisplayName) 246 if strings.Contains(name, "-vm-") { 247 return ImageTypeVM 248 } 249 if strings.Contains(name, "-bm-") { 250 return ImageTypeBM 251 } 252 if strings.Contains(name, "-gpu-") { 253 return ImageTypeGPU 254 } 255 return ImageTypeGeneric 256 } 257 258 // NewInstanceImage returns a populated InstanceImage from the ociCore.Image 259 // struct returned by oci's API, the image's architecture or an error. 260 func NewInstanceImage(img ociCore.Image, compartmentID *string) (InstanceImage, string, error) { 261 var ( 262 err error 263 arch string 264 base corebase.Base 265 isMinimal bool 266 imgType InstanceImage 267 ) 268 switch osName := *img.OperatingSystem; osName { 269 case centOS: 270 base = corebase.MakeDefaultBase("centos", *img.OperatingSystemVersion) 271 // For the moment, only x86 shapes are supported 272 arch = corearch.AMD64 273 case ubuntuOS: 274 base, arch, isMinimal = parseUbuntuImage(img) 275 default: 276 return InstanceImage{}, "", errors.NotSupportedf("os %s", osName) 277 } 278 279 imgType.ImageType = getImageType(img) 280 imgType.Id = *img.Id 281 imgType.Base = base 282 imgType.Raw = img 283 imgType.CompartmentId = compartmentID 284 imgType.IsMinimal = isMinimal 285 286 version, err := NewImageVersion(img) 287 if err != nil { 288 return InstanceImage{}, "", err 289 } 290 imgType.Version = version 291 292 return imgType, arch, nil 293 } 294 295 // parseUbuntuImage returns the base and architecture of the returned image 296 // from the OCI sdk. 297 func parseUbuntuImage(img ociCore.Image) (corebase.Base, string, bool) { 298 var ( 299 arch string = corearch.AMD64 300 base corebase.Base 301 isMinimal bool 302 ) 303 // On some cases, the retrieved OperatingSystemVersion can contain 304 // the channel plus some extra information and in some others this 305 // extra information might be missing. 306 // Here are two examples: 307 // - The retrieved image with name Canonical-Ubuntu-22.04-Minimal-aarch64-2023.08.27-0 308 // will contain the following values from the API: 309 // OperatingSystem: Canonical Ubuntu 310 // OperatingSystemVersion: 22.04 Minimal aarch64 311 // In this case, we need to separate the channel (22.04) from the 312 // "postfix" (Minimal aarch64). The channel is needed to correctly 313 // make the base. 314 // - The retrieved image with name Canonical-Ubuntu-22.04-aarch64-2023.08.23 315 // will contain the following values from the API: 316 // OperatingSystem: Canonical Ubuntu 317 // OperatingSystemVersion: 22.04 318 // In this case, the OperatingSystemVersion is not consistent with 319 // the previous example. This is an error on OCI's response (or on 320 // the ubuntu image's metadata) so we need to find a workaround as 321 // explained in the NOTE a few lines below. 322 channel, postfix, _ := strings.Cut(*img.OperatingSystemVersion, " ") 323 base = corebase.MakeDefaultBase(corebase.UbuntuOS, channel) 324 // if not found, means that the OperatingSystemVersion only contained 325 // the channel. 326 if strings.Contains(*img.DisplayName, "Minimal") || 327 strings.Contains(postfix, "Minimal") { 328 isMinimal = true 329 } 330 331 if strings.Contains(*img.DisplayName, "aarch64") || 332 strings.Contains(postfix, "aarch64") { 333 arch = corearch.ARM64 334 } 335 336 return base, arch, isMinimal 337 } 338 339 // instanceTypes will return the list of instanceTypes with information 340 // retrieved from OCI shapes supported by the imageID and compartmentID. 341 // TODO(nvinuesa) 2023-09-26 342 // We should only keep flexible shapes as OCI recommends: 343 // https://docs.oracle.com/en-us/iaas/Content/Compute/References/computeshapes.htm#flexible#previous-generation-shapes__previous-generation-vm#ariaid-title18 344 func instanceTypes(cli ComputeClient, compartmentID, imageID *string) ([]instances.InstanceType, error) { 345 if cli == nil { 346 return nil, errors.Errorf("cannot use nil client") 347 } 348 349 // fetch all shapes for the image from the provider 350 shapes, err := cli.ListShapes(context.Background(), compartmentID, imageID) 351 if err != nil { 352 return nil, errors.Trace(err) 353 } 354 355 // convert shapes to InstanceType 356 types := []instances.InstanceType{} 357 for _, val := range shapes { 358 var mem, cpus float32 359 if val.MemoryInGBs != nil { 360 mem = *val.MemoryInGBs * 1024 361 } 362 if val.Ocpus != nil { 363 cpus = *val.Ocpus 364 } 365 archForShape, instanceType := parseArchAndInstType(val) 366 367 // TODO 2023-04-12 (hml) 368 // Can we add cost information for each instance type by 369 // using the FREE, PAID, and LIMITED_FREE values ranked? 370 // BillingType and IsBilledForStoppedInstance. 371 newType := instances.InstanceType{ 372 Name: *val.Shape, 373 Arch: archForShape, 374 Mem: uint64(mem), 375 CpuCores: uint64(cpus), 376 VirtType: &instanceType, 377 } 378 // If the shape is a flexible shape then the MemoryOptions and 379 // OcpuOptions will not be nil and they indicate the maximum 380 // and minimum values. We assign the max memory and cpu cores 381 // values to the instance type in that case. 382 if val.MemoryOptions != nil && val.MemoryOptions.MaxInGBs != nil { 383 maxMem := uint64(*val.MemoryOptions.MaxInGBs) * 1024 384 newType.MaxMem = &maxMem 385 } 386 if val.OcpuOptions != nil && val.OcpuOptions.Max != nil { 387 maxCpuCores := uint64(*val.OcpuOptions.Max) 388 newType.MaxCpuCores = &maxCpuCores 389 } 390 types = append(types, newType) 391 } 392 return types, nil 393 } 394 395 func parseArchAndInstType(shape ociCore.Shape) (string, string) { 396 var archType, instType string 397 if shape.ProcessorDescription != nil { 398 archType = archTypeByProcessorDescription(*shape.ProcessorDescription) 399 } 400 if shape.Shape == nil { 401 return archType, instType 402 } 403 return archType, instTypeByShapeName(*shape.Shape) 404 } 405 406 func archTypeByProcessorDescription(input string) string { 407 // ProcessorDescription: &"2.55 GHz AMD EPYC™ 7J13 (Milan)", 408 // ProcessorDescription: &"2.6 GHz Intel® Xeon® Platinum 8358 (Ice Lake)", 409 // ProcessorDescription: &"3.0 GHz Ampere® Altra™", 410 var archType string 411 description := strings.ToLower(input) 412 if strings.Contains(description, "ampere") { 413 archType = arch.ARM64 414 } else if strings.Contains(description, "intel") || strings.Contains(description, "amd") { 415 archType = arch.AMD64 416 } 417 return archType 418 } 419 420 func instTypeByShapeName(shape string) string { 421 // Shape: &"VM.GPU.A10.2", 422 // Shape: &"VM.Optimized3.Flex", 423 // Shape: &"VM.Standard.A1.Flex", 424 // Shape: &"BM.GPU.A10.4", 425 // Shape: &"BM.HPC2.36", 426 // Shape: &"BM.Optimized3.36", 427 // Shape: &"BM.Standard.A1.160", 428 switch { 429 case strings.HasPrefix(shape, "VM.GPU"), strings.HasPrefix(shape, "BM.GPU"): 430 return GPUMachine.String() 431 case strings.HasPrefix(shape, "VM."): 432 return VirtualMachine.String() 433 case strings.HasPrefix(shape, "BM."): 434 return BareMetal.String() 435 default: 436 return "" 437 } 438 } 439 440 func refreshImageCache(cli ComputeClient, compartmentID *string) (*ImageCache, error) { 441 cacheMutex.Lock() 442 defer cacheMutex.Unlock() 443 444 if globalImageCache.isStale() == false { 445 return globalImageCache, nil 446 } 447 448 items, err := cli.ListImages(context.Background(), compartmentID) 449 if err != nil { 450 return nil, err 451 } 452 453 images := map[corebase.Base]map[string][]InstanceImage{} 454 455 for _, val := range items { 456 img, arch, err := NewInstanceImage(val, compartmentID) 457 if err != nil { 458 if val.Id != nil { 459 logger.Debugf("error parsing image %q: %q", *val.Id, err) 460 } else { 461 logger.Debugf("error parsing image %q", err) 462 } 463 continue 464 } 465 // For the moment juju does not support minimal ubuntu 466 if img.IsMinimal { 467 logger.Tracef("ubuntu minimal images (%q), not supported", *val.DisplayName) 468 continue 469 } 470 // Only set the instance types to the images that we correctly 471 // parsed. 472 instTypes, err := instanceTypes(cli, compartmentID, val.Id) 473 if err != nil { 474 return nil, err 475 } 476 // An image only suports one architecture, but since for each 477 // image we retrieve the list of shapes from OCI and we map 478 // them to InstanceTypes, we just make sure that all of the 479 // shapes have the same architecture as the image and we log 480 // in case one of them doesn't. 481 for _, instType := range instTypes { 482 if instType.Arch != arch { 483 logger.Debugf("instance type %s has arch %s while image %s only supports %s", instType.Name, instType.Arch, *val.Id, arch) 484 } 485 } 486 img.SetInstanceTypes(instTypes) 487 // TODO: ListImages can return more than one option for a base 488 // based on time created. There is no guarantee that the same 489 // shapes are used with all versions of the same images. 490 if images[img.Base] == nil { 491 images[img.Base] = make(map[string][]InstanceImage) 492 } 493 images[img.Base][arch] = append(images[img.Base][arch], img) 494 } 495 // Sort images of every base and arch 496 for base := range images { 497 for arch := range images[base] { 498 sort.Sort(byVersion(images[base][arch])) 499 } 500 } 501 globalImageCache = &ImageCache{ 502 images: images, 503 lastRefresh: time.Now(), 504 } 505 return globalImageCache, nil 506 } 507 508 // findInstanceSpec returns an *InstanceSpec, imagelist name 509 // satisfying the supplied instanceConstraint 510 func findInstanceSpec( 511 base corebase.Base, 512 arch string, 513 constraints coreconstraints.Value, 514 imgCache *ImageCache, 515 ) (*instances.InstanceSpec, string, error) { 516 allImageMetadata := imgCache.ImageMetadata(base, arch, *constraints.VirtType) 517 logger.Debugf("received %d image(s): %v", len(allImageMetadata), allImageMetadata) 518 519 ic := &instances.InstanceConstraint{ 520 Base: base, 521 Arch: arch, 522 Constraints: constraints, 523 } 524 filtered := []*imagemetadata.ImageMetadata{} 525 // Filter by series. imgCache.supportedShapes() and 526 // imgCache.imageMetadata() will return filtered values 527 // by series already. This additional filtering is done 528 // in case someone wants to use this function with values 529 // not returned by the above two functions 530 for _, val := range allImageMetadata { 531 if val.Version != ic.Base.Channel.Track { 532 continue 533 } 534 filtered = append(filtered, val) 535 } 536 537 instanceType := imgCache.SupportedShapes(base, arch) 538 images := instances.ImageMetadataToImages(filtered) 539 spec, err := instances.FindInstanceSpec(images, ic, instanceType) 540 if err != nil { 541 return nil, "", errors.Trace(err) 542 } 543 544 return spec, spec.Image.Id, nil 545 }