diff --git a/v1/providers/nebius/instance.go b/v1/providers/nebius/instance.go index b474773..fa5dacc 100644 --- a/v1/providers/nebius/instance.go +++ b/v1/providers/nebius/instance.go @@ -18,6 +18,22 @@ const ( platformTypeCPU = "cpu" ) +// image scoring tiers. higher wins. exact-match bonus (200) > max baseline gap +// so explicit requests always override defaults, even for worker-node. +const ( + imageScoreBaseline = 1 + imageScoreWorkerNode = 10 + imageScoreUbuntuGeneric = 50 + imageScoreUbuntu22 = 55 + imageScoreUbuntu24 = 60 + imageScoreUbuntu22Cuda = 80 + imageScoreUbuntu24Cuda12 = 90 + imageScoreUbuntu24Cuda13 = 100 + + imageScoreExactMatchBonus = 200 + imageScoreUbuntuHintBonus = 50 +) + //nolint:gocyclo,funlen // Complex instance creation with resource management func (c *NebiusClient) CreateInstance(ctx context.Context, attrs v1.CreateInstanceAttrs) (*v1.Instance, error) { // Track created resources for automatic cleanup on failure @@ -1182,6 +1198,12 @@ func (c *NebiusClient) createBootDisk(ctx context.Context, attrs v1.CreateInstan // buildDiskCreateRequest builds a disk creation request, trying image family first, then image ID func (c *NebiusClient) buildDiskCreateRequest(ctx context.Context, diskName string, attrs v1.CreateInstanceAttrs) (*compute.CreateDiskRequest, error) { + c.logger.Info(ctx, "buildDiskCreateRequest: start", + v1.LogField("diskName", diskName), + v1.LogField("attrs.ImageID", attrs.ImageID), + v1.LogField("attrs.RefID", attrs.RefID), + v1.LogField("attrs.DiskSize", attrs.DiskSize)) + if attrs.DiskSize == 0 { attrs.DiskSize = 1280 * units.Gibibyte // Defaulted by the Nebius Console } @@ -1205,51 +1227,18 @@ func (c *NebiusClient) buildDiskCreateRequest(ctx context.Context, diskName stri } // First, try to resolve and use image family - if imageFamily, err := c.resolveImageFamily(ctx, attrs.ImageID); err == nil { - publicImagesParent := c.getPublicImagesParent() - - // Skip validation for known-good common families to speed up instance start - knownFamilies := []string{"ubuntu22.04-cuda12", "mk8s-worker-node-v-1-32-ubuntu24.04", "mk8s-worker-node-v-1-32-ubuntu24.04-cuda12.8"} - isKnownFamily := false - for _, known := range knownFamilies { - if imageFamily == known { - isKnownFamily = true - break - } - } - - if isKnownFamily { - // Use known family without validation - baseReq.Spec.Source = &compute.DiskSpec_SourceImageFamily{ - SourceImageFamily: &compute.SourceImageFamily{ - ImageFamily: imageFamily, - ParentId: publicImagesParent, - }, - } - baseReq.Metadata.Labels["image-family"] = imageFamily - return baseReq, nil - } - - // For unknown families, validate first - _, err := c.sdk.Services().Compute().V1().Image().GetLatestByFamily(ctx, &compute.GetImageLatestByFamilyRequest{ - ParentId: publicImagesParent, - ImageFamily: imageFamily, - }) - if err == nil { - // Family works, use it - baseReq.Spec.Source = &compute.DiskSpec_SourceImageFamily{ - SourceImageFamily: &compute.SourceImageFamily{ - ImageFamily: imageFamily, - ParentId: publicImagesParent, - }, - } - baseReq.Metadata.Labels["image-family"] = imageFamily - return baseReq, nil - } + if c.tryApplyImageFamilySource(ctx, baseReq, attrs.ImageID) { + return baseReq, nil } // Family approach failed, try to use a known working public image ID + c.logger.Info(ctx, "buildDiskCreateRequest: BRANCH=scoring (falling back to getWorkingPublicImageID)", + v1.LogField("attrs.ImageID", attrs.ImageID)) publicImageID, err := c.getWorkingPublicImageID(ctx, attrs.ImageID) + c.logger.Info(ctx, "buildDiskCreateRequest: getWorkingPublicImageID result", + v1.LogField("publicImageID", publicImageID), + v1.LogField("err", fmt.Sprintf("%v", err))) + if err == nil { baseReq.Spec.Source = &compute.DiskSpec_SourceImageId{ SourceImageId: publicImageID, @@ -1262,80 +1251,230 @@ func (c *NebiusClient) buildDiskCreateRequest(ctx context.Context, diskName stri return nil, fmt.Errorf("could not resolve image %s to either a working family or image ID: %w", attrs.ImageID, err) } -// getWorkingPublicImageID gets a working public image ID based on the requested image type -// -//nolint:gocognit,gocyclo // Complex function trying multiple image resolution strategies -func (c *NebiusClient) getWorkingPublicImageID(ctx context.Context, requestedImage string) (string, error) { - // Get available public images from the correct region +// tryApplyImageFamilySource attempts to set baseReq's disk source via image-family lookup. +// Returns true if a family-based source was applied (caller should return baseReq). +// Returns false if the caller should fall back to scoring (getWorkingPublicImageID). +func (c *NebiusClient) tryApplyImageFamilySource(ctx context.Context, baseReq *compute.CreateDiskRequest, imageID string) bool { + imageFamily, resolveErr := c.resolveImageFamily(ctx, imageID) + c.logger.Info(ctx, "buildDiskCreateRequest: resolveImageFamily result", + v1.LogField("attrs.ImageID", imageID), + v1.LogField("resolvedFamily", imageFamily), + v1.LogField("err", fmt.Sprintf("%v", resolveErr))) + if resolveErr != nil { + return false + } + publicImagesParent := c.getPublicImagesParent() - imagesResp, err := c.sdk.Services().Compute().V1().Image().List(ctx, &compute.ListImagesRequest{ - ParentId: publicImagesParent, + knownFamilies := []string{"ubuntu24.04-cuda13.0", "ubuntu24.04-cuda12", "ubuntu22.04-cuda12", "mk8s-worker-node-v-1-32-ubuntu24.04", "mk8s-worker-node-v-1-32-ubuntu24.04-cuda12.8"} + isKnownFamily := false + for _, known := range knownFamilies { + if imageFamily == known { + isKnownFamily = true + break + } + } + c.logger.Info(ctx, "buildDiskCreateRequest: known-family check", + v1.LogField("imageFamily", imageFamily), + v1.LogField("isKnownFamily", isKnownFamily), + v1.LogField("publicImagesParent", publicImagesParent)) + + if isKnownFamily { + c.logger.Info(ctx, "buildDiskCreateRequest: BRANCH=known-family (skipping validation)", + v1.LogField("imageFamily", imageFamily)) + applyImageFamilySource(baseReq, imageFamily, publicImagesParent) + return true + } + + latestImage, err := c.sdk.Services().Compute().V1().Image().GetLatestByFamily(ctx, &compute.GetImageLatestByFamilyRequest{ + ParentId: publicImagesParent, + ImageFamily: imageFamily, }) + latestName, latestID, latestArch := "", "", "" + if latestImage != nil { + if latestImage.Metadata != nil { + latestName = latestImage.Metadata.Name + latestID = latestImage.Metadata.Id + } + if latestImage.Spec != nil { + latestArch = latestImage.Spec.GetCpuArchitecture().String() + } + } + c.logger.Info(ctx, "buildDiskCreateRequest: GetLatestByFamily result", + v1.LogField("imageFamily", imageFamily), + v1.LogField("err", fmt.Sprintf("%v", err)), + v1.LogField("latestImageID", latestID), + v1.LogField("latestImageName", latestName), + v1.LogField("latestImageArch", latestArch)) if err != nil { - return "", fmt.Errorf("failed to list public images: %w", err) + return false } - if len(imagesResp.GetItems()) == 0 { - return "", fmt.Errorf("no public images available") + if latestImage.Spec != nil && latestImage.Spec.GetCpuArchitecture() == compute.ImageSpec_ARM64 { + c.logger.Info(ctx, "buildDiskCreateRequest: validated-family is ARM64, falling through to scoring", + v1.LogField("imageFamily", imageFamily)) + return false } - // Try to find the best match based on the requested image + c.logger.Info(ctx, "buildDiskCreateRequest: BRANCH=validated-family (non-ARM64)", + v1.LogField("imageFamily", imageFamily), + v1.LogField("latestImageID", latestID)) + applyImageFamilySource(baseReq, imageFamily, publicImagesParent) + return true +} + +func applyImageFamilySource(baseReq *compute.CreateDiskRequest, imageFamily, publicImagesParent string) { + baseReq.Spec.Source = &compute.DiskSpec_SourceImageFamily{ + SourceImageFamily: &compute.SourceImageFamily{ + ImageFamily: imageFamily, + ParentId: publicImagesParent, + }, + } + baseReq.Metadata.Labels["image-family"] = imageFamily +} + +// getWorkingPublicImageID gets a working public image ID based on the requested image type. +// It scores every non-ARM64 image and returns the highest-scored one, this is done to handle change in ordering of images from nebius api. +func (c *NebiusClient) getWorkingPublicImageID(ctx context.Context, requestedImage string) (string, error) { + publicImagesParent := c.getPublicImagesParent() + c.logger.Info(ctx, "getWorkingPublicImageID: listing images", + v1.LogField("requestedImage", requestedImage), + v1.LogField("publicImagesParent", publicImagesParent)) + requestedLower := strings.ToLower(requestedImage) - var bestMatch *compute.Image - var fallbackImage *compute.Image + var bestImage *compute.Image + bestScore := -1 + totalCount, consideredCount, arm64Skipped, nilMetadataSkipped := 0, 0, 0, 0 + var iterErr error + + // Filter auto-paginates via the SDK. Using List directly only returns the first + // page (small default size), which can omit ubuntu24.04-cuda13.0 entirely. + imageIter := c.sdk.Services().Compute().V1().Image().Filter(ctx, &compute.ListImagesRequest{ + ParentId: publicImagesParent, + PageSize: 988, + }) + imageIter(func(image *compute.Image, err error) bool { + if err != nil { + iterErr = err + return false + } + totalCount++ - for _, image := range imagesResp.GetItems() { if image.Metadata == nil { - continue + nilMetadataSkipped++ + return true } + if image.Spec != nil && image.Spec.GetCpuArchitecture() == compute.ImageSpec_ARM64 { + arm64Skipped++ + return true + } + consideredCount++ - imageName := strings.ToLower(image.Metadata.Name) + score := scoreImage(image, requestedLower) + family := "" + if image.Spec != nil { + family = image.Spec.GetImageFamily() + } + c.logger.Info(ctx, "getWorkingPublicImageID: scored", + v1.LogField("id", image.Metadata.Id), + v1.LogField("name", image.Metadata.Name), + v1.LogField("family", family), + v1.LogField("score", score)) - // Set fallback to first available image - if fallbackImage == nil { - fallbackImage = image + if score > bestScore { + bestScore = score + bestImage = image } + return true + }) - // Look for Ubuntu matches - if strings.Contains(requestedLower, "ubuntu") && strings.Contains(imageName, "ubuntu") { - // Prefer specific version matches - //nolint:gocritic // if-else chain is clearer than switch for version matching logic - if strings.Contains(requestedLower, "24.04") || strings.Contains(requestedLower, "24") { - if strings.Contains(imageName, "ubuntu24.04") { - bestMatch = image - break - } - } else if strings.Contains(requestedLower, "22.04") || strings.Contains(requestedLower, "22") { - if strings.Contains(imageName, "ubuntu22.04") { - bestMatch = image - break - } - } else if strings.Contains(requestedLower, "20.04") || strings.Contains(requestedLower, "20") { - if strings.Contains(imageName, "ubuntu20.04") { - bestMatch = image - break - } - } + if iterErr != nil { + c.logger.Error(ctx, fmt.Errorf("failed to iterate public images: %w", iterErr), + v1.LogField("publicImagesParent", publicImagesParent)) + return "", fmt.Errorf("failed to iterate public images: %w", iterErr) + } - // Any Ubuntu image is better than non-Ubuntu - if bestMatch == nil { - bestMatch = image - } - } + c.logger.Info(ctx, "getWorkingPublicImageID: scoring summary", + v1.LogField("totalImages", totalCount), + v1.LogField("consideredCount", consideredCount), + v1.LogField("arm64Skipped", arm64Skipped), + v1.LogField("nilMetadataSkipped", nilMetadataSkipped), + v1.LogField("bestScore", bestScore)) + + if bestImage == nil { + return "", fmt.Errorf("no suitable public image found") } - // Use best match if found, otherwise fallback - selectedImage := bestMatch - if selectedImage == nil { - selectedImage = fallbackImage + winnerFamily := "" + if bestImage.Spec != nil { + winnerFamily = bestImage.Spec.GetImageFamily() } + c.logger.Info(ctx, "getWorkingPublicImageID: winner", + v1.LogField("id", bestImage.Metadata.Id), + v1.LogField("name", bestImage.Metadata.Name), + v1.LogField("family", winnerFamily), + v1.LogField("score", bestScore)) - if selectedImage == nil { - return "", fmt.Errorf("no suitable public image found") + return bestImage.Metadata.Id, nil +} + +// scoreImage picks the best public image when Nebius returns a messy list. +// default order: ubuntu24+cuda13 > ubuntu24+cuda12 > ubuntu22+cuda12 > +// ubuntu24 > ubuntu22 > other ubuntu > worker-node > rest. +// request bonus layers on top if the caller asked for something specific. +func scoreImage(image *compute.Image, requestedLower string) int { + family := "" + if image.Spec != nil { + family = strings.ToLower(image.Spec.GetImageFamily()) + } + name := strings.ToLower(image.Metadata.Name) + + return baseImageScore(name, family) + requestMatchBonus(name, family, requestedLower) +} + +// baseImageScore scores based on the image alone. worker-node is checked first +// so an ubuntu24-cuda12.8 worker image doesn't get classified as ubuntu24+cuda12. +func baseImageScore(name, family string) int { + has := func(s string) bool { return strings.Contains(name, s) || strings.Contains(family, s) } + + if has("mk8s-worker") || strings.Contains(name, "worker-node") { + return imageScoreWorkerNode + } + if !has("ubuntu") { + return imageScoreBaseline + } + if has("ubuntu24") { + if has("cuda13") { + return imageScoreUbuntu24Cuda13 + } + if has("cuda12") { + return imageScoreUbuntu24Cuda12 + } + return imageScoreUbuntu24 } + if has("ubuntu22") { + if has("cuda12") { + return imageScoreUbuntu22Cuda + } + return imageScoreUbuntu22 + } + return imageScoreUbuntuGeneric +} - return selectedImage.Metadata.Id, nil +// requestMatchBonus: +200 if request is a substring of name/family (big enough +// to beat any baseline gap), +50 as a weak nudge when caller said "ubuntu" but +// nothing matched directly. +func requestMatchBonus(name, family, requestedLower string) int { + if requestedLower == "" { + return 0 + } + if strings.Contains(name, requestedLower) || strings.Contains(family, requestedLower) || requestedLower == family { + return imageScoreExactMatchBonus + } + if strings.Contains(requestedLower, "ubuntu") && (strings.Contains(name, "ubuntu") || strings.Contains(family, "ubuntu")) { + return imageScoreUbuntuHintBonus + } + return 0 } // getPublicImagesParent determines the correct public images parent ID based on project routing code @@ -1581,8 +1720,15 @@ func (c *NebiusClient) parseInstanceType(ctx context.Context, instanceTypeID str // //nolint:gocyclo,unparam // Complex image family resolution with fallback logic func (c *NebiusClient) resolveImageFamily(ctx context.Context, imageID string) (string, error) { + c.logger.Info(ctx, "resolveImageFamily: start", + v1.LogField("imageID", imageID), + v1.LogField("imageIDLen", len(imageID))) + // Common Nebius image families - if ImageID matches one of these, use it directly commonFamilies := []string{ + "ubuntu24.04-cuda13.0", + "ubuntu24.04-cuda12", + "ubuntu24.04-driverless", "ubuntu22.04-cuda12", "mk8s-worker-node-v-1-32-ubuntu24.04", "mk8s-worker-node-v-1-32-ubuntu24.04-cuda12.8", @@ -1594,6 +1740,8 @@ func (c *NebiusClient) resolveImageFamily(ctx context.Context, imageID string) ( // Check if ImageID is already a known family name for _, family := range commonFamilies { if imageID == family { + c.logger.Info(ctx, "resolveImageFamily: matched commonFamilies", + v1.LogField("family", family)) return family, nil } } @@ -1601,7 +1749,8 @@ func (c *NebiusClient) resolveImageFamily(ctx context.Context, imageID string) ( // If ImageID looks like a family name pattern (contains dots, dashes, no UUIDs) // and doesn't look like a UUID, assume it's a family name if !strings.Contains(imageID, "-") || len(imageID) < 32 { - // Likely a family name, use it directly + c.logger.Info(ctx, "resolveImageFamily: treating as family (short/no-dash)", + v1.LogField("returnValue", imageID)) return imageID, nil } @@ -1610,17 +1759,22 @@ func (c *NebiusClient) resolveImageFamily(ctx context.Context, imageID string) ( Id: imageID, }) if err != nil { - // If we can't get the image, try using the ID as a family name anyway - // This allows for custom family names that don't match our patterns + c.logger.Info(ctx, "resolveImageFamily: Get failed, returning imageID as family", + v1.LogField("imageID", imageID), + v1.LogField("err", fmt.Sprintf("%v", err))) return imageID, nil } // Extract family from image metadata/labels if available if image.Metadata != nil && image.Metadata.Labels != nil { if family, exists := image.Metadata.Labels["family"]; exists && family != "" { + c.logger.Info(ctx, "resolveImageFamily: resolved via labels[family]", + v1.LogField("family", family)) return family, nil } if family, exists := image.Metadata.Labels["image-family"]; exists && family != "" { + c.logger.Info(ctx, "resolveImageFamily: resolved via labels[image-family]", + v1.LogField("family", family)) return family, nil } } @@ -1630,15 +1784,21 @@ func (c *NebiusClient) resolveImageFamily(ctx context.Context, imageID string) ( // Try to extract a reasonable family name from the image name name := strings.ToLower(image.Metadata.Name) if strings.Contains(name, "ubuntu22") || strings.Contains(name, "ubuntu-22") { + c.logger.Info(ctx, "resolveImageFamily: inferred ubuntu22 from name", + v1.LogField("name", image.Metadata.Name)) return "ubuntu22.04", nil } if strings.Contains(name, "ubuntu20") || strings.Contains(name, "ubuntu-20") { + c.logger.Info(ctx, "resolveImageFamily: inferred ubuntu20 from name", + v1.LogField("name", image.Metadata.Name)) return "ubuntu20.04", nil } } // Default fallback - use the original ImageID as family // This handles cases where users provide custom family names + c.logger.Info(ctx, "resolveImageFamily: default fallback, returning imageID as family", + v1.LogField("imageID", imageID)) return imageID, nil } @@ -1752,7 +1912,6 @@ func generateCloudInitUserData(publicKey string, firewallRules v1.FirewallRules) script := `#cloud-config packages: - ufw - - iptables-persistent ` // Add SSH key configuration if provided @@ -1784,6 +1943,15 @@ packages: // accessible from the internet by default. commands = append(commands, generateIPTablesCommands()...) + // Install iptables-persistent in runcmd (not packages:) so netfilter-persistent.service + // doesn't race ufw.service on first boot (Launchpad #1987227). + // + // Preseed autosave_v4/v6 before installing. Without this, the postinst with + // DEBIAN_FRONTEND=noninteractive writes empty rules.v4/v6, and the service + // flushes the UFW + DOCKER-USER rules we just applied (Launchpad #1949643). + // With autosave=true, postinst snapshots the currently-applied iptables state. + // removing from here ip tables + // Save the complete iptables state (UFW chains + DOCKER-USER rules) so it // survives instance stop/start cycles. Cloud-init runcmd only executes on // first boot; on subsequent boots netfilter-persistent restores this snapshot, diff --git a/v1/providers/nebius/instance_test.go b/v1/providers/nebius/instance_test.go index 389dea2..58ffe5b 100644 --- a/v1/providers/nebius/instance_test.go +++ b/v1/providers/nebius/instance_test.go @@ -6,6 +6,8 @@ import ( "time" v1 "github.com/brevdev/cloud/v1" + common "github.com/nebius/gosdk/proto/nebius/common/v1" + compute "github.com/nebius/gosdk/proto/nebius/compute/v1" "github.com/stretchr/testify/assert" ) @@ -417,3 +419,82 @@ func TestParseInstanceTypeFormat(t *testing.T) { }) } } + +func makeTestImage(name, family string) *compute.Image { + return &compute.Image{ + Metadata: &common.ResourceMetadata{Name: name}, + Spec: &compute.ImageSpec{ + ImageFamily: family, + CpuArchitecture: compute.ImageSpec_AMD64, + }, + } +} + +func TestBaseImageScore(t *testing.T) { + tests := []struct { + name, family string + want int + }{ + {"ubuntu24.04-cuda13.0.0.2.673", "ubuntu24.04-cuda13.0", imageScoreUbuntu24Cuda13}, + {"ubuntu24.04-cuda12.8.0.0.12", "ubuntu24.04-cuda12", imageScoreUbuntu24Cuda12}, + {"ubuntu22.04-cuda12.3.0.0.5", "ubuntu22.04-cuda12", imageScoreUbuntu22Cuda}, + {"ubuntu24.04-driverless-20260401", "ubuntu24.04-driverless", imageScoreUbuntu24}, + {"ubuntu22.04-20260401", "ubuntu22.04", imageScoreUbuntu22}, + {"ubuntu20.04-20260401", "ubuntu20.04", imageScoreUbuntuGeneric}, + {"worker-node-v-1-33-ubuntu24.04-cuda12.8-20260403", "mk8s-worker-node-v-1-33-ubuntu24.04-cuda12.8", imageScoreWorkerNode}, + {"debian12-20260301", "debian12", imageScoreBaseline}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := baseImageScore(strings.ToLower(tc.name), strings.ToLower(tc.family)) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestRequestMatchBonus(t *testing.T) { + tests := []struct { + desc, name, family, requested string + want int + }{ + {"empty request, no bonus", "ubuntu24.04-cuda13.0", "ubuntu24.04-cuda13.0", "", 0}, + {"exact family match", "ubuntu24.04-cuda13.0.0.2.673", "ubuntu24.04-cuda13.0", "ubuntu24.04-cuda13.0", imageScoreExactMatchBonus}, + {"substring match in name", "worker-node-v-1-33-ubuntu24.04-cuda12.8", "mk8s-worker-node-v-1-33-ubuntu24.04-cuda12.8", "worker-node", imageScoreExactMatchBonus}, + {"ubuntu hint without exact match", "ubuntu22.04-cuda12", "ubuntu22.04-cuda12", "ubuntu24.04", imageScoreUbuntuHintBonus}, + {"non-ubuntu request, non-matching image, no bonus", "debian12", "debian12", "ubuntu", 0}, + } + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := requestMatchBonus(strings.ToLower(tc.name), strings.ToLower(tc.family), strings.ToLower(tc.requested)) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestScoreImage_prioritizesUbuntu24Cuda13OverWorkerNode(t *testing.T) { + ubuntu24Cuda13 := makeTestImage("ubuntu24.04-cuda13.0.0.2.673", "ubuntu24.04-cuda13.0") + workerNode := makeTestImage("worker-node-v-1-33-ubuntu24.04-cuda12.8-20260403", "mk8s-worker-node-v-1-33-ubuntu24.04-cuda12.8") + + // Regression guard for BREV-8794 scenario: default deploy (empty request) + // must prefer ubuntu24-cuda13 over any mk8s worker-node image. + assert.Greater(t, scoreImage(ubuntu24Cuda13, ""), scoreImage(workerNode, "")) + + // Request that happens to contain 'ubuntu24.04' must still prefer + // ubuntu24-cuda13 over worker-node (worker image name contains 'ubuntu24.04' + // as a substring, but the baseline score gap keeps it below). + assert.Greater(t, scoreImage(ubuntu24Cuda13, "ubuntu24.04"), scoreImage(workerNode, "ubuntu24.04")) +} + +func TestScoreImage_exactRequestForWorkerNodeWins(t *testing.T) { + ubuntu24Cuda13 := makeTestImage("ubuntu24.04-cuda13.0.0.2.673", "ubuntu24.04-cuda13.0") + workerNode := makeTestImage("worker-node-v-1-33-ubuntu24.04-cuda12.8-20260403", "mk8s-worker-node-v-1-33-ubuntu24.04-cuda12.8") + + // If a caller explicitly asks for the worker-node family, it must win. + requested := "mk8s-worker-node-v-1-33-ubuntu24.04-cuda12.8" + assert.Greater(t, scoreImage(workerNode, requested), scoreImage(ubuntu24Cuda13, requested)) +} + +func TestScoreImage_nilSpecUsesBaseline(t *testing.T) { + img := &compute.Image{Metadata: &common.ResourceMetadata{Name: "unknown-image"}} + assert.Equal(t, imageScoreBaseline, scoreImage(img, "")) +}