Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion api/v1alpha1/managedcloudprofile.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,11 @@ type MachineImageUpdate struct {
// Provider contains configuration for a provider for machine images.
Provider MachineImageUpdateProvider `json:"provider"`

// ImagesName is the name of the image to maintain automatically
// ImageName is the name of the image to maintain automatically
ImageName string `json:"imageName"`
// Paused disables automatic updates for this image and keeps the existing CloudProfile machine images.
// +optional
Paused bool `json:"paused,omitempty"`
}

type GarbageCollectionConfig struct {
Expand Down Expand Up @@ -153,6 +156,9 @@ type GlanceSource struct {
// KeepLatest limits results to the newest N versions.
// +optional
KeepLatest int `json:"keepLatest,omitempty"`
// VersionOffset controls how many newest GardenLinux versions to skip before applying KeepLatest.
// +optional
VersionOffset int `json:"versionOffset,omitempty"`
// Parallel bounds how many regions are queried concurrently.
// +optional
Parallel int64 `json:"parallel,omitempty"`
Expand Down
28 changes: 23 additions & 5 deletions cloudprofilesync/ossync/source/glance/os_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ const (
DefaultGlanceKeepLatest = 3
// defaultGlanceParallel is the region query concurrency used when GlanceParams.Parallel
// is not set.
defaultGlanceParallel = 8
usiVariantMarker = "_usi"
defaultGlanceParallel = 8
defaultGlanceVersionOffset = 0
usiVariantMarker = "_usi"
)

type Result[T any] struct {
Expand All @@ -55,7 +56,8 @@ type GlanceParams struct {
KeepLatest int

// Parallel bounds how many regions are queried concurrently.
Parallel int64
Parallel int64
VersionOffset int

// ProjectName / ProjectDomainName scope the token.
ProjectName string
Expand All @@ -73,6 +75,7 @@ type Glance struct {
params GlanceParams
namePrefix string
keepLatest int
offset int
sema *semaphore.Weighted
authenticate func(ctx context.Context, authURL string, opts gophercloud.AuthOptions) (*gophercloud.ProviderClient, error)
listImages func(ctx context.Context, provider *gophercloud.ProviderClient, region string) ([]images.Image, error)
Expand Down Expand Up @@ -102,11 +105,17 @@ func NewGlance(params GlanceParams, log logr.Logger) (*Glance, error) {
parallel = defaultGlanceParallel
}

offset := params.VersionOffset
if offset <= 0 {
offset = defaultGlanceVersionOffset
}

return &Glance{
log: log,
params: params,
namePrefix: prefix,
keepLatest: keepLatest,
offset: offset,
sema: semaphore.NewWeighted(parallel),
authenticate: defaultAuthenticate,
listImages: defaultListImages,
Expand Down Expand Up @@ -209,8 +218,17 @@ func (g *Glance) GetVersions(ctx context.Context) ([]ossync.SourceImage, error)
slices.SortFunc(versions, func(a, b ossync.SourceImage) int {
return compareSemverDesc(a.Version, b.Version)
})
if g.keepLatest > 0 && len(versions) > g.keepLatest {
versions = versions[:g.keepLatest]
if g.keepLatest > 0 || g.offset > 0 {
// Skip the newest `offset` versions, then keep the next `keepLatest`.
// Clamp both bounds so an offset that runs past the available versions
// never slices out of range. A keepLatest of 0 keeps everything after
// the offset.
Comment thread
yahor-kurachkin marked this conversation as resolved.
lo := min(g.offset, len(versions))
hi := len(versions)
if g.keepLatest > 0 {
hi = min(lo+g.keepLatest, len(versions))
}
versions = versions[lo:hi]
}

supported := gardenerv1beta1.ClassificationSupported
Expand Down
110 changes: 110 additions & 0 deletions cloudprofilesync/ossync/source/glance/os_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ package glance

import (
"context"
"slices"
"testing"
"time"

"github.com/go-logr/logr"
"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/image/v2/images"

"github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync/ossync"
)

const (
Expand Down Expand Up @@ -167,6 +170,113 @@ func TestGetVersionsStampsExpirationOnDeprecated(t *testing.T) {
}
}

// imageName builds a standard gardenlinux Glance image name for a version.
func imageName(version string) string {
return defaultGlanceNamePrefix + version + "-deadbeef"
}

// versionStrings extracts the ordered version strings from the result.
func versionStrings(vs []ossync.SourceImage) []string {
out := make([]string, len(vs))
for i, v := range vs {
out[i] = v.Version
}
return out
}

// VersionOffset selects a slice of the newest-first versions, skipping the first
// `offset` and keeping the next `keepLatest`.
func TestGetVersionsVersionOffset(t *testing.T) {
// Five versions; GetVersions sorts them newest-first: 5,4,3,2,1.
allVersions := []string{"1.0.0", "2.0.0", "3.0.0", "4.0.0", "5.0.0"}
imgs := make([]images.Image, 0, len(allVersions))
for _, v := range allVersions {
imgs = append(imgs, images.Image{ID: v + "-uuid", Name: imageName(v)})
}

tests := []struct {
name string
keepLatest int
offset int
want []string
}{
{
name: "offset 0 keeps newest N",
keepLatest: 3,
offset: 0,
want: []string{"5.0.0", "4.0.0", "3.0.0"},
},
{
name: "offset skips the newest versions",
keepLatest: 2,
offset: 2,
want: []string{"3.0.0", "2.0.0"},
},
{
name: "offset reaches the oldest window",
keepLatest: 2,
offset: 3,
want: []string{"2.0.0", "1.0.0"},
},
{
name: "offset applies even when keepLatest covers all versions",
keepLatest: 5,
offset: 2,
want: []string{"3.0.0", "2.0.0", "1.0.0"},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
g := newTestGlance(t, GlanceParams{
Regions: []string{testRegion},
KeepLatest: tc.keepLatest,
VersionOffset: tc.offset,
}, map[string][]images.Image{testRegion: imgs})

versions, err := g.GetVersions(context.Background())
if err != nil {
t.Fatalf("GetVersions: %v", err)
}
got := versionStrings(versions)
if !slices.Equal(got, tc.want) {
t.Errorf("versions = %v, want %v", got, tc.want)
}
})
}
}

// A VersionOffset whose window runs past the available versions must not panic
// and must not slice out of bounds.
func TestGetVersionsVersionOffsetOutOfBounds(t *testing.T) {
allVersions := []string{"1.0.0", "2.0.0", "3.0.0", "4.0.0"}
imgs := make([]images.Image, 0, len(allVersions))
for _, v := range allVersions {
imgs = append(imgs, images.Image{ID: v + "-uuid", Name: imageName(v)})
}

// len=4, keepLatest=3 satisfies the len>keepLatest guard, but offset=2 makes
// the upper bound offset+keepLatest = 5, which is past len(versions).
g := newTestGlance(t, GlanceParams{
Regions: []string{testRegion},
KeepLatest: 3,
VersionOffset: 2,
}, map[string][]images.Image{testRegion: imgs})

versions, err := g.GetVersions(context.Background())
if err != nil {
t.Fatalf("GetVersions: %v", err)
}
// Expect the window to be clamped to the available versions. Versions sorted
// newest-first are 4,3,2,1; skipping the 2 newest leaves 2.0.0, 1.0.0. The
// upper bound must clamp instead of panicking.
got := versionStrings(versions)
want := []string{"2.0.0", "1.0.0"}
if !slices.Equal(got, want) {
t.Errorf("versions = %v, want %v (window must clamp to available range)", got, want)
}
}

// newTestGlance builds a Glance source with auth/list stubbed so no real OpenStack is contacted.
func newTestGlance(t *testing.T, params GlanceParams, imgsByRegion map[string][]images.Image) *Glance {
t.Helper()
Expand Down
23 changes: 23 additions & 0 deletions controllers/cloud_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"errors"
"fmt"
"slices"

gardenerv1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1"
"github.com/go-logr/logr"
Expand Down Expand Up @@ -42,9 +43,22 @@ func (r *Reconciler) reconcileCloudProfile(ctx context.Context, log logr.Logger,
return err
}
storedExpirations := collectExpirationDates(cloudProfile.Spec.MachineImages)
storedImages := collectMachineImages(cloudProfile.Spec.MachineImages)
cloudProfile.Spec = CloudProfileSpecToGardener(&mcp.Spec.CloudProfile)
errs := make([]error, 0)
for _, updates := range mcp.Spec.MachineImageUpdates {
if updates.Paused {
log.V(1).Info("machine image update paused, keeping existing images", "cloudProfile", cloudProfile.Name, "imageName", updates.ImageName)
if img, ok := storedImages[updates.ImageName]; ok {
// Replace any entry the MCP spec contributed for this image so the
// stored (previously reconciled) versions are kept without duplicating.
cloudProfile.Spec.MachineImages = slices.DeleteFunc(cloudProfile.Spec.MachineImages, func(m gardenerv1beta1.MachineImage) bool {
return m.Name == updates.ImageName
})
cloudProfile.Spec.MachineImages = append(cloudProfile.Spec.MachineImages, img)
}
continue
}
log.V(1).Info("updating machine images", "cloudProfile", cloudProfile.Name)
if updateErr := r.updateMachineImages(ctx, log, updates, &cloudProfile.Spec); updateErr != nil {
errs = append(errs, updateErr)
Expand Down Expand Up @@ -127,6 +141,7 @@ func (r *Reconciler) updateMachineImages(ctx context.Context, log logr.Logger, u
Regions: update.Source.Glance.Regions,
NamePrefix: update.Source.Glance.NamePrefix,
KeepLatest: update.Source.Glance.KeepLatest,
VersionOffset: update.Source.Glance.VersionOffset,
Parallel: update.Source.Glance.Parallel,
ProjectName: update.Source.Glance.ProjectName,
ProjectDomainName: update.Source.Glance.ProjectDomainName,
Expand Down Expand Up @@ -238,6 +253,14 @@ func expirationDateKey(imageName, version string) string {
return imageName + "/" + version
}

func collectMachineImages(images []gardenerv1beta1.MachineImage) map[string]gardenerv1beta1.MachineImage {
out := make(map[string]gardenerv1beta1.MachineImage, len(images))
for _, img := range images {
out[img.Name] = *img.DeepCopy()
}
return out
}

func collectExpirationDates(images []gardenerv1beta1.MachineImage) map[string]*metav1.Time {
out := make(map[string]*metav1.Time)
for _, img := range images {
Expand Down
51 changes: 51 additions & 0 deletions controllers/managedcloudprofile_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,57 @@ var _ = Describe("The ManagedCloudProfile reconciler", func() {
Expect(k8sClient.Delete(ctx, cloudProfile)).To(Succeed())
})

It("keeps existing images when the update is paused", func(ctx SpecContext) {
keptVersion := "4242.0.0"

var mcp v1alpha1.ManagedCloudProfile
mcp.Name = "test-paused"
mcp.Spec.CloudProfile = baseCloudProfileSpec(
Comment thread
yahor-kurachkin marked this conversation as resolved.
gardenerv1beta1.MachineImage{
Name: "the-image",
Versions: []gardenerv1beta1.MachineImageVersion{
{Version: keptVersion, Architectures: []string{"amd64"}},
},
},
)
mcp.Spec.MachineImageUpdates = []v1alpha1.MachineImageUpdate{
{
Source: v1alpha1.MachineImageUpdateSource{
OCI: &v1alpha1.OCI{
Registry: registryAddr,
Repository: orasRepoName("repo"),
Insecure: true,
},
},
Provider: v1alpha1.MachineImageUpdateProvider{
IroncoreMetal: &v1alpha1.MachineImagesUpdateProviderIroncoreMetal{
Registry: registryAddr,
Repository: orasRepoName("repo"),
},
},
ImageName: "the-image",
Paused: true,
},
}
Expect(k8sClient.Create(ctx, &mcp)).To(Succeed())

expectReconcileStatus(ctx, &mcp, v1alpha1.SucceededReconcileStatus)
expectAppliedCondition(&mcp, metav1.ConditionTrue)

cloudProfile := getCloudProfile(ctx, mcp.Name)
mi := cloudProfile.Spec.MachineImages
Expect(mi).To(HaveLen(1))
Expect(mi[0].Name).To(Equal("the-image"))
// The updater was skipped, so the pre-existing version is kept and the OCI
// source versions (1.0.0, 1.0.1+abc) were never fetched.
vers := mi[0].Versions
Expect(vers).To(HaveLen(1))
Expect(vers[0].Version).To(Equal(keptVersion))

Expect(k8sClient.Delete(ctx, &mcp)).To(Succeed())
Expect(k8sClient.Delete(ctx, cloudProfile)).To(Succeed())
})

It("fetches a secret for the OCI source", func(ctx SpecContext) {
var secret corev1.Secret
secret.Name = "oci"
Expand Down
12 changes: 10 additions & 2 deletions crd/cloudprofilesync.cobaltcore.dev_managedcloudprofiles.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.21.0
controller-gen.kubebuilder.io/version: v0.22.0
name: managedcloudprofiles.cloudprofilesync.cobaltcore.dev
spec:
group: cloudprofilesync.cobaltcore.dev
Expand Down Expand Up @@ -669,9 +669,13 @@ spec:
items:
properties:
imageName:
description: ImagesName is the name of the image to maintain
description: ImageName is the name of the image to maintain
automatically
type: string
paused:
description: Paused disables automatic updates for this image
and keeps the existing CloudProfile machine images.
type: boolean
provider:
description: Provider contains configuration for a provider
for machine images.
Expand Down Expand Up @@ -760,6 +764,10 @@ spec:
username:
description: Username for authentication.
type: string
versionOffset:
description: VersionOffset controls how many newest
GardenLinux versions to skip before applying KeepLatest.
type: integer
required:
- authURLFormat
- passwordSecret
Expand Down