Skip to content

Commit a87d072

Browse files
committed
Move over a few functions from cvo#1379
1 parent 4cfd94b commit a87d072

2 files changed

Lines changed: 240 additions & 0 deletions

File tree

pkg/proposal/controller.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ package proposal
33
import (
44
"context"
55
"fmt"
6+
"os"
7+
"strings"
68
"time"
79

10+
"github.com/blang/semver/v4"
811
ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client"
912

1013
corev1 "k8s.io/api/core/v1"
@@ -53,6 +56,29 @@ func NewController(updatesGetterFunc UpdatesGetterFunc, client ctrlruntimeclient
5356
}
5457
}
5558

59+
// Config holds configuration for proposal creation.
60+
type Config struct {
61+
Namespace string
62+
Workflow string
63+
PromptConfigMap string // ConfigMap name containing the system prompt
64+
}
65+
66+
// DefaultConfig returns the default configuration, checking env vars for overrides.
67+
func DefaultConfig() Config {
68+
return Config{
69+
Namespace: envOrDefault("LIGHTSPEED_PROPOSAL_NAMESPACE", "openshift-lightspeed"),
70+
Workflow: envOrDefault("LIGHTSPEED_PROPOSAL_WORKFLOW", "ota-advisory"),
71+
PromptConfigMap: envOrDefault("LIGHTSPEED_PROMPT_CONFIGMAP", "ota-advisory-prompt"),
72+
}
73+
}
74+
75+
func envOrDefault(key, def string) string {
76+
if v := os.Getenv(key); v != "" {
77+
return v
78+
}
79+
return def
80+
}
81+
5682
func (c *Controller) Queue() workqueue.TypedRateLimitingInterface[any] {
5783
return c.queue
5884
}
@@ -155,3 +181,98 @@ func getProposals(_ []configv1.Release, _ []configv1.ConditionalUpdate) []*propo
155181
},
156182
}
157183
}
184+
185+
// proposalName generates a deterministic proposal name from the version pair.
186+
func proposalName(current, target string) string {
187+
return fmt.Sprintf("ota-%s-to-%s", sanitize(current), sanitize(target))
188+
}
189+
190+
// sanitize converts a version string into a valid DNS-1035 label component.
191+
// DNS-1035 requires: lowercase alphanumeric or '-', start with alpha, end with alphanum.
192+
func sanitize(s string) string {
193+
s = strings.ToLower(s)
194+
s = strings.ReplaceAll(s, ".", "-")
195+
s = strings.ReplaceAll(s, " ", "-")
196+
if len(s) > 20 {
197+
s = s[:20]
198+
}
199+
return strings.TrimRight(s, "-")
200+
}
201+
202+
const (
203+
updateKindRecommended = "recommended"
204+
updateKindConditional = "conditional"
205+
206+
updateTypeZStream = "z-stream"
207+
updateTypeMinor = "minor"
208+
updateTypeUnknown = "unknown"
209+
)
210+
211+
// classifyUpdate returns "z-stream" if major.minor match, otherwise "minor".
212+
func classifyUpdate(current, target string) string {
213+
cv, cerr := semver.Parse(current)
214+
tv, terr := semver.Parse(target)
215+
if cerr != nil || terr != nil {
216+
return updateTypeUnknown
217+
}
218+
if cv.Major == tv.Major && cv.Minor == tv.Minor {
219+
return updateTypeZStream
220+
}
221+
return updateTypeMinor
222+
}
223+
224+
// buildRequest constructs the proposal request with system prompt, metadata, and readiness data.
225+
func buildRequest(systemPrompt, current, target, channel, updateType, targetType string,
226+
updates []configv1.Release, readinessJSON string) string {
227+
228+
var b strings.Builder
229+
230+
if systemPrompt != "" {
231+
b.WriteString(systemPrompt)
232+
b.WriteString("\n\n---\n\n")
233+
}
234+
235+
fmt.Fprintf(&b, "Current version: OCP %s\n", current)
236+
fmt.Fprintf(&b, "Target version: OCP %s\n", target)
237+
fmt.Fprintf(&b, "Channel: %s\n", channel)
238+
fmt.Fprintf(&b, "Update type: %s\n", updateType)
239+
fmt.Fprintf(&b, "Update path: %s\n\n", targetType)
240+
241+
if targetType == updateKindConditional {
242+
b.WriteString("WARNING: This target version is available as a CONDITIONAL update.\n")
243+
b.WriteString("OSUS has flagged known risks that may apply to this cluster.\n")
244+
b.WriteString("The assessment MUST evaluate each conditional risk against cluster state.\n\n")
245+
}
246+
247+
if len(updates) > 1 {
248+
b.WriteString("Other recommended versions available:\n")
249+
count := 0
250+
for _, u := range updates {
251+
if u.Version != target {
252+
if u.URL != "" {
253+
fmt.Fprintf(&b, " - %s (errata: %s)\n", u.Version, u.URL)
254+
} else {
255+
fmt.Fprintf(&b, " - %s\n", u.Version)
256+
}
257+
count++
258+
if count >= 5 {
259+
remaining := len(updates) - count - 1
260+
if remaining > 0 {
261+
fmt.Fprintf(&b, " ... and %d more\n", remaining)
262+
}
263+
break
264+
}
265+
}
266+
}
267+
b.WriteString("\n")
268+
}
269+
270+
if readinessJSON != "" {
271+
b.WriteString("## Cluster Readiness Data\n\n")
272+
b.WriteString("```json\n")
273+
b.WriteString(readinessJSON)
274+
b.WriteString("\n```\n")
275+
}
276+
277+
return b.String()
278+
}

