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  }