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
15 changes: 0 additions & 15 deletions .claude/settings.local.json

This file was deleted.

2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
build/

.idea

.claude
2 changes: 1 addition & 1 deletion .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ linters:
# for github.com/sapcc/vpa_butler
- k8s.io/client-go
toolchain-forbidden: true
go-version-pattern: 1\.\d+(\.0)?$
go-version-pattern: 1\.\d+(\.\d+)?$ # manually edited, as default rule does not allow go version with patch, but some deps require e.g. go 1.26.2
gosec:
excludes:
# gosec wants us to set a short ReadHeaderTimeout to avoid Slowloris attacks, but doing so would expose us to Keep-Alive race conditions (see https://iximiuz.com/en/posts/reverse-proxy-http-keep-alive-and-502s/
Expand Down
3 changes: 3 additions & 0 deletions .typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

[default.extend-words]

[default]
extend-ignore-identifiers-re = ["ANDed"]

[files]
extend-exclude = [
"go.mod",
Expand Down
121 changes: 0 additions & 121 deletions LICENSES/CC0-1.0.txt

This file was deleted.

51 changes: 38 additions & 13 deletions cloudprofilesync/imageupdater.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,33 @@ import (
func filterImages(log logr.Logger, versions []SourceImage) []SourceImage {
filtered := make([]SourceImage, 0, len(versions))
for _, version := range versions {
versionStr := version.effectiveVersion()
_, err := semver.Parse(versionStr)
if err != nil {
log.V(1).Info("skipping invalid version", "version", versionStr)
if len(version.Architectures) == 0 {
log.V(1).Info("skipping version with no architectures", "version", version.Version)
continue
}
if len(version.Architectures) == 0 {
log.V(1).Info("skipping version with no architectures", "version", versionStr)

validLegacyTag := false
if _, err := semver.Parse(version.Version); err == nil {
validLegacyTag = true
}

validCleanVersion := false
if version.CleanVersion != "" {
// Found that we can have "1921.0" in annotations. It will be transformed to "1921.0.0"
if parsed, err := semver.ParseTolerant(version.CleanVersion); err == nil {
validCleanVersion = true
version.CleanVersion = parsed.String()
} else {
log.V(1).Info("ignoring invalid clean version annotation", "tag", version.Version, "cleanVersion", version.CleanVersion)
version.CleanVersion = ""
}
}
Comment thread
Copilot marked this conversation as resolved.

if !validLegacyTag && !validCleanVersion {
log.V(1).Info("skipping invalid version (both tag and clean version are bad)", "tag", version.Version)
continue
}

filtered = append(filtered, version)
}
return filtered
Expand Down Expand Up @@ -73,13 +90,21 @@ func (iu *ImageUpdater) Update(ctx context.Context, cpSpec *gardenerv1beta1.Clou
if idx, exists := existingVersions[sourceImage.Version]; exists {
image.Versions[idx].Architectures = sourceImage.Architectures
} else {
image.Versions = append(image.Versions, gardenerv1beta1.MachineImageVersion{
ExpirableVersion: gardenerv1beta1.ExpirableVersion{
Version: sourceImage.Version,
},
Architectures: sourceImage.Architectures,
})
existingVersions[sourceImage.Version] = len(image.Versions) - 1
// Moving this check to filterImages() would break the core architectural goal of GEP-33
// as it intentionally decouples the OCI registry tag from the semantic OS version
// In the future, teams might push images with tags like build-0849f313 or 2026-06-release
// As long as the CleanVersion annotation is a valid SemVer (e.g., 2262.0.0), the extension needs to route to it
if _, err = semver.Parse(sourceImage.Version); err != nil {
iu.Log.V(1).Info("skipping legacy entry in spec.machineImages because original tag is not valid semver", "version", sourceImage.Version)
} else {
image.Versions = append(image.Versions, gardenerv1beta1.MachineImageVersion{
ExpirableVersion: gardenerv1beta1.ExpirableVersion{
Version: sourceImage.Version,
},
Architectures: sourceImage.Architectures,
})
existingVersions[sourceImage.Version] = len(image.Versions) - 1
}
}

// When capabilities are enabled, also write the clean version entry.
Expand Down
133 changes: 132 additions & 1 deletion cloudprofilesync/imageupdater_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,108 @@ import (
"github.com/cobaltcore-dev/cloud-profile-sync/cloudprofilesync"
)

var _ = Describe("ImageUpdater", func() {
var _ = Describe("filterImages", func() {
// helper: run Update and return the versions written to spec.machineImages
versions := func(ctx SpecContext, images []cloudprofilesync.SourceImage) []gardencorev1beta1.MachineImageVersion {
mockSource.images = images
updater := cloudprofilesync.ImageUpdater{
Log: GinkgoLogr,
Source: &mockSource,
ImageName: "test",
EnableCapabilities: true,
}
var cpSpec gardencorev1beta1.CloudProfileSpec
Expect(updater.Update(ctx, &cpSpec)).To(Succeed())
if len(cpSpec.MachineImages) == 0 {
return nil
}
return cpSpec.MachineImages[0].Versions
}

It("invalid tag + no clean version: drops the image entirely", func(ctx SpecContext) {
result := versions(ctx, []cloudprofilesync.SourceImage{
{Version: "not-a-version", Architectures: []string{"amd64"}},
})
Expect(result).To(BeEmpty())
})

It("invalid tag + invalid clean version: drops the image entirely", func(ctx SpecContext) {
result := versions(ctx, []cloudprofilesync.SourceImage{
{Version: "not-a-version", CleanVersion: "also-not-a-version", Architectures: []string{"amd64"}},
})
Expect(result).To(BeEmpty())
})

It("invalid tag + valid clean version: NEW format only (no legacy entry)", func(ctx SpecContext) {
result := versions(ctx, []cloudprofilesync.SourceImage{
{
Version: "1877.9.2.0-metal-sci-pxe-amd64",
CleanVersion: "1877.9.2",
Architectures: []string{"amd64"},
Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_pxe"}},
},
})
Expect(result).To(HaveLen(1))
Expect(result[0].Version).To(Equal("1877.9.2"))
})

It("valid tag + valid clean version: BOTH formats", func(ctx SpecContext) {
result := versions(ctx, []cloudprofilesync.SourceImage{
{
Version: "2254.0.0-baremetal-sci-usi-amd64",
CleanVersion: "2254.0.0",
Architectures: []string{"amd64"},
Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}},
},
})
Expect(result).To(HaveLen(2))
versionStrings := []string{result[0].Version, result[1].Version}
Expect(versionStrings).To(ContainElements("2254.0.0-baremetal-sci-usi-amd64", "2254.0.0"))
})

It("valid tag + no clean version: OLD format only", func(ctx SpecContext) {
result := versions(ctx, []cloudprofilesync.SourceImage{
{Version: "1921.0.0", Architectures: []string{"amd64"}},
})
Expect(result).To(HaveLen(1))
Expect(result[0].Version).To(Equal("1921.0.0"))
})

It("valid tag + invalid clean version: BOTH formats with clean version normalized", func(ctx SpecContext) {
result := versions(ctx, []cloudprofilesync.SourceImage{
{
Version: "1921.0.0-metal-sci-usi-amd64",
CleanVersion: "1921.0",
Architectures: []string{"amd64"},
Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_usi"}},
},
})
Expect(result).To(HaveLen(2))
versionStrings := []string{result[0].Version, result[1].Version}
Expect(versionStrings).To(ContainElements("1921.0.0-metal-sci-usi-amd64", "1921.0.0"))
})

It("valid tag + unparsable clean version: does not write clean version entry", func(ctx SpecContext) {
result := versions(ctx, []cloudprofilesync.SourceImage{
{
Version: "1921.0.0-metal-sci-usi-amd64",
CleanVersion: "not-a-version",
Architectures: []string{"amd64"},
},
})
Expect(result).To(HaveLen(1))
Expect(result[0].Version).To(Equal("1921.0.0-metal-sci-usi-amd64"))
})

It("no architectures: drops the image entirely", func(ctx SpecContext) {
result := versions(ctx, []cloudprofilesync.SourceImage{
{Version: "1.0.0"},
})
Expect(result).To(BeEmpty())
})
})

var _ = Describe("ImageUpdater", func() {
Describe("flag OFF (default behavior)", func() {
It("adds an image from the source to the CloudProfile spec", func(ctx SpecContext) {
mockSource.images = []cloudprofilesync.SourceImage{{Version: "1.0.0", Architectures: []string{"amd64"}}}
Expand Down Expand Up @@ -172,6 +272,37 @@ var _ = Describe("ImageUpdater", func() {
Expect(cpSpec.MachineImages[0].Versions).To(HaveLen(2))
})

It("skips legacy spec entry for non-semver raw tag but still passes image to provider", func(ctx SpecContext) {
mockSource.images = []cloudprofilesync.SourceImage{
{
Version: "1877.9.2.0-metal-sci-pxe-amd64-1877-9-2-6bb2b442",
CleanVersion: "1877.9.2",
Architectures: []string{"amd64"},
Capabilities: gardencorev1beta1.Capabilities{"architecture": {"amd64"}, "feature": {"sci", "_pxe"}},
},
}
updater := cloudprofilesync.ImageUpdater{
Log: GinkgoLogr,
Source: &mockSource,
ImageName: "test",
EnableCapabilities: true,
Provider: &MockProvider{},
}
var cpSpec gardencorev1beta1.CloudProfileSpec
Expect(updater.Update(ctx, &cpSpec)).To(Succeed())

// Non-semver raw tag must not appear in spec.machineImages — Gardener would reject it.
// Only the clean version entry should be written.
Expect(cpSpec.MachineImages[0].Versions).To(HaveLen(1))
Expect(cpSpec.MachineImages[0].Versions[0].Version).To(Equal("1877.9.2"))

// The raw tag must still reach the provider (capabilityFlavors).
var fromProvider []cloudprofilesync.SourceImage
Expect(json.Unmarshal(cpSpec.ProviderConfig.Raw, &fromProvider)).To(Succeed())
Expect(fromProvider).To(HaveLen(1))
Expect(fromProvider[0].Version).To(Equal("1877.9.2.0-metal-sci-pxe-amd64-1877-9-2-6bb2b442"))
})

It("writes only full tag when CleanVersion is absent", func(ctx SpecContext) {
mockSource.images = []cloudprofilesync.SourceImage{
{Version: "1877.0.0", Architectures: []string{"amd64"}},
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/cobaltcore-dev/cloud-profile-sync

go 1.26
go 1.26.2

require (
github.com/blang/semver/v4 v4.0.0
Expand Down
Loading