diff --git a/cmd/release-controller-api/http.go b/cmd/release-controller-api/http.go index 0fa0d3402..62fc7c307 100644 --- a/cmd/release-controller-api/http.go +++ b/cmd/release-controller-api/http.go @@ -107,7 +107,7 @@ func (c *Controller) findReleaseStreamTags(includeStableTags bool, tags ...strin // TODO: should be refactored to be unsortedSemanticReleaseTags releaseTags := releasecontroller.SortedReleaseTags(r) if includeStableTags { - if version, err := releasecontroller.SemverParseTolerant(r.Config.Name); err == nil || r.Config.As == releasecontroller.ReleaseConfigModeStable { + if version, err := releasecontroller.SemverParseTolerant(r.Config.Name); err == nil || r.Config.As == releasecontroller.ReleaseConfigModeStable || r.Config.As == releasecontroller.ReleaseConfigModeLayered { stable.Releases = append(stable.Releases, releasecontroller.StableRelease{ Release: r, Version: version, @@ -1692,7 +1692,7 @@ func (c *Controller) tableLink(config *releasecontroller.ReleaseConfig, tag imag } if strings.Contains(tag.Name, "nightly") && c.doesInconsistencyExist(tag.Name) { return fmt.Sprintf(`
%s
%s/%s changes`, r.Release.Source.Namespace, r.Release.Source.Name))
}
@@ -1836,7 +1840,7 @@ func (c *Controller) httpReleases(w http.ResponseWriter, req *http.Request) {
Tags: releasecontroller.SortedReleaseTags(r),
}
var delays []string
- if r.Config.As != releasecontroller.ReleaseConfigModeStable && len(s.Tags) > 0 {
+ if r.Config.As != releasecontroller.ReleaseConfigModeStable && r.Config.As != releasecontroller.ReleaseConfigModeLayered && len(s.Tags) > 0 {
if ok, _, queueAfter := releasecontroller.IsReleaseDelayedForInterval(r, s.Tags[0]); ok {
delays = append(delays, fmt.Sprintf("waiting for %s", queueAfter.Truncate(time.Second)))
}
@@ -1847,7 +1851,7 @@ func (c *Controller) httpReleases(w http.ResponseWriter, req *http.Request) {
if len(delays) > 0 {
s.Delayed = &ReleaseDelay{Message: fmt.Sprintf("Next release may not start: %s", strings.Join(delays, ", "))}
}
- if r.Config.As != releasecontroller.ReleaseConfigModeStable {
+ if r.Config.As != releasecontroller.ReleaseConfigModeStable && r.Config.As != releasecontroller.ReleaseConfigModeLayered {
s.Upgrades = calculateReleaseUpgrades(r, s.Tags, c.graph, false)
}
page.Streams = append(page.Streams, s)
@@ -2204,7 +2208,7 @@ func (c *Controller) httpReleaseStreamTable(w http.ResponseWriter, req *http.Req
"publishDescription": func(r *ReleaseStream) string {
streamMessage := generateStreamMessage(r)
if len(streamMessage) > 0 {
- if r.Release.Config.As == releasecontroller.ReleaseConfigModeStable {
+ if r.Release.Config.As == releasecontroller.ReleaseConfigModeStable || r.Release.Config.As == releasecontroller.ReleaseConfigModeLayered {
searchFunctionPrefix := removeSpecialCharacters(r.Release.Config.Name)
searchFunction := fmt.Sprintf("searchTable_%s('%s')", searchFunctionPrefix, searchFunctionPrefix)
return fmt.Sprintf("%s
%s/%s changes`, r.Release.Source.Namespace, r.Release.Source.Name))
}
@@ -2295,7 +2303,7 @@ func (c *Controller) httpReleaseStreamTable(w http.ResponseWriter, req *http.Req
Tags: releasecontroller.SortedReleaseTags(r),
}
var delays []string
- if r.Config.As != releasecontroller.ReleaseConfigModeStable && len(s.Tags) > 0 {
+ if r.Config.As != releasecontroller.ReleaseConfigModeStable && r.Config.As != releasecontroller.ReleaseConfigModeLayered && len(s.Tags) > 0 {
if ok, _, queueAfter := releasecontroller.IsReleaseDelayedForInterval(r, s.Tags[0]); ok {
delays = append(delays, fmt.Sprintf("waiting for %s", queueAfter.Truncate(time.Second)))
}
@@ -2306,7 +2314,7 @@ func (c *Controller) httpReleaseStreamTable(w http.ResponseWriter, req *http.Req
if len(delays) > 0 {
s.Delayed = &ReleaseDelay{Message: fmt.Sprintf("Next release may not start: %s", strings.Join(delays, ", "))}
}
- if r.Config.As != releasecontroller.ReleaseConfigModeStable {
+ if r.Config.As != releasecontroller.ReleaseConfigModeStable && r.Config.As != releasecontroller.ReleaseConfigModeLayered {
s.Upgrades = calculateReleaseUpgrades(r, s.Tags, c.graph, false)
}
page.TargetStream = s
@@ -2367,6 +2375,10 @@ func (c *Controller) httpDashboardOverview(w http.ResponseWriter, req *http.Requ
if len(streamMessage) == 0 {
out = append(out, `stable tags`)
}
+ case releasecontroller.ReleaseConfigModeLayered:
+ if len(streamMessage) == 0 {
+ out = append(out, `layered releases`)
+ }
default:
out = append(out, fmt.Sprintf(`updated when %s/%s changes`, r.Release.Source.Namespace, r.Release.Source.Name))
}
@@ -2434,7 +2446,7 @@ func (c *Controller) httpDashboardOverview(w http.ResponseWriter, req *http.Requ
Tags: releasecontroller.SortedReleaseTags(r),
}
var delays []string
- if r.Config.As != releasecontroller.ReleaseConfigModeStable && len(s.Tags) > 0 {
+ if r.Config.As != releasecontroller.ReleaseConfigModeStable && r.Config.As != releasecontroller.ReleaseConfigModeLayered && len(s.Tags) > 0 {
if ok, _, queueAfter := releasecontroller.IsReleaseDelayedForInterval(r, s.Tags[0]); ok {
delays = append(delays, fmt.Sprintf("waiting for %s", queueAfter.Truncate(time.Second)))
}
@@ -2453,7 +2465,7 @@ func (c *Controller) httpDashboardOverview(w http.ResponseWriter, req *http.Requ
if len(delays) > 0 {
s.Delayed = &ReleaseDelay{Message: fmt.Sprintf("Next release may not start: %s", strings.Join(delays, ", "))}
}
- if r.Config.As != releasecontroller.ReleaseConfigModeStable {
+ if r.Config.As != releasecontroller.ReleaseConfigModeStable && r.Config.As != releasecontroller.ReleaseConfigModeLayered {
s.Upgrades = calculateReleaseUpgrades(r, s.Tags, c.graph, true)
}
page.Streams = append(page.Streams, s)
diff --git a/cmd/release-controller-api/http_candidate.go b/cmd/release-controller-api/http_candidate.go
index cad5bd21d..a3e7351ae 100644
--- a/cmd/release-controller-api/http_candidate.go
+++ b/cmd/release-controller-api/http_candidate.go
@@ -303,7 +303,7 @@ func (c *Controller) findReleaseByName(includeStableTags bool, names ...string)
}
if includeStableTags {
- if version, err := releasecontroller.SemverParseTolerant(r.Config.Name); err == nil || r.Config.As == releasecontroller.ReleaseConfigModeStable {
+ if version, err := releasecontroller.SemverParseTolerant(r.Config.Name); err == nil || r.Config.As == releasecontroller.ReleaseConfigModeStable || r.Config.As == releasecontroller.ReleaseConfigModeLayered {
stable.Releases = append(stable.Releases, releasecontroller.StableRelease{
Release: r,
Version: version,
diff --git a/cmd/release-controller-api/http_helper.go b/cmd/release-controller-api/http_helper.go
index 45eada6a3..200d31c8a 100644
--- a/cmd/release-controller-api/http_helper.go
+++ b/cmd/release-controller-api/http_helper.go
@@ -717,7 +717,7 @@ func renderAlerts(release ReleaseStream) string {
func releaseJoin(streams []ReleaseStream, showStableReleases bool) string {
releases := []string{}
for _, s := range streams {
- if !showStableReleases && s.Release.Config.As == releasecontroller.ReleaseConfigModeStable {
+ if !showStableReleases && (s.Release.Config.As == releasecontroller.ReleaseConfigModeStable || s.Release.Config.As == releasecontroller.ReleaseConfigModeLayered) {
continue
}
releases = append(releases, fmt.Sprintf("%s", template.HTMLEscapeString(s.Release.Config.Name), template.HTMLEscapeString(s.Release.Config.Name)))
@@ -939,7 +939,7 @@ func (r preferredReleases) Less(i, j int) bool {
if a.Release.Config.Hide && !b.Release.Config.Hide {
return false
}
- aStable, bStable := a.Release.Config.As == releasecontroller.ReleaseConfigModeStable, b.Release.Config.As == releasecontroller.ReleaseConfigModeStable
+ aStable, bStable := (a.Release.Config.As == releasecontroller.ReleaseConfigModeStable || a.Release.Config.As == releasecontroller.ReleaseConfigModeLayered), (b.Release.Config.As == releasecontroller.ReleaseConfigModeStable || b.Release.Config.As == releasecontroller.ReleaseConfigModeLayered)
if aStable && !bStable {
return true
}
@@ -1104,7 +1104,7 @@ func isStaleStatusTag(tag imagev1.NamedTagEventList, target *imagev1.ImageStream
func pruneEndOfLifeTags(page *ReleasePage, endOfLifePrefixes sets.Set[string]) {
for i := range page.Streams {
stream := &page.Streams[i]
- if stream.Release.Config.As == releasecontroller.ReleaseConfigModeStable {
+ if stream.Release.Config.As == releasecontroller.ReleaseConfigModeStable || stream.Release.Config.As == releasecontroller.ReleaseConfigModeLayered {
var tags []*imagev1.TagReference
for _, tag := range stream.Tags {
if version, err := releasecontroller.SemverParseTolerant(tag.Name); err == nil {
diff --git a/cmd/release-controller/layered_mode_test.go b/cmd/release-controller/layered_mode_test.go
new file mode 100644
index 000000000..6e55aa765
--- /dev/null
+++ b/cmd/release-controller/layered_mode_test.go
@@ -0,0 +1,70 @@
+package main
+
+import (
+ "testing"
+
+ releasecontroller "github.com/openshift/release-controller/pkg/release-controller"
+)
+
+func TestLayeredModeConfiguration(t *testing.T) {
+ testCases := []struct {
+ name string
+ configJSON string
+ expectError bool
+ errorMsg string
+ }{
+ {
+ name: "Valid layered mode without 'to' field",
+ configJSON: `{"name": "test-layered", "as": "Layered"}`,
+ expectError: false,
+ },
+ {
+ name: "Layered mode with optional 'to' field is allowed",
+ configJSON: `{"name": "test-layered", "as": "Layered", "to": "releases"}`,
+ expectError: false,
+ },
+ {
+ name: "Stable mode without 'to' field is valid",
+ configJSON: `{"name": "test-stable", "as": "Stable"}`,
+ expectError: false,
+ },
+ {
+ name: "Integration mode without 'to' field should error",
+ configJSON: `{"name": "test-integration"}`,
+ expectError: true,
+ errorMsg: "release must specify 'to' unless 'as' is 'Stable' or 'Layered'",
+ },
+ {
+ name: "Integration mode with 'to' field is valid",
+ configJSON: `{"name": "test-integration", "to": "releases"}`,
+ expectError: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ config, err := releasecontroller.ParseReleaseConfig(tc.configJSON, nil)
+
+ if tc.expectError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ return
+ }
+ if tc.errorMsg != "" && err.Error() != tc.errorMsg {
+ t.Errorf("Expected error message %q, got %q", tc.errorMsg, err.Error())
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Expected no error but got: %v", err)
+ return
+ }
+
+ if config == nil {
+ t.Errorf("Expected valid config but got nil")
+ return
+ }
+ })
+ }
+}
diff --git a/cmd/release-controller/sync.go b/cmd/release-controller/sync.go
index d7e568d13..3daa521f5 100644
--- a/cmd/release-controller/sync.go
+++ b/cmd/release-controller/sync.go
@@ -147,7 +147,8 @@ func calculateSyncActions(release *releasecontroller.Release, now time.Time) (ad
)
target := release.Target
- shouldAdopt := release.Config.As == releasecontroller.ReleaseConfigModeStable
+ shouldAdopt := release.Config.As == releasecontroller.ReleaseConfigModeStable ||
+ release.Config.As == releasecontroller.ReleaseConfigModeLayered
tags := make([]*imagev1.TagReference, 0, len(target.Spec.Tags))
for i := range target.Spec.Tags {
@@ -170,7 +171,9 @@ func calculateSyncActions(release *releasecontroller.Release, now time.Time) (ad
continue
}
// check annotations when using the target as tag source
- if release.Config.As != releasecontroller.ReleaseConfigModeStable && tag.Annotations[releasecontroller.ReleaseAnnotationSource] != fmt.Sprintf("%s/%s", release.Source.Namespace, release.Source.Name) {
+ if release.Config.As != releasecontroller.ReleaseConfigModeStable &&
+ release.Config.As != releasecontroller.ReleaseConfigModeLayered &&
+ tag.Annotations[releasecontroller.ReleaseAnnotationSource] != fmt.Sprintf("%s/%s", release.Source.Namespace, release.Source.Name) {
continue
}
// if the name has changed, consider the tag abandoned (admin is responsible for cleaning it up)
@@ -237,7 +240,7 @@ func calculateSyncActions(release *releasecontroller.Release, now time.Time) (ad
}
switch release.Config.As {
- case releasecontroller.ReleaseConfigModeStable:
+ case releasecontroller.ReleaseConfigModeStable, releasecontroller.ReleaseConfigModeLayered:
hasNewImages = false
inputImageHash = ""
removeTags = nil
@@ -393,6 +396,32 @@ func (c *Controller) syncPending(release *releasecontroller.Release, pendingTags
}
}
return nil
+
+ case releasecontroller.ReleaseConfigModeLayered:
+ // New layered mode - skip payload building, go directly to ready
+ for _, tag := range pendingTags {
+ if len(tag.Annotations[releasecontroller.ReleaseAnnotationImageHash]) == 0 {
+ // Set a hash based on the single input image
+ hash := fmt.Sprintf("layered-%s-%d", tag.Name, *tag.Generation)
+ if err := c.setReleaseAnnotation(release, tag.Annotations[releasecontroller.ReleaseAnnotationPhase],
+ map[string]string{releasecontroller.ReleaseAnnotationImageHash: hash}, tag.Name); err != nil {
+ return err
+ }
+ continue
+ }
+
+ // Create ReleasePayload object for verification tracking
+ _, err = c.ensureReleasePayload(release, tag)
+ if err != nil {
+ return err
+ }
+
+ // Mark as ready immediately since we skip payload building
+ if err := c.markReleaseReady(release, nil, tag.Name); err != nil {
+ return err
+ }
+ }
+ return nil
}
if len(pendingTags) > 1 {
@@ -460,15 +489,21 @@ func (c *Controller) syncReady(release *releasecontroller.Release) error {
}
for _, releaseTag := range readyTags {
- mirror, err := releasecontroller.GetMirror(release, releaseTag.Name, c.releaseLister)
- if err != nil {
- klog.Errorf("Failed to identify `from` mirror for creation of release mirror job: %v", err)
- } else if _, err := c.ensureReleaseMirrorJob(release, releaseTag.Name, mirror); err != nil {
- klog.Errorf("Failed to create release mirror job: %v", err)
+ // Skip mirroring for layered releases since they use pre-existing single images
+ if release.Config.As != releasecontroller.ReleaseConfigModeLayered {
+ mirror, err := releasecontroller.GetMirror(release, releaseTag.Name, c.releaseLister)
+ if err != nil {
+ klog.Errorf("Failed to identify `from` mirror for creation of release mirror job: %v", err)
+ } else if _, err := c.ensureReleaseMirrorJob(release, releaseTag.Name, mirror); err != nil {
+ klog.Errorf("Failed to create release mirror job: %v", err)
+ }
}
- if err := c.ensureReleaseUpgradeJobs(release, releaseTag); err != nil {
- klog.Errorf("unable to launch release upgrade jobs for %q: %v", releaseTag.Name, err)
+ // Skip upgrade jobs for layered releases since they represent single components
+ if release.Config.As != releasecontroller.ReleaseConfigModeLayered {
+ if err := c.ensureReleaseUpgradeJobs(release, releaseTag); err != nil {
+ klog.Errorf("unable to launch release upgrade jobs for %q: %v", releaseTag.Name, err)
+ }
}
payload, verifyStatus, err := c.getReleasePayloadVerificationState(release, releaseTag.Name)
@@ -535,7 +570,15 @@ func (c *Controller) syncAccepted(release *releasecontroller.Release) error {
if len(ns) == 0 {
ns = release.Target.Namespace
}
- if err := c.ensureImageStreamMatchesRelease(release, ns, publishType.ImageStreamRef.Name, newestAccepted.Name, publishType.ImageStreamRef.Tags, publishType.ImageStreamRef.ExcludeTags); err != nil {
+
+ // For layered releases, we need to publish the specific tag that was verified
+ // rather than all tags within the image stream.
+ tagNames := publishType.ImageStreamRef.Tags
+ if len(tagNames) == 0 && release.Config.As == releasecontroller.ReleaseConfigModeLayered {
+ tagNames = []string{newestAccepted.Name}
+ }
+
+ if err := c.ensureImageStreamMatchesRelease(release, ns, publishType.ImageStreamRef.Name, newestAccepted.Name, tagNames, publishType.ImageStreamRef.ExcludeTags); err != nil {
errs = append(errs, fmt.Errorf("unable to update image stream for publish step %s: %v", name, err))
continue
}
@@ -631,4 +674,3 @@ func getRejectionDetails(payload *v1alpha1.ReleasePayload) (string, string) {
}
return "VerificationFailed", "release verification failed"
}
-
diff --git a/cmd/release-controller/sync_publish.go b/cmd/release-controller/sync_publish.go
index f93f6af4d..41c5ce7e3 100644
--- a/cmd/release-controller/sync_publish.go
+++ b/cmd/release-controller/sync_publish.go
@@ -71,10 +71,19 @@ func (c *Controller) ensureImageStreamMatchesRelease(release *releasecontroller.
return nil
}
- mirror, err := releasecontroller.GetMirror(release, from, c.releaseLister)
- if err != nil {
- klog.V(2).Infof("Error getting release mirror image stream: %v", err)
- return nil
+ var mirror *imagev1.ImageStream
+ var err error
+
+ // For layered releases, use the release target imagestream directly since there's no separate mirror
+ if release.Config.As == releasecontroller.ReleaseConfigModeLayered {
+ mirror = release.Target
+ klog.V(4).Infof("Using release target imagestream directly for layered release publishing: %s/%s", release.Target.Namespace, release.Target.Name)
+ } else {
+ mirror, err = releasecontroller.GetMirror(release, from, c.releaseLister)
+ if err != nil {
+ klog.V(2).Infof("Error getting release mirror image stream: %v", err)
+ return nil
+ }
}
lister := c.publishLister.ImageStreams(toNamespace)
diff --git a/cmd/release-controller/sync_release_payload.go b/cmd/release-controller/sync_release_payload.go
index a8fd90935..c702ef1af 100644
--- a/cmd/release-controller/sync_release_payload.go
+++ b/cmd/release-controller/sync_release_payload.go
@@ -40,18 +40,14 @@ func newReleasePayload(release *releasecontroller.Release, tag *imagev1.TagRefer
Namespace: release.Target.Namespace,
},
Spec: v1alpha1.ReleasePayloadSpec{
+ PayloadCreationConfig: v1alpha1.PayloadCreationConfig{
+ ProwCoordinates: v1alpha1.ProwCoordinates{Namespace: prowNamespace},
+ },
PayloadCoordinates: v1alpha1.PayloadCoordinates{
Namespace: release.Target.Namespace,
ImagestreamName: release.Target.Name,
ImagestreamTagName: name,
- StreamName: release.Config.Name,
- },
- PayloadCreationConfig: v1alpha1.PayloadCreationConfig{
- ReleaseCreationCoordinates: v1alpha1.ReleaseCreationCoordinates{
- Namespace: jobNamespace,
- ReleaseCreationJobName: name,
- },
- ProwCoordinates: v1alpha1.ProwCoordinates{Namespace: prowNamespace},
+ StreamName: release.Config.Name,
},
PayloadVerificationConfig: v1alpha1.PayloadVerificationConfig{
BlockingJobs: []v1alpha1.CIConfiguration{},
@@ -63,6 +59,24 @@ func newReleasePayload(release *releasecontroller.Release, tag *imagev1.TagRefer
},
}
+ // Only add ReleaseCreationCoordinates for releases that need payload creation jobs
+ // Layered releases use pre-existing images and don't need creation jobs
+ if release.Config.As != releasecontroller.ReleaseConfigModeLayered {
+ payload.Spec.PayloadCreationConfig.ReleaseCreationCoordinates = v1alpha1.ReleaseCreationCoordinates{
+ Namespace: jobNamespace,
+ ReleaseCreationJobName: name,
+ }
+
+ // We should only be populating the ReleaseMirrorCoordinates if/when they are actually defined...
+ // And only for releases that have PayloadCreationConfig (not layered releases)
+ if release.Config.AlternateImageRepository != "" && release.Config.AlternateImageRepositorySecretName != "" {
+ payload.Spec.PayloadCreationConfig.ReleaseMirrorCoordinates = v1alpha1.ReleaseMirrorCoordinates{
+ Namespace: jobNamespace,
+ ReleaseMirrorJobName: releaseMirrorJobName(name),
+ }
+ }
+ }
+
if releasecontroller.IsReferenceReleaseTag(release, tag) {
payload.Spec.ReleaseCoordinates = []v1alpha1.ReleaseCoordinates{{
Repository: release.Config.ReferenceRelease.PullRepository,
@@ -75,14 +89,6 @@ func newReleasePayload(release *releasecontroller.Release, tag *imagev1.TagRefer
}}
}
- // We should only be populating the ReleaseMirrorCoordinates if/when they are actually defined...
- if release.Config.AlternateImageRepository != "" && release.Config.AlternateImageRepositorySecretName != "" {
- payload.Spec.PayloadCreationConfig.ReleaseMirrorCoordinates = v1alpha1.ReleaseMirrorCoordinates{
- Namespace: jobNamespace,
- ReleaseMirrorJobName: releaseMirrorJobName(name),
- }
- }
-
// Sort the ReleaseVerification items into a consistent order
var sortedKeys []string
for key := range verificationJobs {
diff --git a/pkg/cmd/release-payload-controller/layered_reference_test.go b/pkg/cmd/release-payload-controller/layered_reference_test.go
new file mode 100644
index 000000000..ce64fbdff
--- /dev/null
+++ b/pkg/cmd/release-payload-controller/layered_reference_test.go
@@ -0,0 +1,121 @@
+package release_payload_controller
+
+import (
+ "context"
+ "testing"
+
+ "github.com/openshift/library-go/pkg/operator/events"
+ "github.com/openshift/library-go/pkg/operator/v1helpers"
+ "github.com/openshift/release-controller/pkg/apis/release/v1alpha1"
+ "github.com/openshift/release-controller/pkg/client/clientset/versioned/fake"
+ releasepayloadinformers "github.com/openshift/release-controller/pkg/client/informers/externalversions"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/tools/cache"
+ "k8s.io/utils/clock"
+)
+
+func TestPayloadCreationWithoutCreationConfig(t *testing.T) {
+ testCases := []struct {
+ name string
+ hasCreationConfig bool
+ expectCreatedCondition metav1.ConditionStatus
+ expectFailedCondition metav1.ConditionStatus
+ expectMessage string
+ }{
+ {
+ name: "Payload with creation config follows normal job-based flow",
+ hasCreationConfig: true,
+ expectCreatedCondition: metav1.ConditionUnknown, // Waits for job completion
+ expectFailedCondition: metav1.ConditionUnknown,
+ },
+ {
+ name: "Payload without creation config marked as created immediately",
+ hasCreationConfig: false,
+ expectCreatedCondition: metav1.ConditionTrue, // Immediate success
+ expectFailedCondition: metav1.ConditionFalse,
+ expectMessage: "Release payload using pre-existing image, no creation job needed",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Create a test payload with coordinates (like a real release payload)
+ payload := &v1alpha1.ReleasePayload{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-release",
+ Namespace: "test-namespace",
+ },
+ Spec: v1alpha1.ReleasePayloadSpec{
+ PayloadCoordinates: v1alpha1.PayloadCoordinates{
+ Namespace: "test-namespace",
+ ImagestreamName: "releases",
+ ImagestreamTagName: "test-release",
+ },
+ PayloadType: v1alpha1.PayloadTypeLocal,
+ },
+ }
+
+ if tc.hasCreationConfig {
+ payload.Spec.PayloadCreationConfig = v1alpha1.PayloadCreationConfig{
+ ReleaseCreationCoordinates: v1alpha1.ReleaseCreationCoordinates{
+ Namespace: "test-jobs",
+ ReleaseCreationJobName: "test-release",
+ },
+ }
+ }
+
+ // Set up fake client and controller
+ fakeClient := fake.NewSimpleClientset(payload)
+ informerFactory := releasepayloadinformers.NewSharedInformerFactory(fakeClient, 0)
+ payloadInformer := informerFactory.Release().V1alpha1().ReleasePayloads()
+
+ controller, err := NewPayloadCreationController(
+ payloadInformer,
+ fakeClient.ReleaseV1alpha1(),
+ events.NewInMemoryRecorder("test", clock.RealClock{}),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create controller: %v", err)
+ }
+
+ // Start informer and wait for cache sync
+ informerFactory.Start(make(chan struct{}))
+ cache.WaitForCacheSync(make(chan struct{}), payloadInformer.Informer().HasSynced)
+
+ // Sync the payload
+ err = controller.sync(context.Background(), "test-namespace/test-release")
+ if err != nil {
+ t.Fatalf("Sync failed: %v", err)
+ }
+
+ // Get the updated payload
+ updatedPayload, err := fakeClient.ReleaseV1alpha1().ReleasePayloads("test-namespace").Get(context.Background(), "test-release", metav1.GetOptions{})
+ if err != nil {
+ t.Fatalf("Failed to get updated payload: %v", err)
+ }
+
+ // Check the conditions
+ createdCond := v1helpers.FindCondition(updatedPayload.Status.Conditions, v1alpha1.ConditionPayloadCreated)
+ failedCond := v1helpers.FindCondition(updatedPayload.Status.Conditions, v1alpha1.ConditionPayloadFailed)
+
+ if createdCond == nil {
+ t.Fatalf("PayloadCreated condition not found")
+ }
+ if failedCond == nil {
+ t.Fatalf("PayloadFailed condition not found")
+ }
+
+ if createdCond.Status != tc.expectCreatedCondition {
+ t.Errorf("Expected PayloadCreated condition status %s, got %s", tc.expectCreatedCondition, createdCond.Status)
+ }
+
+ if failedCond.Status != tc.expectFailedCondition {
+ t.Errorf("Expected PayloadFailed condition status %s, got %s", tc.expectFailedCondition, failedCond.Status)
+ }
+
+ if tc.expectMessage != "" && createdCond.Message != tc.expectMessage {
+ t.Errorf("Expected message %q, got %q", tc.expectMessage, createdCond.Message)
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/pkg/cmd/release-payload-controller/payload_creation_controller.go b/pkg/cmd/release-payload-controller/payload_creation_controller.go
index b20335b73..d7432ed01 100644
--- a/pkg/cmd/release-payload-controller/payload_creation_controller.go
+++ b/pkg/cmd/release-payload-controller/payload_creation_controller.go
@@ -115,6 +115,37 @@ func (c *PayloadCreationController) sync(ctx context.Context, key string) error
return nil
}
+ // If we already have coordinates and no creation config, then we skip the release creation phase
+ // and assume that the release has already been compiled and is ready to be used.
+ if originalReleasePayload.Spec.PayloadCoordinates != (v1alpha1.PayloadCoordinates{}) &&
+ originalReleasePayload.Spec.PayloadCreationConfig.ReleaseCreationCoordinates == (v1alpha1.ReleaseCreationCoordinates{}) {
+ preCreatedImageConditions := getPreCreatedImageCreationConditions()
+
+ releasePayload := originalReleasePayload.DeepCopy()
+ for _, condition := range preCreatedImageConditions {
+ v1helpers.SetCondition(&releasePayload.Status.Conditions, condition)
+ }
+ releasepayloadhelpers.CanonicalizeReleasePayloadStatus(releasePayload)
+
+ // Set the job status to success to indicate that the release has already been created.
+ // This short-circuits the release creation job controller and the release creation status controller.
+ // It also indicates to the release payload accepted controller that the release has already been created.
+ releasePayload.Status.ReleaseCreationJobResult = v1alpha1.ReleaseCreationJobResult{
+ Status: v1alpha1.ReleaseCreationJobSuccess,
+ Message: "Release payload using pre-existing image, no creation job needed",
+ }
+
+ if reflect.DeepEqual(originalReleasePayload, releasePayload) {
+ return nil
+ }
+
+ _, err := c.releasePayloadClient.ReleasePayloads(releasePayload.Namespace).UpdateStatus(ctx, releasePayload, metav1.UpdateOptions{})
+ if errors.IsNotFound(err) {
+ return nil
+ }
+ return err
+ }
+
createdCondition := &metav1.Condition{
Type: v1alpha1.ConditionPayloadCreated,
Status: metav1.ConditionUnknown,
@@ -161,3 +192,20 @@ func (c *PayloadCreationController) sync(ctx context.Context, key string) error
return nil
}
+
+func getPreCreatedImageCreationConditions() []metav1.Condition {
+ createdCondition := metav1.Condition{
+ Type: v1alpha1.ConditionPayloadCreated,
+ Status: metav1.ConditionTrue,
+ Reason: ReleasePayloadCreatedReason,
+ Message: "Release payload using pre-existing image, no creation job needed",
+ }
+ failedCondition := metav1.Condition{
+ Type: v1alpha1.ConditionPayloadFailed,
+ Status: metav1.ConditionFalse,
+ Reason: ReleasePayloadFailedReason,
+ Message: "Release payload using pre-existing image, no creation job needed",
+ }
+
+ return []metav1.Condition{createdCondition, failedCondition}
+}
diff --git a/pkg/cmd/release-payload-controller/payload_mirror_controller.go b/pkg/cmd/release-payload-controller/payload_mirror_controller.go
index af475488c..ab88c2891 100644
--- a/pkg/cmd/release-payload-controller/payload_mirror_controller.go
+++ b/pkg/cmd/release-payload-controller/payload_mirror_controller.go
@@ -115,6 +115,30 @@ func (c *PayloadMirrorController) sync(ctx context.Context, key string) error {
return nil
}
+ // If we already have coordinates and no mirror config, then we skip the release mirror phase
+ // and assume that the release uses pre-existing images and doesn't need mirroring.
+ if originalReleasePayload.Spec.PayloadCoordinates != (v1alpha1.PayloadCoordinates{}) &&
+ originalReleasePayload.Spec.PayloadCreationConfig.ReleaseMirrorCoordinates == (v1alpha1.ReleaseMirrorCoordinates{}) {
+
+ preCreatedImageConditions := getPreCreatedImageMirrorConditions()
+
+ releasePayload := originalReleasePayload.DeepCopy()
+ for _, condition := range preCreatedImageConditions {
+ v1helpers.SetCondition(&releasePayload.Status.Conditions, condition)
+ }
+ releasepayloadhelpers.CanonicalizeReleasePayloadStatus(releasePayload)
+
+ if reflect.DeepEqual(originalReleasePayload, releasePayload) {
+ return nil
+ }
+
+ _, err := c.releasePayloadClient.ReleasePayloads(releasePayload.Namespace).UpdateStatus(ctx, releasePayload, metav1.UpdateOptions{})
+ if errors.IsNotFound(err) {
+ return nil
+ }
+ return err
+ }
+
createdCondition := &metav1.Condition{
Type: v1alpha1.ConditionPayloadMirrored,
Status: metav1.ConditionUnknown,
@@ -161,3 +185,19 @@ func (c *PayloadMirrorController) sync(ctx context.Context, key string) error {
return nil
}
+
+func getPreCreatedImageMirrorConditions() []metav1.Condition {
+ createdCondition := metav1.Condition{
+ Type: v1alpha1.ConditionPayloadMirrored,
+ Status: metav1.ConditionTrue,
+ Reason: ReleasePayloadMirroredReason,
+ Message: "Release payload using pre-existing image, no mirror job needed",
+ }
+ failedCondition := metav1.Condition{
+ Type: v1alpha1.ConditionPayloadMirrorFailed,
+ Status: metav1.ConditionFalse,
+ Reason: ReleasePayloadMirrorFailedReason,
+ Message: "Release payload using pre-existing image, no mirror job needed",
+ }
+ return []metav1.Condition{createdCondition, failedCondition}
+}
diff --git a/pkg/cmd/release-payload-controller/payload_mirror_controller_test.go b/pkg/cmd/release-payload-controller/payload_mirror_controller_test.go
index fbed62694..ae91c6c6b 100644
--- a/pkg/cmd/release-payload-controller/payload_mirror_controller_test.go
+++ b/pkg/cmd/release-payload-controller/payload_mirror_controller_test.go
@@ -277,3 +277,116 @@ func TestPayloadMirrorSync(t *testing.T) {
})
}
}
+
+func TestPayloadMirrorWithoutMirrorConfig(t *testing.T) {
+ testCases := []struct {
+ name string
+ hasMirrorConfig bool
+ expectMirroredCondition metav1.ConditionStatus
+ expectFailedCondition metav1.ConditionStatus
+ expectMessage string
+ }{
+ {
+ name: "Payload with mirror config follows normal job-based flow",
+ hasMirrorConfig: true,
+ expectMirroredCondition: metav1.ConditionUnknown, // Waits for job completion
+ expectFailedCondition: metav1.ConditionUnknown,
+ },
+ {
+ name: "Payload without mirror config marked as mirrored immediately",
+ hasMirrorConfig: false,
+ expectMirroredCondition: metav1.ConditionTrue, // Immediate success
+ expectFailedCondition: metav1.ConditionFalse,
+ expectMessage: "Release payload using pre-existing image, no mirror job needed",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Create a test payload with coordinates (like a real release payload)
+ payload := &v1alpha1.ReleasePayload{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-release",
+ Namespace: "test-namespace",
+ },
+ Spec: v1alpha1.ReleasePayloadSpec{
+ PayloadCoordinates: v1alpha1.PayloadCoordinates{
+ Namespace: "test-namespace",
+ ImagestreamName: "releases",
+ ImagestreamTagName: "test-release",
+ },
+ PayloadType: v1alpha1.PayloadTypeLocal,
+ },
+ }
+
+ if tc.hasMirrorConfig {
+ payload.Spec.PayloadCreationConfig = v1alpha1.PayloadCreationConfig{
+ ReleaseMirrorCoordinates: v1alpha1.ReleaseMirrorCoordinates{
+ Namespace: "test-jobs",
+ ReleaseMirrorJobName: "test-release-mirror",
+ },
+ }
+ }
+
+ // Set up fake client and controller
+ fakeClient := fake.NewSimpleClientset(payload)
+ informerFactory := releasepayloadinformers.NewSharedInformerFactory(fakeClient, 0)
+ payloadInformer := informerFactory.Release().V1alpha1().ReleasePayloads()
+
+ controller, err := NewPayloadMirrorController(
+ payloadInformer,
+ fakeClient.ReleaseV1alpha1(),
+ events.NewInMemoryRecorder("test", clock.RealClock{}),
+ )
+ if err != nil {
+ t.Fatalf("Failed to create controller: %v", err)
+ }
+
+ // Start informer and wait for cache sync
+ informerFactory.Start(make(chan struct{}))
+ cache.WaitForCacheSync(make(chan struct{}), payloadInformer.Informer().HasSynced)
+
+ // Sync the payload
+ err = controller.sync(context.Background(), "test-namespace/test-release")
+ if err != nil {
+ t.Fatalf("Sync failed: %v", err)
+ }
+
+ // Get the updated payload
+ updatedPayload, err := fakeClient.ReleaseV1alpha1().ReleasePayloads("test-namespace").Get(context.Background(), "test-release", metav1.GetOptions{})
+ if err != nil {
+ t.Fatalf("Failed to get updated payload: %v", err)
+ }
+
+ // Check the conditions
+ var mirroredCond, failedCond *metav1.Condition
+ for _, cond := range updatedPayload.Status.Conditions {
+ if cond.Type == v1alpha1.ConditionPayloadMirrored {
+ mirroredCond = &cond
+ }
+ if cond.Type == v1alpha1.ConditionPayloadMirrorFailed {
+ failedCond = &cond
+ }
+ }
+
+ if mirroredCond == nil {
+ t.Fatalf("PayloadMirrored condition not found")
+ }
+ if failedCond == nil {
+ t.Fatalf("PayloadMirrorFailed condition not found")
+ }
+
+ if mirroredCond.Status != tc.expectMirroredCondition {
+ t.Errorf("Expected PayloadMirrored condition status %s, got %s", tc.expectMirroredCondition, mirroredCond.Status)
+ }
+
+ if failedCond.Status != tc.expectFailedCondition {
+ t.Errorf("Expected PayloadMirrorFailed condition status %s, got %s", tc.expectFailedCondition, failedCond.Status)
+ }
+
+ if tc.expectMessage != "" && mirroredCond.Message != tc.expectMessage {
+ t.Errorf("Expected message %q, got %q", tc.expectMessage, mirroredCond.Message)
+ }
+ })
+ }
+}
diff --git a/pkg/cmd/release-payload-controller/release_creation_job_controller.go b/pkg/cmd/release-payload-controller/release_creation_job_controller.go
index d88a8952c..3f25207f9 100644
--- a/pkg/cmd/release-payload-controller/release_creation_job_controller.go
+++ b/pkg/cmd/release-payload-controller/release_creation_job_controller.go
@@ -85,8 +85,8 @@ func (c *ReleaseCreationJobController) sync(ctx context.Context, key string) err
return err
}
- // If the Coordinates are already set, then don't do anything...
- if len(originalReleasePayload.Status.ReleaseCreationJobResult.Coordinates.Namespace) > 0 && len(originalReleasePayload.Status.ReleaseCreationJobResult.Coordinates.Name) > 0 {
+ // If the Coordinates are already set, or the job has otherwise been set to success, then don't do anything...
+ if len(originalReleasePayload.Status.ReleaseCreationJobResult.Coordinates.Namespace) > 0 && len(originalReleasePayload.Status.ReleaseCreationJobResult.Coordinates.Name) > 0 || originalReleasePayload.Status.ReleaseCreationJobResult.Status == v1alpha1.ReleaseCreationJobSuccess {
return nil
}
diff --git a/pkg/release-controller/release.go b/pkg/release-controller/release.go
index ae1a40bdd..cfb07e0e6 100644
--- a/pkg/release-controller/release.go
+++ b/pkg/release-controller/release.go
@@ -114,6 +114,13 @@ func ReleaseDefinition(is *imagev1.ImageStream, releaseConfigCache *lru.Cache, e
Config: cfg,
}
return r, true, nil
+ case ReleaseConfigModeLayered:
+ r := &Release{
+ Source: is,
+ Target: is,
+ Config: cfg,
+ }
+ return r, true, nil
default:
targetImageStream, err := releaseLister.ImageStreams(is.Namespace).Get(cfg.To)
if errors.IsNotFound(err) {
@@ -152,8 +159,8 @@ func ParseReleaseConfig(data string, configCache *lru.Cache) (*ReleaseConfig, er
if len(cfg.Name) == 0 {
return nil, fmt.Errorf("release config must have a valid name")
}
- if len(cfg.To) == 0 && cfg.As != ReleaseConfigModeStable {
- return nil, fmt.Errorf("release must specify 'to' unless 'as' is 'Stable'")
+ if len(cfg.To) == 0 && cfg.As != ReleaseConfigModeStable && cfg.As != ReleaseConfigModeLayered {
+ return nil, fmt.Errorf("release must specify 'to' unless 'as' is 'Stable' or 'Layered'")
}
for name, verify := range cfg.Verify {
if len(name) == 0 {
@@ -608,7 +615,7 @@ func GetImageInfo(releaseInfo ReleaseInfo, architecture, pullSpec string) (*imag
}
func GetVerificationJobs(rcCache *lru.Cache, eventRecorder record.EventRecorder, lister *MultiImageStreamLister, release *Release, releaseTag *imagev1.TagReference, artSuffix string) (map[string]ReleaseVerification, error) {
- if release.Config.As != ReleaseConfigModeStable || artSuffix == "" {
+ if release.Config.As != ReleaseConfigModeStable && release.Config.As != ReleaseConfigModeLayered || artSuffix == "" {
return release.Config.Verify, nil
}
jobs := make(map[string]ReleaseVerification)
diff --git a/pkg/release-controller/types.go b/pkg/release-controller/types.go
index 535b7cc45..72bad8fc5 100644
--- a/pkg/release-controller/types.go
+++ b/pkg/release-controller/types.go
@@ -556,6 +556,7 @@ const (
ReleaseVerificationStatePending = "Pending"
ReleaseConfigModeStable = "Stable"
+ ReleaseConfigModeLayered = "Layered"
// ReferencePayloadTagPrefix is prepended to release names when pushing
// to ReferenceRepository, so that image cleanup tooling can identify