Skip to content

Commit 853ec65

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 853ec65

File tree

2 files changed

+846
-0
lines changed

2 files changed

+846
-0
lines changed

raycicmd/matrix_expand.go

Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
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),
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, val)
144+
} else {
145+
// Named dimensions: use dimension-value format (e.g., python3.11)
146+
parts = append(parts, dim+val)
147+
}
148+
}
149+
150+
return strings.Join(parts, "-")
151+
}
152+
153+
// generateMatrixTags creates auto-generated tags for matrix values.
154+
func generateMatrixTags(values map[string]string) []string {
155+
var tags []string
156+
for dim, val := range values {
157+
if dim == "" {
158+
// Simple array: just use the value as tag (e.g., 3.10)
159+
tags = append(tags, val)
160+
} else {
161+
// Named dimension: dimension-value format (e.g., python-3.11)
162+
tags = append(tags, dim+"-"+val)
163+
}
164+
}
165+
sort.Strings(tags)
166+
return tags
167+
}
168+
169+
// substituteMatrixValues replaces {{matrix.X}} placeholders in any value.
170+
func substituteMatrixValues(v any, values map[string]string) any {
171+
switch val := v.(type) {
172+
case string:
173+
result := val
174+
for dim, dimVal := range values {
175+
if dim == "" {
176+
// Simple array: use {{matrix}}
177+
result = strings.ReplaceAll(result, "{{matrix}}", dimVal)
178+
} else {
179+
// Named dimension: use {{matrix.dim}} (e.g., {{matrix.python}})
180+
result = strings.ReplaceAll(result, "{{matrix."+dim+"}}", dimVal)
181+
}
182+
}
183+
return result
184+
185+
case map[string]any:
186+
result := make(map[string]any)
187+
for k, v := range val {
188+
result[k] = substituteMatrixValues(v, values)
189+
}
190+
return result
191+
192+
case []any:
193+
result := make([]any, len(val))
194+
for i, v := range val {
195+
result[i] = substituteMatrixValues(v, values)
196+
}
197+
return result
198+
199+
default:
200+
return v
201+
}
202+
}
203+
204+
// parseMatrixDependsOn parses depends_on field, returns list of selectors.
205+
// Handles: nil, string, []string, []any, []map with {key, matrix} selector syntax
206+
func parseMatrixDependsOn(v any) ([]*matrixSelector, error) {
207+
if v == nil {
208+
return nil, nil
209+
}
210+
211+
switch val := v.(type) {
212+
case string:
213+
return []*matrixSelector{{Key: val}}, nil
214+
215+
case []string:
216+
var selectors []*matrixSelector
217+
for _, key := range val {
218+
selectors = append(selectors, &matrixSelector{Key: key})
219+
}
220+
return selectors, nil
221+
222+
case []any:
223+
var selectors []*matrixSelector
224+
for i, item := range val {
225+
switch itemVal := item.(type) {
226+
case string:
227+
selectors = append(selectors, &matrixSelector{Key: itemVal})
228+
229+
case map[string]any:
230+
sel, err := parseMatrixSelectorMap(itemVal)
231+
if err != nil {
232+
return nil, fmt.Errorf("depends_on[%d]: %w", i, err)
233+
}
234+
selectors = append(selectors, sel)
235+
236+
default:
237+
return nil, fmt.Errorf("depends_on[%d]: unexpected type %T", i, item)
238+
}
239+
}
240+
return selectors, nil
241+
242+
default:
243+
return nil, fmt.Errorf("depends_on must be string or array, got %T", v)
244+
}
245+
}
246+
247+
func parseMatrixSelectorMap(m map[string]any) (*matrixSelector, error) {
248+
sel := &matrixSelector{}
249+
250+
key, ok := m["key"]
251+
if !ok {
252+
return nil, fmt.Errorf("selector missing 'key' field")
253+
}
254+
keyStr, ok := key.(string)
255+
if !ok {
256+
return nil, fmt.Errorf("selector 'key' must be a string")
257+
}
258+
sel.Key = keyStr
259+
260+
if matrix, ok := m["matrix"]; ok {
261+
matrixMap, ok := matrix.(map[string]any)
262+
if !ok {
263+
return nil, fmt.Errorf("selector 'matrix' must be a map")
264+
}
265+
sel.Matrix = make(map[string]string)
266+
for k, v := range matrixMap {
267+
s, ok := v.(string)
268+
if !ok {
269+
return nil, fmt.Errorf("selector 'matrix.%s' must be a string", k)
270+
}
271+
sel.Matrix[k] = s
272+
}
273+
}
274+
275+
return sel, nil
276+
}
277+
278+
// expandMatrixSelector expands a selector to matching expanded keys.
279+
func expandMatrixSelector(
280+
sel *matrixSelector,
281+
registry map[string]*matrixConfig,
282+
expandedKeys map[string][]string, // baseKey -> list of expanded keys
283+
) ([]string, error) {
284+
cfg, ok := registry[sel.Key]
285+
if !ok {
286+
// Not a matrix step, just return the key as-is
287+
return []string{sel.Key}, nil
288+
}
289+
290+
if sel.Matrix == nil {
291+
// No filter - return the base key (which is the meta-step)
292+
return []string{sel.Key}, nil
293+
}
294+
295+
// Validate that all selector dimensions exist in the config
296+
for dim := range sel.Matrix {
297+
if _, ok := cfg.Setup[dim]; !ok {
298+
return nil, fmt.Errorf("selector dimension %q not found in matrix for %q", dim, sel.Key)
299+
}
300+
}
301+
302+
// Find all expanded keys that match the selector
303+
allExpanded := expandedKeys[sel.Key]
304+
var matches []string
305+
306+
instances := expandMatrix(cfg)
307+
for i, inst := range instances {
308+
if matchesMatrixSelector(inst, sel) {
309+
if i < len(allExpanded) {
310+
matches = append(matches, allExpanded[i])
311+
}
312+
}
313+
}
314+
315+
if len(matches) == 0 {
316+
return nil, fmt.Errorf("no matches for selector {key: %q, matrix: %v}", sel.Key, sel.Matrix)
317+
}
318+
319+
return matches, nil
320+
}
321+
322+
func matchesMatrixSelector(inst *matrixInstance, sel *matrixSelector) bool {
323+
for dim, val := range sel.Matrix {
324+
if inst.Values[dim] != val {
325+
return false
326+
}
327+
}
328+
return true
329+
}
330+
331+
// hasMatrixPlaceholder checks if a string contains any {{matrix...}} placeholder.
332+
func hasMatrixPlaceholder(s string) bool {
333+
return strings.Contains(s, "{{matrix")
334+
}
335+
336+
// anySliceToStringSlice converts []any to []string.
337+
func anySliceToStringSlice(arr []any) ([]string, error) {
338+
result := make([]string, len(arr))
339+
for i, v := range arr {
340+
s, ok := v.(string)
341+
if !ok {
342+
return nil, fmt.Errorf("element %d is not a string: %T", i, v)
343+
}
344+
result[i] = s
345+
}
346+
return result, nil
347+
}
348+
349+
// deepCopyStepMap creates a deep copy of a step map.
350+
func deepCopyStepMap(step map[string]any) map[string]any {
351+
return deepCopyAnyMap(step)
352+
}
353+
354+
func deepCopyAnyMap(m map[string]any) map[string]any {
355+
result := make(map[string]any)
356+
for k, v := range m {
357+
result[k] = deepCopyAnyValue(v)
358+
}
359+
return result
360+
}
361+
362+
func deepCopyAnyValue(v any) any {
363+
switch val := v.(type) {
364+
case map[string]any:
365+
return deepCopyAnyMap(val)
366+
case []any:
367+
return deepCopyAnySlice(val)
368+
default:
369+
return v
370+
}
371+
}
372+
373+
func deepCopyAnySlice(s []any) []any {
374+
result := make([]any, len(s))
375+
for i, v := range s {
376+
result[i] = deepCopyAnyValue(v)
377+
}
378+
return result
379+
}

0 commit comments

Comments
 (0)