Skip to content

Commit cec5d54

Browse files
authored
Merge pull request konflux-ci#1272 from 14rcole/konflux-8553
feat(konflux-8553): allow users to define auto-release logic
2 parents b17b70b + 6098636 commit cec5d54

File tree

5 files changed

+342
-7
lines changed

5 files changed

+342
-7
lines changed

gitops/release.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
Copyright 2024 Red Hat Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package gitops
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"fmt"
23+
"time"
24+
25+
"github.com/google/cel-go/cel"
26+
"github.com/google/cel-go/common/types"
27+
"github.com/google/cel-go/common/types/ref"
28+
applicationapiv1alpha1 "github.com/konflux-ci/application-api/api/v1alpha1"
29+
)
30+
31+
// EvaluateSnapshotAutoReleaseAnnotation evaluates the provided auto-release annotation CEL-like expression
32+
// against the given Snapshot and returns whether it allows auto-release.
33+
func EvaluateSnapshotAutoReleaseAnnotation(autoReleaseExpr string, snapshot *applicationapiv1alpha1.Snapshot) (bool, error) {
34+
// Empty or missing annotation: do not auto-release
35+
if len(autoReleaseExpr) == 0 {
36+
return false, nil
37+
}
38+
39+
// Convert snapshot to a JSON-like map so field selections like
40+
// snapshot.metadata.creationTimestamp work naturally with CEL and
41+
// to allow custom functions to access the snapshot contents.
42+
objMap, err := convertToCELObjectMap(snapshot)
43+
if err != nil {
44+
return false, fmt.Errorf("failed to convert snapshot: %w", err)
45+
}
46+
47+
// Build a CEL environment
48+
// The with a dynamic 'snapshot' variable represents the snapshot object
49+
funcs := snapshotCELFunctions{snapshot: objMap}
50+
env, err := cel.NewEnv(
51+
cel.DefaultUTCTimeZone(true),
52+
cel.Variable("snapshot", cel.DynType),
53+
cel.Variable("now", cel.TimestampType),
54+
// Register custom function: updatedComponentIs(name: string) -> bool
55+
cel.Function("updatedComponentIs",
56+
cel.Overload("updatedComponentIs_string_bool",
57+
[]*cel.Type{cel.StringType},
58+
cel.BoolType,
59+
cel.UnaryBinding(funcs.updatedComponentIs),
60+
),
61+
),
62+
)
63+
if err != nil {
64+
return false, fmt.Errorf("failed to create CEL env: %w", err)
65+
}
66+
67+
// Compile the expression
68+
ast, iss := env.Compile(autoReleaseExpr)
69+
if iss != nil && iss.Err() != nil {
70+
return false, fmt.Errorf("invalid cel expression: %w", iss.Err())
71+
}
72+
73+
prog, err := env.Program(ast)
74+
if err != nil {
75+
return false, fmt.Errorf("failed to create cel program: %w", err)
76+
}
77+
78+
activation := map[string]any{}
79+
activation["snapshot"] = objMap
80+
activation["now"] = time.Now().UTC()
81+
82+
// Evaluate
83+
out, _, err := prog.ContextEval(context.Background(), activation)
84+
if err != nil {
85+
return false, err
86+
}
87+
88+
// Expect a boolean result
89+
if b, ok := out.Value().(bool); ok {
90+
return b, nil
91+
}
92+
return false, fmt.Errorf("cel expression did not evaluate to a boolean")
93+
}
94+
95+
// snapshotCELFunctions contains named implementations of custom CEL functions which
96+
// may access the current snapshot via the captured map.
97+
// Note: The method value used during registration is not an anonymous function.
98+
// It captures the receiver 'snapshotCELFunctions' instance with the prepared snapshot map.
99+
// This helps avoid global state while keeping a named function.
100+
101+
type snapshotCELFunctions struct {
102+
snapshot map[string]any
103+
}
104+
105+
func (sf snapshotCELFunctions) updatedComponentIs(arg ref.Val) ref.Val {
106+
name, ok := arg.Value().(string)
107+
if !ok {
108+
return types.Bool(false)
109+
}
110+
// Navigate snapshot.spec.components[].name
111+
spec, ok := sf.snapshot["spec"].(map[string]any)
112+
if !ok {
113+
return types.Bool(false)
114+
}
115+
components, ok := spec["components"].([]any)
116+
if !ok {
117+
return types.Bool(false)
118+
}
119+
for _, c := range components {
120+
cm, ok := c.(map[string]any)
121+
if !ok {
122+
continue
123+
}
124+
if compName, ok := cm["name"].(string); ok && compName == name {
125+
return types.Bool(true)
126+
}
127+
}
128+
return types.Bool(false)
129+
}
130+
131+
// convertToCELObjectMap marshals the typed object to JSON and back to a
132+
// map[string]any, ensuring JSON field names (e.g., metadata.creationTimestamp)
133+
// are available to CEL.
134+
func convertToCELObjectMap(obj any) (map[string]any, error) {
135+
raw, err := json.Marshal(obj)
136+
if err != nil {
137+
return nil, err
138+
}
139+
var out map[string]any
140+
if err := json.Unmarshal(raw, &out); err != nil {
141+
return nil, err
142+
}
143+
return out, nil
144+
}

