Skip to content

Commit 19a7968

Browse files
committed
Move over a few functions from cvo#1379
1 parent 4f02d49 commit 19a7968

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
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/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
}
@@ -187,3 +213,98 @@ func getProposals(_ []configv1.Release, _ []configv1.ConditionalUpdate) []*propo
187213
},
188214
}
189215
}
216+
217+
// proposalName generates a deterministic proposal name from the version pair.
218+
func proposalName(current, target string) string {
219+
return fmt.Sprintf("ota-%s-to-%s", sanitize(current), sanitize(target))
220+
}
221+
222+
// sanitize converts a version string into a valid DNS-1035 label component.
223+
// DNS-1035 requires: lowercase alphanumeric or '-', start with alpha, end with alphanum.
224+
func sanitize(s string) string {
225+
s = strings.ToLower(s)
226+
s = strings.ReplaceAll(s, ".", "-")
227+
s = strings.ReplaceAll(s, " ", "-")
228+
if len(s) > 20 {
229+
s = s[:20]
230+
}
231+
return strings.TrimRight(s, "-")
232+
}
233+
234+
const (
235+
updateKindRecommended = "recommended"
236+
updateKindConditional = "conditional"
237+
238+
updateTypeZStream = "z-stream"
239+
updateTypeMinor = "minor"
240+
updateTypeUnknown = "unknown"
241+
)
242+
243+
// classifyUpdate returns "z-stream" if major.minor match, otherwise "minor".
244+
func classifyUpdate(current, target string) string {
245+
cv, cerr := semver.Parse(current)
246+
tv, terr := semver.Parse(target)
247+
if cerr != nil || terr != nil {
248+
return updateTypeUnknown
249+
}
250+
if cv.Major == tv.Major && cv.Minor == tv.Minor {
251+
return updateTypeZStream
252+
}
253+
return updateTypeMinor
254+
}
255+
256+
// buildRequest constructs the proposal request with system prompt, metadata, and readiness data.
257+
func buildRequest(systemPrompt, current, target, channel, updateType, targetType string,
258+
updates []configv1.Release, readinessJSON string) string {
259+
260+
var b strings.Builder
261+
262+
if systemPrompt != "" {
263+
b.WriteString(systemPrompt)
264+
b.WriteString("\n\n---\n\n")
265+
}
266+
267+
fmt.Fprintf(&b, "Current version: OCP %s\n", current)
268+
fmt.Fprintf(&b, "Target version: OCP %s\n", target)
269+
fmt.Fprintf(&b, "Channel: %s\n", channel)
270+
fmt.Fprintf(&b, "Update type: %s\n", updateType)
271+
fmt.Fprintf(&b, "Update path: %s\n\n", targetType)
272+
273+
if targetType == updateKindConditional {
274+
b.WriteString("WARNING: This target version is available as a CONDITIONAL update.\n")
275+
b.WriteString("OSUS has flagged known risks that may apply to this cluster.\n")
276+
b.WriteString("The assessment MUST evaluate each conditional risk against cluster state.\n\n")
277+
}
278+
279+
if len(updates) > 1 {
280+
b.WriteString("Other recommended versions available:\n")
281+
count := 0
282+
for _, u := range updates {
283+
if u.Version != target {
284+
if u.URL != "" {
285+
fmt.Fprintf(&b, " - %s (errata: %s)\n", u.Version, u.URL)
286+
} else {
287+
fmt.Fprintf(&b, " - %s\n", u.Version)
288+
}
289+
count++
290+
if count >= 5 {
291+
remaining := len(updates) - count - 1
292+
if remaining > 0 {
293+
fmt.Fprintf(&b, " ... and %d more\n", remaining)
294+
}
295+
break
296+
}
297+
}
298+
}
299+
b.WriteString("\n")
300+
}
301+
302+
if readinessJSON != "" {
303+
b.WriteString("## Cluster Readiness Data\n\n")
304+
b.WriteString("```json\n")
305+
b.WriteString(readinessJSON)
306+
b.WriteString("\n```\n")
307+
}
308+
309+
return b.String()
310+
}

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

0 commit comments

Comments
 (0)