Skip to content

Commit 570e6e7

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 570e6e7

File tree

2 files changed

+828
-0
lines changed

2 files changed

+828
-0
lines changed

raycicmd/matrix_expand.go

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

0 commit comments

Comments
 (0)