gitops/release_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
Copyright 2024 Red Hat Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package gitops_test
18+
19+
import (
20+
"time"
21+
22+
applicationapiv1alpha1 "github.com/konflux-ci/application-api/api/v1alpha1"
23+
"github.com/konflux-ci/integration-service/gitops"
24+
25+
. "github.com/onsi/ginkgo/v2"
26+
. "github.com/onsi/gomega"
27+
"k8s.io/apimachinery/pkg/api/errors"
28+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
29+
"k8s.io/apimachinery/pkg/types"
30+
)
31+
32+
var _ = Describe("Auto-release annotation evaluation", Ordered, func() {
33+
var (
34+
hasSnapshot *applicationapiv1alpha1.Snapshot
35+
)
36+
37+
const (
38+
namespace = "default"
39+
snapshotName = "auto-release-snapshot"
40+
)
41+
42+
BeforeAll(func() {
43+
hasSnapshot = &applicationapiv1alpha1.Snapshot{
44+
ObjectMeta: metav1.ObjectMeta{
45+
Name: snapshotName,
46+
Namespace: namespace,
47+
Labels: map[string]string{},
48+
Annotations: map[string]string{},
49+
},
50+
Spec: applicationapiv1alpha1.SnapshotSpec{
51+
Application: "test-application",
52+
Components: []applicationapiv1alpha1.SnapshotComponent{
53+
{
54+
Name: "component-sample",
55+
ContainerImage: "quay.io/redhat-appstudio/sample-image:latest",
56+
},
57+
},
58+
},
59+
}
60+
Expect(k8sClient.Create(ctx, hasSnapshot)).To(Succeed())
61+
62+
// Ensure it's created and retrievable
63+
Eventually(func() error {
64+
return k8sClient.Get(ctx, types.NamespacedName{Name: snapshotName, Namespace: namespace}, hasSnapshot)
65+
}, time.Second*10).ShouldNot(HaveOccurred())
66+
})
67+
68+
AfterAll(func() {
69+
err := k8sClient.Delete(ctx, hasSnapshot)
70+
Expect(err == nil || errors.IsNotFound(err)).To(BeTrue())
71+
})
72+
73+
It("returns false when the auto-release annotation is missing", func() {
74+
snapshotCopy := hasSnapshot.DeepCopy()
75+
autoReleaseAnnotation := ""
76+
allowed, err := gitops.EvaluateSnapshotAutoReleaseAnnotation(autoReleaseAnnotation, snapshotCopy)
77+
Expect(err).NotTo(HaveOccurred())
78+
Expect(allowed).To(BeFalse())
79+
})
80+
81+
It("returns true when the auto-release annotation is 'true'", func() {
82+
snapshotCopy := hasSnapshot.DeepCopy()
83+
autoReleaseAnnotation := "true"
84+
allowed, err := gitops.EvaluateSnapshotAutoReleaseAnnotation(autoReleaseAnnotation, snapshotCopy)
85+
Expect(err).NotTo(HaveOccurred())
86+
Expect(allowed).To(BeTrue())
87+
})
88+
89+
It("returns false when the auto-release annotation is 'false'", func() {
90+
snapshotCopy := hasSnapshot.DeepCopy()
91+
autoReleaseAnnotation := "false"
92+
allowed, err := gitops.EvaluateSnapshotAutoReleaseAnnotation(autoReleaseAnnotation, snapshotCopy)
93+
Expect(err).NotTo(HaveOccurred())
94+
Expect(allowed).To(BeFalse())
95+
})
96+
97+
It("returns true when the snapshot is older than 1 week", func() {
98+
snapshotCopy := hasSnapshot.DeepCopy()
99+
snapshotCopy.CreationTimestamp = metav1.NewTime(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC))
100+
autoReleaseAnnotation := "timestamp(snapshot.metadata.creationTimestamp) < (timestamp(now) - duration('168h'))"
101+
allowed, err := gitops.EvaluateSnapshotAutoReleaseAnnotation(autoReleaseAnnotation, snapshotCopy)
102+
Expect(err).NotTo(HaveOccurred())
103+
Expect(allowed).To(BeTrue())
104+
})
105+
106+
It("returns true when the updated component is 'component-sample'", func() {
107+
snapshotCopy := hasSnapshot.DeepCopy()
108+
autoReleaseAnnotation := "updatedComponentIs('component-sample')"
109+
allowed, err := gitops.EvaluateSnapshotAutoReleaseAnnotation(autoReleaseAnnotation, snapshotCopy)
110+
Expect(err).NotTo(HaveOccurred())
111+
Expect(allowed).To(BeTrue())
112+
})
113+
114+
It("returns error and false when the auto-release annotation is an invalid CEL expression", func() {
115+
snapshotCopy := hasSnapshot.DeepCopy()
116+
autoReleaseAnnotation := "invalid expression$" // syntactically invalid
117+
allowed, err := gitops.EvaluateSnapshotAutoReleaseAnnotation(autoReleaseAnnotation, snapshotCopy)
118+
Expect(err).To(HaveOccurred())
119+
Expect(allowed).To(BeFalse())
120+
})
121+
})