pkg/proposal/controller_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package proposal
33
import (
44
"context"
55
"fmt"
6+
"strings"
67
"testing"
78

89
"github.com/google/go-cmp/cmp"
@@ -73,3 +74,121 @@ func TestController_Sync(t *testing.T) {
7374
})
7475
}
7576
}
77+
78+
func TestClassifyUpdate(t *testing.T) {
79+
tests := []struct {
80+
name string
81+
current string
82+
target string
83+
expected string
84+
}{
85+
{name: "z-stream", current: "4.15.1", target: "4.15.3", expected: "z-stream"},
86+
{name: "minor", current: "4.15.1", target: "4.16.0", expected: "minor"},
87+
{name: "major", current: "4.15.1", target: "5.0.0", expected: "minor"},
88+
{name: "invalid current", current: "bad", target: "4.15.0", expected: "unknown"},
89+
{name: "invalid target", current: "4.15.0", target: "bad", expected: "unknown"},
90+
}
91+
92+
for _, tt := range tests {
93+
t.Run(tt.name, func(t *testing.T) {
94+
got := classifyUpdate(tt.current, tt.target)
95+
if got != tt.expected {
96+
t.Errorf("classifyUpdate(%q, %q) = %q, want %q", tt.current, tt.target, got, tt.expected)
97+
}
98+
})
99+
}
100+
}
101+
102+
func TestProposalName(t *testing.T) {
103+
tests := []struct {
104+
current string
105+
target string
106+
expected string
107+
}{
108+
{"4.15.1", "4.15.3", "ota-4-15-1-to-4-15-3"},
109+
{"4.15.1", "4.16.0", "ota-4-15-1-to-4-16-0"},
110+
}
111+
112+
for _, tt := range tests {
113+
t.Run(tt.current+"->"+tt.target, func(t *testing.T) {
114+
got := proposalName(tt.current, tt.target)
115+
if got != tt.expected {
116+
t.Errorf("proposalName(%q, %q) = %q, want %q", tt.current, tt.target, got, tt.expected)
117+
}
118+
})
119+
}
120+
}
121+
122+
func TestSanitize(t *testing.T) {
123+
tests := []struct {
124+
input string
125+
expected string
126+
}{
127+
{"4.15.1", "4-15-1"},
128+
{"Hello World", "hello-world"},
129+
{"a-very-long-version-string-that-is-too-long", "a-very-long-version"},
130+
{"trailing-dot.", "trailing-dot"},
131+
{"trailing-dash-", "trailing-dash"},
132+
}
133+
134+
for _, tt := range tests {
135+
t.Run(tt.input, func(t *testing.T) {
136+
got := sanitize(tt.input)
137+
if got != tt.expected {
138+
t.Errorf("sanitize(%q) = %q, want %q", tt.input, got, tt.expected)
139+
}
140+
})
141+
}
142+
}
143+
144+
func TestBuildRequest(t *testing.T) {
145+
updates := []configv1.Release{
146+
{Version: "4.16.0", URL: "https://example.com/errata/1"},
147+
{Version: "4.16.1", URL: "https://example.com/errata/2"},
148+
}
149+
150+
t.Run("recommended target", func(t *testing.T) {
151+
request := buildRequest("", "4.15.3", "4.16.0", "stable-4.16", "minor", "recommended", updates, "")
152+
if !strings.Contains(request, "Current version: OCP 4.15.3") {
153+
t.Error("request should contain current version")
154+
}
155+
if !strings.Contains(request, "Target version: OCP 4.16.0") {
156+
t.Error("request should contain target version")
157+
}
158+
if !strings.Contains(request, "Update type: minor") {
159+
t.Error("request should contain update type")
160+
}
161+
if !strings.Contains(request, "Update path: recommended") {
162+
t.Error("request should contain update path")
163+
}
164+
if strings.Contains(request, "WARNING") {
165+
t.Error("recommended target should not have warning")
166+
}
167+
if !strings.Contains(request, "Other recommended versions available:") {
168+
t.Error("should list other versions when more than one update")
169+
}
170+
if !strings.Contains(request, "4.16.1") {
171+
t.Error("should list alternative version")
172+
}
173+
})
174+
175+
t.Run("conditional target", func(t *testing.T) {
176+
request := buildRequest("", "4.15.3", "4.16.0", "stable-4.16", "minor", "conditional", updates, "")
177+
if !strings.Contains(request, "WARNING") {
178+
t.Error("conditional target should have warning")
179+
}
180+
if !strings.Contains(request, "CONDITIONAL update") {
181+
t.Error("conditional target should mention CONDITIONAL")
182+
}
183+
})
184+
185+
t.Run("readiness JSON embedded", func(t *testing.T) {
186+
request := buildRequest("", "4.15.3", "4.16.0", "stable-4.16", "minor", "recommended", updates, `{"checks":{},"meta":{}}`)
187+
if !strings.Contains(request, "## Cluster Readiness Data") {
188+
t.Error("request should contain readiness data header")
189+
}
190+
if !strings.Contains(request, `{"checks":{},"meta":{}}`) {
191+
t.Error("request should contain readiness JSON")
192+
}
193+
})
194+
}

0 commit comments

Comments
 (0)