Skip to content

Commit 7450d3e

Browse files
feat(raycicmd): add matrix_expand
Rayci will expand matrix into individual Buildkite steps, allowing for fine-grained depends_on targeting of specific matrix combinations. Topic: matrix-expand Labels: draft Signed-off-by: andrew <andrew@anyscale.com>
1 parent df64f50 commit 7450d3e

File tree

2 files changed

+865
-0
lines changed

2 files changed

+865
-0
lines changed

raycicmd/matrix_expand.go

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
package raycicmd
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
)
8+
9+
// matrixConfig represents a parsed matrix definition.
10+
type matrixConfig struct {
11+
Setup map[string][]string // dimension -> values
12+
}
13+
14+
// matrixInstance represents one expanded combination.
15+
type matrixInstance struct {
16+
Values map[string]string // dimension -> selected value
17+
}
18+
19+
// matrixSelector represents a dependency selector with optional matrix filter.
20+
type matrixSelector struct {
21+
Key string // base step key
22+
Matrix map[string]string // partial dimension constraints (nil = all)
23+
}
24+
25+
// parseMatrixConfig parses the matrix field from a step.
26+
// Examples:
27+
// Named dimensions matrix
28+
//
29+
// matrix:
30+
// setup:
31+
// python: ["3.10", "3.11"]
32+
// cuda: ["12.1.1", "12.8.1"]
33+
//
34+
// Simple array matrix (no dimension name)
35+
//
36+
// matrix:
37+
// - "darwin"
38+
// - "Linux"
39+
// - "Windows"
40+
func parseMatrixConfig(v any) (*matrixConfig, error) {
41+
cfg := &matrixConfig{
42+
Setup: make(map[string][]string),
43+
}
44+
45+
switch val := v.(type) {
46+
case []any:
47+
// Simple array: matrix: ["3.10", "3.11"]
48+
values, err := anySliceToStringSlice(val)
49+
if err != nil {
50+
return nil, fmt.Errorf("invalid matrix array: %w", err)
51+
}
52+
cfg.Setup[""] = values
53+
54+
case map[string]any:
55+
// Map with setup and optional adjustments
56+
if setup, ok := val["setup"]; ok {
57+
setupMap, ok := setup.(map[string]any)
58+
if !ok {
59+
return nil, fmt.Errorf("matrix.setup must be a map")
60+
}
61+
for dim, vals := range setupMap {
62+
valsSlice, ok := vals.([]any)
63+
if !ok {
64+
return nil, fmt.Errorf("matrix.setup.%s must be an array", dim)
65+
}
66+
strVals, err := anySliceToStringSlice(valsSlice)
67+
if err != nil {
68+
return nil, fmt.Errorf("invalid values for dimension %s: %w", dim, err)
69+
}
70+
cfg.Setup[dim] = strVals
71+
}
72+
} else {
73+
return nil, fmt.Errorf("matrix map must have 'setup' key")
74+
}
75+
76+
if _, ok := val["adjustments"]; ok {
77+
return nil, fmt.Errorf("matrix.adjustments is not supported")
78+
}
79+
80+
default:
81+
return nil, fmt.Errorf("matrix must be an array or map, got %T", v)
82+
}
83+
84+
return cfg, nil
85+
}
86+
87+
// expandMatrix generates all combinations from a matrix config.
88+
func expandMatrix(cfg *matrixConfig) []*matrixInstance {
89+
if len(cfg.Setup) == 0 {
90+
return nil
91+
}
92+
93+
dims := sortedMatrixDimensions(cfg)
94+
return generateMatrixCartesianProduct(cfg.Setup, dims)
95+
}
96+
97+
func sortedMatrixDimensions(cfg *matrixConfig) []string {
98+
dims := make([]string, 0, len(cfg.Setup))
99+
for dim := range cfg.Setup {
100+
dims = append(dims, dim)
101+
}
102+
sort.Strings(dims)
103+
return dims
104+
}
105+
106+
func generateMatrixCartesianProduct(setup map[string][]string, dims []string) []*matrixInstance {
107+
if len(dims) == 0 {
108+
return []*matrixInstance{{Values: make(map[string]string)}}
109+
}
110+
111+
firstDim := dims[0]
112+
restDims := dims[1:]
113+
114+
restInstances := generateMatrixCartesianProduct(setup, restDims)
115+
116+
var result []*matrixInstance
117+
for _, val := range setup[firstDim] {
118+
for _, rest := range restInstances {
119+
inst := &matrixInstance{
120+
Values: make(map[string]string, len(rest.Values)+1),
121+
}
122+
for k, v := range rest.Values {
123+
inst.Values[k] = v
124+
}
125+
inst.Values[firstDim] = val
126+
result = append(result, inst)
127+
}
128+
}
129+
130+
return result
131+
}
132+
133+
// generateMatrixInstanceKey creates the expanded key for an instance.
134+
// Format: {base-key}-{dim1}{val1}-{dim2}{val2} (dims sorted alphabetically)
135+
func generateMatrixInstanceKey(baseKey string, cfg *matrixConfig, values map[string]string) string {
136+
dims := sortedMatrixDimensions(cfg)
137+
138+
parts := []string{baseKey}
139+
for _, dim := range dims {
140+
val := values[dim]
141+
if dim == "" {
142+
// Simple array matrix (no dimension name) - use value as-is (e.g., 3.10)
143+
parts = append(parts, sanitizeKeyPart(val))
144+
} else {
145+
// Named dimensions: use dimension-value format (e.g., python3.11)
146+
parts = append(parts, sanitizeKeyPart(dim+val))
147+
}
148+
}
149+
150+
return strings.Join(parts, "-")
151+
}
152+
153+
// sanitizeKeyPart removes or replaces invalid characters from a key part.
154+
// Buildkite keys may only contain alphanumeric characters, underscores, dashes, and colons.
155+
func sanitizeKeyPart(s string) string {
156+
var result strings.Builder
157+
for _, r := range s {
158+
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == ':' {
159+
result.WriteRune(r)
160+
}
161+
// Skip periods, dashes, and other invalid characters within the part
162+
// (dashes are used as separators between parts)
163+
}
164+
return result.String()
165+
}
166+
167+
// generateMatrixTags creates auto-generated tags for matrix values.
168+
func generateMatrixTags(values map[string]string) []string {
169+
var tags []string
170+
for dim, val := range values {
171+
if dim == "" {
172+
// Simple array: just use the value as tag (e.g., 3.10)
173+
tags = append(tags, val)
174+
} else {
175+
// Named dimension: dimension-value format (e.g., python-3.11)
176+
tags = append(tags, dim+"-"+val)
177+
}
178+
}
179+
sort.Strings(tags)
180+
return tags
181+
}
182+
183+
// substituteMatrixValues replaces {{matrix.X}} placeholders in any value.
184+
func substituteMatrixValues(v any, values map[string]string) any {
185+
switch val := v.(type) {
186+
case string:
187+
var replacerArgs []string
188+
for dim, dimVal := range values {
189+
if dim == "" {
190+
// Simple array: use {{matrix}}
191+
replacerArgs = append(replacerArgs, "{{matrix}}", dimVal)
192+
} else {
193+
// Named dimension: use {{matrix.dim}} (e.g., {{matrix.python}})
194+
replacerArgs = append(replacerArgs, "{{matrix."+dim+"}}", dimVal)
195+
}
196+
}
197+
replacer := strings.NewReplacer(replacerArgs...)
198+
return replacer.Replace(val)
199+
200+
case map[string]any:
201+
result := make(map[string]any)
202+
for k, v := range val {
203+
result[k] = substituteMatrixValues(v, values)
204+
}
205+
return result
206+
207+
case []any:
208+
result := make([]any, len(val))
209+
for i, v := range val {
210+
result[i] = substituteMatrixValues(v, values)
211+
}
212+
return result
213+
214+
default:
215+
return v
216+
}
217+
}
218+
219+
// parseMatrixDependsOn parses depends_on field, returns list of selectors.
220+
// Handles: nil, string, []string, []any, []map with {key, matrix} selector syntax
221+
func parseMatrixDependsOn(v any) ([]*matrixSelector, error) {
222+
if v == nil {
223+
return nil, nil
224+
}
225+
226+
switch val := v.(type) {
227+
case string:
228+
return []*matrixSelector{{Key: val}}, nil
229+
230+
case []string:
231+
var selectors []*matrixSelector
232+
for _, key := range val {
233+
selectors = append(selectors, &matrixSelector{Key: key})
234+
}
235+
return selectors, nil
236+
237+
case []any:
238+
var selectors []*matrixSelector
239+
for i, item := range val {
240+
switch itemVal := item.(type) {
241+
case string:
242+
selectors = append(selectors, &matrixSelector{Key: itemVal})
243+
244+
case map[string]any:
245+
sel, err := parseMatrixSelectorMap(itemVal)
246+
if err != nil {
247+
return nil, fmt.Errorf("depends_on[%d]: %w", i, err)
248+
}
249+
selectors = append(selectors, sel)
250+
251+
default:
252+
return nil, fmt.Errorf("depends_on[%d]: unexpected type %T", i, item)
253+
}
254+
}
255+
return selectors, nil
256+
257+
default:
258+
return nil, fmt.Errorf("depends_on must be string or array, got %T", v)
259+
}
260+
}
261+
262+
func parseMatrixSelectorMap(m map[string]any) (*matrixSelector, error) {
263+
sel := &matrixSelector{}
264+
265+
key, ok := m["key"]
266+
if !ok {
267+
return nil, fmt.Errorf("selector missing 'key' field")
268+
}
269+
keyStr, ok := key.(string)
270+
if !ok {
271+
return nil, fmt.Errorf("selector 'key' must be a string")
272+
}
273+
sel.Key = keyStr
274+
275+
if matrix, ok := m["matrix"]; ok {
276+
matrixMap, ok := matrix.(map[string]any)
277+
if !ok {
278+
return nil, fmt.Errorf("selector 'matrix' must be a map")
279+
}
280+
sel.Matrix = make(map[string]string)
281+
for k, v := range matrixMap {
282+
s, ok := v.(string)
283+
if !ok {
284+
return nil, fmt.Errorf("selector 'matrix.%s' must be a string", k)
285+
}
286+
sel.Matrix[k] = s
287+
}
288+
}
289+
290+
return sel, nil
291+
}
292+
293+
// expandMatrixSelector expands a selector to matching expanded keys.
294+
func expandMatrixSelector(
295+
sel *matrixSelector,
296+
registry map[string]*matrixConfig,
297+
expandedKeys map[string][]string, // baseKey -> list of expanded keys
298+
) ([]string, error) {
299+
cfg, ok := registry[sel.Key]
300+
if !ok {
301+
// Not a matrix step, just return the key as-is
302+
return []string{sel.Key}, nil
303+
}
304+
305+
if sel.Matrix == nil {
306+
// No filter - return the base key (which is the meta-step)
307+
return []string{sel.Key}, nil
308+
}
309+
310+
// Validate that all selector dimensions exist in the config
311+
for dim := range sel.Matrix {
312+
if _, ok := cfg.Setup[dim]; !ok {
313+
return nil, fmt.Errorf("selector dimension %q not found in matrix for %q", dim, sel.Key)
314+
}
315+
}
316+
317+
// Find all expanded keys that match the selector
318+
allExpanded := expandedKeys[sel.Key]
319+
var matches []string
320+
321+
instances := expandMatrix(cfg)
322+
for i, inst := range instances {
323+
if matchesMatrixSelector(inst, sel) {
324+
if i < len(allExpanded) {
325+
matches = append(matches, allExpanded[i])
326+
}
327+
}
328+
}
329+
330+
if len(matches) == 0 {
331+
return nil, fmt.Errorf("no matches for selector {key: %q, matrix: %v}", sel.Key, sel.Matrix)
332+
}
333+
334+
return matches, nil
335+
}
336+
337+
func matchesMatrixSelector(inst *matrixInstance, sel *matrixSelector) bool {
338+
for dim, val := range sel.Matrix {
339+
if inst.Values[dim] != val {
340+
return false
341+
}
342+
}
343+
return true
344+
}
345+
346+
// hasMatrixPlaceholder checks if a string contains any {{matrix...}} placeholder.
347+
func hasMatrixPlaceholder(s string) bool {
348+
return strings.Contains(s, "{{matrix")
349+
}
350+
351+
// anySliceToStringSlice converts []any to []string.
352+
func anySliceToStringSlice(arr []any) ([]string, error) {
353+
result := make([]string, len(arr))
354+
for i, v := range arr {
355+
s, ok := v.(string)
356+
if !ok {
357+
return nil, fmt.Errorf("element %d is not a string: %T", i, v)
358+
}
359+
result[i] = s
360+
}
361+
return result, nil
362+
}
363+
364+
// deepCopyStepMap creates a deep copy of a step map.
365+
func deepCopyStepMap(step map[string]any) map[string]any {
366+
return deepCopyAnyMap(step)
367+
}
368+
369+
func deepCopyAnyMap(m map[string]any) map[string]any {
370+
result := make(map[string]any)
371+
for k, v := range m {
372+
result[k] = deepCopyAnyValue(v)
373+
}
374+
return result
375+
}
376+
377+
func deepCopyAnyValue(v any) any {
378+
switch val := v.(type) {
379+
case map[string]any:
380+
return deepCopyAnyMap(val)
381+
case []any:
382+
return deepCopyAnySlice(val)
383+
case []string:
384+
newSlice := make([]string, len(val))
385+
copy(newSlice, val)
386+
return newSlice
387+
default:
388+
return v
389+
}
390+
}
391+
392+
func deepCopyAnySlice(s []any) []any {
393+
result := make([]any, len(s))
394+
for i, v := range s {
395+
result[i] = deepCopyAnyValue(v)
396+
}
397+
return result
398+
}

0 commit comments

Comments
 (0)