gitops/snapshot.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -679,9 +679,13 @@ func CanSnapshotBePromoted(snapshot *applicationapiv1alpha1.Snapshot) (bool, []s
679679
canBePromoted = false
680680
reasons = append(reasons, "the Snapshot was created for a PaC pull request event")
681681
}
682-
if IsSnapshotAutoReleaseDisabled(snapshot) {
682+
if res, err := DoesSnapshotAutoReleaseAnnotationEvaluateToTrue(snapshot); !res {
683683
canBePromoted = false
684-
reasons = append(reasons, fmt.Sprintf("the Snapshot '%s' label is 'false'", AutoReleaseLabel))
684+
if err == nil {
685+
reasons = append(reasons, fmt.Sprintf("the Snapshot '%s' label expression evaluated to 'false'", AutoReleaseLabel))
686+
} else {
687+
reasons = append(reasons, fmt.Sprintf("there was an error evaluatoring the '%s' label expression: %s", AutoReleaseLabel, err))
688+
}
685689
}
686690
if IsGroupSnapshot(snapshot) {
687691
canBePromoted = false
@@ -743,9 +747,15 @@ func IsSnapshotCreatedByPACPushEvent(snapshot *applicationapiv1alpha1.Snapshot)
743747
!metadata.HasLabel(snapshot, PipelineAsCodePullRequestAnnotation) && !IsGroupSnapshot(snapshot)
744748
}
745749

746-
// IsSnapshotAutoReleaseDisabled checks if a snapshot has a AutoReleaseLabel label and if its value is "false"
747-
func IsSnapshotAutoReleaseDisabled(snapshot *applicationapiv1alpha1.Snapshot) bool {
748-
return metadata.HasLabelWithValue(snapshot, AutoReleaseLabel, "false")
750+
// DoesSnapshotAutoReleaseAnnotationEvaluateToTrue checks if the auto-release label exists. If so, it evaluates
751+
// the CEL expression it contains and returns the result
752+
func DoesSnapshotAutoReleaseAnnotationEvaluateToTrue(snapshot *applicationapiv1alpha1.Snapshot) (bool, error) {
753+
labelValue, ok := snapshot.GetLabels()[AutoReleaseLabel]
754+
if !ok || labelValue == "" {
755+
// if the label doesn't exist we default to true
756+
return true, nil
757+
}
758+
return EvaluateSnapshotAutoReleaseAnnotation(labelValue, snapshot)
749759
}
750760

751761
// IsSnapshotCreatedBySamePACEvent checks if the two snapshot are created by the same PAC event

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/bradleyfalzon/ghinstallation/v2 v2.16.0
1010
github.com/ghodss/yaml v1.0.0
1111
github.com/go-logr/logr v1.4.3
12+
github.com/google/cel-go v0.23.1
1213
github.com/google/go-containerregistry v0.20.6
1314
github.com/google/go-github/v45 v45.2.0
1415
github.com/konflux-ci/application-api v0.0.0-20250324201748-5a9670bf7679
@@ -68,7 +69,6 @@ require (
6869
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
6970
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
7071
github.com/golang/protobuf v1.5.4 // indirect
71-
github.com/google/cel-go v0.23.1 // indirect
7272
github.com/google/gnostic-models v0.7.0 // indirect
7373
github.com/google/go-cmp v0.7.0 // indirect
7474
github.com/google/go-github/v72 v72.0.0 // indirect

internal/controller/snapshot/snapshot_adapter_test.go

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,66 @@ var _ = Describe("Snapshot Adapter", Ordered, func() {
10431043
Expect(condition.Message).To(Equal("The Snapshot was auto-released"))
10441044
})
10451045

1046+
It("creates a Release for valid CEL expression", func() {
1047+
log := helpers.IntegrationLogger{Logger: buflogr.NewWithBuffer(&buf)}
1048+
1049+
hasSnapshot.Labels[gitops.AutoReleaseLabel] = "updatedComponentIs('component-sample')"
1050+
err := gitops.MarkSnapshotIntegrationStatusAsFinished(ctx, k8sClient, hasSnapshot, "Snapshot integration status condition is finished since all testing pipelines completed")
1051+
Expect(err).ToNot(HaveOccurred())
1052+
err = gitops.MarkSnapshotAsPassed(ctx, k8sClient, hasSnapshot, "test passed")
1053+
Expect(err).To(Succeed())
1054+
Expect(gitops.HaveAppStudioTestsFinished(hasSnapshot)).To(BeTrue())
1055+
Expect(gitops.HaveAppStudioTestsSucceeded(hasSnapshot)).To(BeTrue())
1056+
adapter = NewAdapter(ctx, hasSnapshot, hasApp, log, loader.NewMockLoader(), k8sClient)
1057+
1058+
adapter.context = toolkit.GetMockedContext(ctx, []toolkit.MockData{
1059+
{
1060+
ContextKey: loader.ApplicationContextKey,
1061+
Resource: hasApp,
1062+
},
1063+
{
1064+
ContextKey: loader.ComponentContextKey,
1065+
Resource: hasComp,
1066+
},
1067+
{
1068+
ContextKey: loader.SnapshotContextKey,
1069+
Resource: hasSnapshot,
1070+
},
1071+
{
1072+
ContextKey: loader.AutoReleasePlansContextKey,
1073+
Resource: []releasev1alpha1.ReleasePlan{*testReleasePlan},
1074+
},
1075+
{
1076+
ContextKey: loader.ReleaseContextKey,
1077+
Resource: &releasev1alpha1.Release{},
1078+
},
1079+
})
1080+
1081+
Eventually(func() bool {
1082+
result, err := adapter.EnsureAllReleasesExist()
1083+
return !result.CancelRequest && err == nil
1084+
}, time.Second*10).Should(BeTrue())
1085+
1086+
Eventually(func() bool {
1087+
err := k8sClient.Get(ctx, types.NamespacedName{
1088+
Name: hasSnapshot.Name,
1089+
Namespace: "default",
1090+
}, hasSnapshot)
1091+
return err == nil && gitops.IsSnapshotMarkedAsAutoReleased(hasSnapshot)
1092+
}, time.Second*10).Should(BeTrue())
1093+
1094+
// Check if the adapter function detects that it already released the snapshot
1095+
result, err := adapter.EnsureAllReleasesExist()
1096+
Expect(err).ShouldNot(HaveOccurred())
1097+
Expect(result.CancelRequest).To(BeFalse())
1098+
1099+
expectedLogEntry := "The Snapshot was previously auto-released, skipping auto-release."
1100+
Expect(buf.String()).Should(ContainSubstring(expectedLogEntry))
1101+
1102+
condition := meta.FindStatusCondition(hasSnapshot.Status.Conditions, gitops.SnapshotAutoReleasedCondition)
1103+
Expect(condition.Message).To(Equal("The Snapshot was auto-released"))
1104+
})
1105+
10461106
It("no action when EnsureAllReleasesExist function runs when AppStudio Tests failed and the snapshot is invalid", func() {
10471107
log := helpers.IntegrationLogger{Logger: buflogr.NewWithBuffer(&buf)}
10481108

@@ -1071,7 +1131,7 @@ var _ = Describe("Snapshot Adapter", Ordered, func() {
10711131
Expect(buf.String()).Should(ContainSubstring(expectedLogEntry))
10721132
expectedLogEntry = "the Snapshot was created for a PaC pull request event"
10731133
Expect(buf.String()).Should(ContainSubstring(expectedLogEntry))
1074-
expectedLogEntry = fmt.Sprintf("the Snapshot '%s' label is 'false'", gitops.AutoReleaseLabel)
1134+
expectedLogEntry = fmt.Sprintf("the Snapshot '%s' label expression evaluated to 'false'", gitops.AutoReleaseLabel)
10751135
Expect(buf.String()).Should(ContainSubstring(expectedLogEntry))
10761136
})
10771137

0 commit comments

Comments
 (0)