Skip to content
Draft
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
32 changes: 22 additions & 10 deletions cmd/release-controller-api/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(`<td class="text-monospace"><a class="%s" href="/releasestream/%s/release/%s">%s</a> <a href="/releasestream/%s/inconsistency/%s"><i title="Inconsistency detected! Click for more details" class="bi bi-exclamation-circle"></i></a></td>`, alert, template.HTMLEscapeString(config.Name), template.HTMLEscapeString(tag.Name), template.HTMLEscapeString(tag.Name), template.HTMLEscapeString(config.Name), template.HTMLEscapeString(tag.Name))
} else if config.As == releasecontroller.ReleaseConfigModeStable {
} else if config.As == releasecontroller.ReleaseConfigModeStable || config.As == releasecontroller.ReleaseConfigModeLayered {
return fmt.Sprintf(`<td class="text-monospace"><a class="%s" style="padding-left:15px" href="/releasestream/%s/release/%s">%s</a></td>`, alert, template.HTMLEscapeString(config.Name), template.HTMLEscapeString(tag.Name), template.HTMLEscapeString(tag.Name))
} else {
return fmt.Sprintf(`<td class="text-monospace"><a class="%s" href="/releasestream/%s/release/%s">%s</a></td>`, alert, template.HTMLEscapeString(config.Name), template.HTMLEscapeString(tag.Name), template.HTMLEscapeString(tag.Name))
Expand Down Expand Up @@ -1737,7 +1737,7 @@ func (c *Controller) httpReleases(w http.ResponseWriter, req *http.Request) {
"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("<div class=\"container\">\n<div class=\"row d-flex justify-content-between\">\n<div><p>%s</p></div>\n<div class=\"form-outline\"><input type=\"search\" class=\"form-control\" id=\"%s\" onkeyup=\"%s\" placeholder=\"Search\" aria-label=\"Search\"></div>\n</div>\n</div>", streamMessage, searchFunctionPrefix, searchFunction)
Expand All @@ -1750,6 +1750,10 @@ func (c *Controller) httpReleases(w http.ResponseWriter, req *http.Request) {
if len(streamMessage) == 0 {
out = append(out, `<span>stable tags</span>`)
}
case releasecontroller.ReleaseConfigModeLayered:
if len(streamMessage) == 0 {
out = append(out, `<span>layered releases</span>`)
}
default:
out = append(out, fmt.Sprintf(`<span>updated when <code>%s/%s</code> changes</span>`, r.Release.Source.Namespace, r.Release.Source.Name))
}
Expand Down Expand Up @@ -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)))
}
Expand All @@ -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)
Expand Down Expand Up @@ -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("<div class=\"container\">\n<div class=\"row d-flex justify-content-between\">\n<div><p>%s</p></div>\n<div class=\"form-outline\"><input type=\"search\" class=\"form-control\" id=\"%s\" onkeyup=\"%s\" placeholder=\"Search\" aria-label=\"Search\"></div>\n</div>\n</div>", streamMessage, searchFunctionPrefix, searchFunction)
Expand All @@ -2217,6 +2221,10 @@ func (c *Controller) httpReleaseStreamTable(w http.ResponseWriter, req *http.Req
if len(streamMessage) == 0 {
out = append(out, `<span>stable tags</span>`)
}
case releasecontroller.ReleaseConfigModeLayered:
if len(streamMessage) == 0 {
out = append(out, `<span>layered releases</span>`)
}
default:
out = append(out, fmt.Sprintf(`<span>updated when <code>%s/%s</code> changes</span>`, r.Release.Source.Namespace, r.Release.Source.Name))
}
Expand Down Expand Up @@ -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)))
}
Expand All @@ -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
Expand Down Expand Up @@ -2367,6 +2375,10 @@ func (c *Controller) httpDashboardOverview(w http.ResponseWriter, req *http.Requ
if len(streamMessage) == 0 {
out = append(out, `<span>stable tags</span>`)
}
case releasecontroller.ReleaseConfigModeLayered:
if len(streamMessage) == 0 {
out = append(out, `<span>layered releases</span>`)
}
default:
out = append(out, fmt.Sprintf(`<span>updated when <code>%s/%s</code> changes</span>`, r.Release.Source.Namespace, r.Release.Source.Name))
}
Expand Down Expand Up @@ -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)))
}
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/release-controller-api/http_candidate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions cmd/release-controller-api/http_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("<a href=\"#%s\">%s</a>", template.HTMLEscapeString(s.Release.Config.Name), template.HTMLEscapeString(s.Release.Config.Name)))
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
70 changes: 70 additions & 0 deletions cmd/release-controller/layered_mode_test.go
Original file line number Diff line number Diff line change
@@ -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
}
})
}
}
66 changes: 54 additions & 12 deletions cmd/release-controller/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand 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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -631,4 +674,3 @@ func getRejectionDetails(payload *v1alpha1.ReleasePayload) (string, string) {
}
return "VerificationFailed", "release verification failed"
}

17 changes: 13 additions & 4 deletions cmd/release-controller/sync_publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading