Skip to content

Commit b2ec364

Browse files
[raycicmd] Add validation guards for array expansion
Detect three classes of errors early during array expansion: - Duplicate base key: two array steps sharing the same key/name silently overwrote configs - Duplicate element from adjustment: an addition adjustment that duplicates an existing element would produce duplicate pipeline steps - Key collision from sanitization: values like "1.2.1" and "121" that produce the same sanitized key part Also adds a test for partial-dimension skip behavior (skip by one dim removes all matching combinations). Topic: array-validation-guards Labels: draft Signed-off-by: andrew <andrew@anyscale.com>
1 parent 687b155 commit b2ec364

2 files changed

Lines changed: 151 additions & 0 deletions

File tree

raycicmd/array_expand.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,16 @@ func expandSingleArrayStep(
154154
)
155155
}
156156

157+
if _, exists := configs[baseKey]; exists {
158+
return nil, fmt.Errorf(
159+
"duplicate array step key %q", baseKey,
160+
)
161+
}
162+
157163
cfg.elements = elements
158164
configs[baseKey] = cfg
159165

166+
seenKeys := make(map[string]struct{}, len(elements))
160167
var result []map[string]any
161168
for _, elem := range elements {
162169
// Deep-copy step, replacing {{array.X}} placeholders with
@@ -166,6 +173,15 @@ func expandSingleArrayStep(
166173
// → "build-step--cuda1211-python311"
167174
expandedKey := elem.generateKey(baseKey)
168175

176+
if _, dup := seenKeys[expandedKey]; dup {
177+
return nil, fmt.Errorf(
178+
"array step %q: duplicate generated key %q "+
179+
"(values may collide after sanitization)",
180+
baseKey, expandedKey,
181+
)
182+
}
183+
seenKeys[expandedKey] = struct{}{}
184+
169185
if _, hasName := expandedStep["name"]; hasName {
170186
expandedStep["name"] = expandedKey
171187
} else {
@@ -199,6 +215,12 @@ func applyAdjustments(
199215
)
200216
}
201217
}
218+
if hasElement(elements, adj.with) {
219+
return nil, fmt.Errorf(
220+
"addition adjustment with=%v duplicates "+
221+
"an existing element", adj.with,
222+
)
223+
}
202224
elements = append(elements, &arrayElement{
203225
values: maps.Clone(adj.with),
204226
})

raycicmd/array_expand_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,135 @@ func TestExpandArraySteps_GroupDependsOnNonArrayStep(t *testing.T) {
844844
}
845845
}
846846

847+
func TestExpandArraySteps_DuplicateBaseKey(t *testing.T) {
848+
groups := []*pipelineGroup{
849+
{
850+
Group: "build",
851+
Steps: []map[string]any{{
852+
"label": "Build {{array.python}}",
853+
"name": "shared-name",
854+
"commands": []any{"echo build"},
855+
"array": map[string]any{
856+
"python": []any{"3.10"},
857+
},
858+
}},
859+
},
860+
{
861+
Group: "test",
862+
Steps: []map[string]any{{
863+
"label": "Test {{array.python}}",
864+
"name": "shared-name",
865+
"commands": []any{"echo test"},
866+
"array": map[string]any{
867+
"python": []any{"3.11"},
868+
},
869+
}},
870+
},
871+
}
872+
873+
err := expandArraySteps(groups)
874+
if err == nil {
875+
t.Fatal("expected error for duplicate base key, got nil")
876+
}
877+
if !strings.Contains(err.Error(), "duplicate array step key") {
878+
t.Errorf(
879+
"error = %q, want to contain \"duplicate array step key\"",
880+
err.Error(),
881+
)
882+
}
883+
}
884+
885+
func TestExpandArraySteps_DuplicateElementFromAdjustment(t *testing.T) {
886+
groups := []*pipelineGroup{{
887+
Group: "build",
888+
Steps: []map[string]any{{
889+
"label": "Build {{array.python}}",
890+
"key": "build-step",
891+
"commands": []any{"echo build"},
892+
"array": map[string]any{
893+
"python": []any{"3.10", "3.11"},
894+
"adjustments": []any{
895+
map[string]any{
896+
"with": map[string]any{"python": "3.10"},
897+
},
898+
},
899+
},
900+
}},
901+
}}
902+
903+
err := expandArraySteps(groups)
904+
if err == nil {
905+
t.Fatal("expected error for duplicate element, got nil")
906+
}
907+
if !strings.Contains(err.Error(), "duplicates") {
908+
t.Errorf(
909+
"error = %q, want to contain \"duplicates\"",
910+
err.Error(),
911+
)
912+
}
913+
}
914+
915+
func TestExpandArraySteps_KeyCollisionFromSanitization(t *testing.T) {
916+
groups := []*pipelineGroup{{
917+
Group: "build",
918+
Steps: []map[string]any{{
919+
"label": "Build {{array.ver}}",
920+
"key": "build-step",
921+
"commands": []any{"echo build"},
922+
"array": map[string]any{
923+
"ver": []any{"1.2.1", "121"},
924+
},
925+
}},
926+
}}
927+
928+
err := expandArraySteps(groups)
929+
if err == nil {
930+
t.Fatal("expected error for key collision, got nil")
931+
}
932+
if !strings.Contains(err.Error(), "duplicate generated key") {
933+
t.Errorf(
934+
"error = %q, want to contain \"duplicate generated key\"",
935+
err.Error(),
936+
)
937+
}
938+
}
939+
940+
func TestExpandArraySteps_PartialDimensionSkip(t *testing.T) {
941+
groups := []*pipelineGroup{{
942+
Group: "build",
943+
Steps: []map[string]any{{
944+
"label": "Build {{array.os}} {{array.arch}}",
945+
"key": "build-step",
946+
"commands": []any{"echo {{array.os}} {{array.arch}}"},
947+
"array": map[string]any{
948+
"os": []any{"linux", "windows"},
949+
"arch": []any{"amd64", "arm64"},
950+
"adjustments": []any{
951+
map[string]any{
952+
"with": map[string]any{"os": "windows"},
953+
"skip": true,
954+
},
955+
},
956+
},
957+
}},
958+
}}
959+
960+
if err := expandArraySteps(groups); err != nil {
961+
t.Fatalf("expandArraySteps() error = %v", err)
962+
}
963+
964+
rs := groups[0].resolvedSteps
965+
if len(rs) != 2 {
966+
t.Fatalf("got %d resolvedSteps, want 2", len(rs))
967+
}
968+
for _, r := range rs {
969+
key := r.src["key"].(string)
970+
if strings.Contains(key, "oswindows") {
971+
t.Errorf("skipped os=windows should not appear: %q", key)
972+
}
973+
}
974+
}
975+
847976
func TestParseArrayDependsOnErrors(t *testing.T) {
848977
tests := []struct {
849978
name string

0 commit comments

Comments
 (0)