Skip to content

Commit ace50e1

Browse files
feat(validations): Add AllowedEnumValidator for module-scoped enum checks (#5063)
1 parent db3a2ff commit ace50e1

5 files changed

Lines changed: 289 additions & 1 deletion

File tree

modules/network/gpu-rdma-vpc/metadata.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,10 @@ spec:
1717
requirements:
1818
services:
1919
- compute.googleapis.com
20+
ghpc:
21+
validators:
22+
- validator: allowed_enum
23+
inputs:
24+
vars: [network_routing_mode]
25+
allowed: [GLOBAL, REGIONAL]
26+
error_message: "'network_routing_mode' must be GLOBAL or REGIONAL."

pkg/validators/metadata_validator_helpers.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,18 @@ func IterateRuleTargets(
168168
// Interpret each var name as module.settings.<name>
169169
return processModuleSettings(bp, mod, group, modIdx, varsList, optional, handler)
170170
}
171+
172+
// parseBoolInput retrieves a boolean value from a map by key.
173+
// If the key is missing, it returns the defaultVal.
174+
// If the key is present but not a boolean, it returns an error.
175+
func parseBoolInput(inputs map[string]interface{}, key string, defaultVal bool) (bool, error) {
176+
v, ok := inputs[key]
177+
if !ok {
178+
return defaultVal, nil
179+
}
180+
b, ok := v.(bool)
181+
if !ok {
182+
return false, fmt.Errorf("'%s' must be a boolean, not %T", key, v)
183+
}
184+
return b, nil
185+
}

pkg/validators/metadata_validators.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ package validators
1414
import (
1515
"fmt"
1616
"regexp"
17+
"strings"
1718

1819
"hpc-toolkit/pkg/config"
1920
"hpc-toolkit/pkg/modulereader"
@@ -75,3 +76,119 @@ func (r *RegexValidator) Validate(
7576
})
7677
return err
7778
}
79+
80+
type AllowedEnumValidator struct{}
81+
82+
// normalizeAllowed converts the 'allowed' input (either []string or []interface{}) into a standard string slice.
83+
func (v *AllowedEnumValidator) normalizeAllowed(allowedRaw interface{}) ([]string, error) {
84+
var allowedList []string
85+
switch t := allowedRaw.(type) {
86+
case []string:
87+
allowedList = t
88+
case []interface{}:
89+
for _, e := range t {
90+
allowedList = append(allowedList, fmt.Sprintf("%v", e))
91+
}
92+
default:
93+
return nil, fmt.Errorf("'allowed' must be a list of strings")
94+
}
95+
if len(allowedList) == 0 {
96+
return nil, fmt.Errorf("'allowed' list must be non-empty")
97+
}
98+
return allowedList, nil
99+
}
100+
101+
// checkValues iterates through cty.Values to ensure they exist within the allowed set, handling nulls and casing.
102+
func (v *AllowedEnumValidator) checkValues(values []cty.Value, path config.Path, allowedSet map[string]struct{}, allowedList []string, caseSensitive bool, allowNull bool, errMsg string) error {
103+
for _, val := range values {
104+
if val.IsNull() {
105+
if allowNull {
106+
continue
107+
}
108+
msg := errMsg
109+
if msg == "" {
110+
msg = fmt.Sprintf("null value is not allowed; allowed values: %v", allowedList)
111+
}
112+
return config.BpError{Err: fmt.Errorf("%s", msg), Path: path}
113+
}
114+
115+
if val.Type() != cty.String {
116+
continue
117+
}
118+
119+
str := val.AsString()
120+
key := str
121+
if !caseSensitive {
122+
key = strings.ToLower(str)
123+
}
124+
125+
if _, ok := allowedSet[key]; !ok {
126+
msg := errMsg
127+
if msg == "" {
128+
msg = fmt.Sprintf("invalid value %q; allowed values: %v", str, allowedList)
129+
}
130+
return config.BpError{Err: fmt.Errorf("%s", msg), Path: path}
131+
}
132+
}
133+
return nil
134+
}
135+
136+
// Ensures that user-provided module settings conform to a predefined list of allowed values (enums).
137+
func (v *AllowedEnumValidator) Validate(
138+
bp config.Blueprint,
139+
mod config.Module,
140+
rule modulereader.ValidationRule,
141+
group config.Group,
142+
modIdx int) error {
143+
144+
modPath := config.Root.Groups.At(bp.GroupIndex(group.Name)).Modules.At(modIdx).Source
145+
146+
// 1. Parse Metadata Inputs (flags)
147+
caseSensitive, err := parseBoolInput(rule.Inputs, "case_sensitive", true)
148+
if err != nil {
149+
return config.BpError{
150+
Err: fmt.Errorf("validation rule for module %q: %v", mod.ID, err),
151+
Path: modPath,
152+
}
153+
}
154+
155+
allowNull, err := parseBoolInput(rule.Inputs, "allow_null", false)
156+
if err != nil {
157+
return config.BpError{
158+
Err: fmt.Errorf("validation rule for module %q: %v", mod.ID, err),
159+
Path: modPath,
160+
}
161+
}
162+
163+
// 2. Normalize the 'allowed' list
164+
allowedRaw, ok := rule.Inputs["allowed"]
165+
if !ok {
166+
return config.BpError{
167+
Err: fmt.Errorf("validation rule for module %q is missing an 'allowed' list", mod.ID),
168+
Path: modPath,
169+
}
170+
}
171+
172+
allowedList, err := v.normalizeAllowed(allowedRaw)
173+
if err != nil {
174+
return config.BpError{
175+
Err: fmt.Errorf("validation rule for module %q: %v", mod.ID, err),
176+
Path: modPath,
177+
}
178+
}
179+
180+
// 3. Build the lookup set
181+
allowedSet := make(map[string]struct{}, len(allowedList))
182+
for _, s := range allowedList {
183+
key := s
184+
if !caseSensitive {
185+
key = strings.ToLower(s)
186+
}
187+
allowedSet[key] = struct{}{}
188+
}
189+
190+
// 4. Iterate and validate user-provided values
191+
return IterateRuleTargets(bp, mod, rule, group, modIdx, func(t Target) error {
192+
return v.checkValues(t.Values, t.Path, allowedSet, allowedList, caseSensitive, allowNull, rule.ErrorMessage)
193+
})
194+
}

pkg/validators/metadata_validators_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,151 @@ func TestRegexValidator(t *testing.T) {
140140
}
141141
})
142142
}
143+
144+
func TestAllowedEnumValidator(t *testing.T) {
145+
baseBP := config.Blueprint{
146+
BlueprintName: "test-bp",
147+
Groups: []config.Group{
148+
{
149+
Name: "primary",
150+
Modules: []config.Module{
151+
{
152+
ID: "test-module",
153+
Source: "test/module",
154+
Settings: config.NewDict(map[string]cty.Value{
155+
"network_routing_mode": cty.StringVal("GLOBAL"),
156+
}),
157+
},
158+
},
159+
},
160+
},
161+
}
162+
163+
validator := AllowedEnumValidator{}
164+
165+
t.Run("validates_network_routing_mode_from_metadata_example", func(t *testing.T) {
166+
bp := baseBP
167+
bp.Groups[0].Modules[0].Settings = config.NewDict(map[string]cty.Value{
168+
"network_routing_mode": cty.StringVal("INVALID_MODE"),
169+
})
170+
171+
rule := modulereader.ValidationRule{
172+
Validator: "allowed_enum",
173+
ErrorMessage: "'network_routing_mode' must be GLOBAL or REGIONAL.",
174+
Inputs: map[string]interface{}{
175+
"vars": []interface{}{"network_routing_mode"},
176+
"allowed": []interface{}{"GLOBAL", "REGIONAL"},
177+
},
178+
}
179+
180+
// 1. Test failure with invalid value
181+
err := validator.Validate(bp, bp.Groups[0].Modules[0], rule, bp.Groups[0], 0)
182+
if err == nil {
183+
t.Fatal("expected error for invalid routing mode, got nil")
184+
}
185+
if !strings.Contains(err.Error(), "'network_routing_mode' must be GLOBAL or REGIONAL.") {
186+
t.Fatalf("unexpected error message: %q", err.Error())
187+
}
188+
189+
// 2. Test failure with lowercase value (default case_sensitive is true)
190+
bp.Groups[0].Modules[0].Settings = config.NewDict(map[string]cty.Value{
191+
"network_routing_mode": cty.StringVal("global"),
192+
})
193+
if err := validator.Validate(bp, bp.Groups[0].Modules[0], rule, bp.Groups[0], 0); err == nil {
194+
t.Fatal("expected error for lowercase 'global' due to default case sensitivity, got nil")
195+
}
196+
197+
// 3. Test success with correct uppercase value
198+
bp.Groups[0].Modules[0].Settings = config.NewDict(map[string]cty.Value{
199+
"network_routing_mode": cty.StringVal("GLOBAL"),
200+
})
201+
if err := validator.Validate(bp, bp.Groups[0].Modules[0], rule, bp.Groups[0], 0); err != nil {
202+
t.Fatalf("unexpected error for valid routing mode 'GLOBAL': %v", err)
203+
}
204+
205+
// 4. Test success with correct uppercase value 'REGIONAL'
206+
bp.Groups[0].Modules[0].Settings = config.NewDict(map[string]cty.Value{
207+
"network_routing_mode": cty.StringVal("REGIONAL"),
208+
})
209+
if err := validator.Validate(bp, bp.Groups[0].Modules[0], rule, bp.Groups[0], 0); err != nil {
210+
t.Fatalf("unexpected error for valid routing mode 'REGIONAL': %v", err)
211+
}
212+
})
213+
214+
t.Run("handles_explicit_case_insensitive_matching", func(t *testing.T) {
215+
bp := baseBP
216+
bp.Groups[0].Modules[0].Settings = config.NewDict(map[string]cty.Value{
217+
"tier": cty.StringVal("basic_ssd"),
218+
})
219+
220+
rule := modulereader.ValidationRule{
221+
Validator: "allowed_enum",
222+
Inputs: map[string]interface{}{
223+
"vars": []interface{}{"tier"},
224+
"allowed": []interface{}{"BASIC_SSD"},
225+
"case_sensitive": false,
226+
},
227+
}
228+
229+
if err := validator.Validate(bp, bp.Groups[0].Modules[0], rule, bp.Groups[0], 0); err != nil {
230+
t.Fatalf("expected case-insensitive match to pass, got: %v", err)
231+
}
232+
})
233+
234+
t.Run("fails_on_null_when_allow_null_is_false", func(t *testing.T) {
235+
bp := baseBP
236+
bp.Groups[0].Modules[0].Settings = config.NewDict(map[string]cty.Value{
237+
"tier": cty.NullVal(cty.String),
238+
})
239+
240+
rule := modulereader.ValidationRule{
241+
Validator: "allowed_enum",
242+
Inputs: map[string]interface{}{
243+
"vars": []interface{}{"tier"},
244+
"allowed": []interface{}{"BASIC_SSD"},
245+
"allow_null": false,
246+
},
247+
}
248+
249+
if err := validator.Validate(bp, bp.Groups[0].Modules[0], rule, bp.Groups[0], 0); err == nil {
250+
t.Fatal("expected error for null value when allow_null is false, got nil")
251+
}
252+
})
253+
254+
t.Run("passes_on_null_when_allow_null_is_true", func(t *testing.T) {
255+
bp := baseBP
256+
bp.Groups[0].Modules[0].Settings = config.NewDict(map[string]cty.Value{
257+
"tier": cty.NullVal(cty.String),
258+
})
259+
260+
rule := modulereader.ValidationRule{
261+
Validator: "allowed_enum",
262+
Inputs: map[string]interface{}{
263+
"vars": []interface{}{"tier"},
264+
"allowed": []interface{}{"BASIC_SSD"},
265+
"allow_null": true,
266+
},
267+
}
268+
269+
if err := validator.Validate(bp, bp.Groups[0].Modules[0], rule, bp.Groups[0], 0); err != nil {
270+
t.Fatalf("unexpected error for null value when allow_null is true: %v", err)
271+
}
272+
})
273+
274+
t.Run("fails_with_missing_allowed_input", func(t *testing.T) {
275+
rule := modulereader.ValidationRule{
276+
Validator: "allowed_enum",
277+
Inputs: map[string]interface{}{
278+
"vars": []interface{}{"tier"},
279+
},
280+
}
281+
282+
err := validator.Validate(baseBP, baseBP.Groups[0].Modules[0], rule, baseBP.Groups[0], 0)
283+
if err == nil {
284+
t.Fatal("expected error for missing 'allowed' input, got nil")
285+
}
286+
if !strings.Contains(err.Error(), "missing an 'allowed' list") {
287+
t.Fatalf("unexpected error message: %v", err)
288+
}
289+
})
290+
}

pkg/validators/registry.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ package validators
1616

1717
// Registry maps validation type strings to their corresponding validator implementation.
1818
var Registry = map[string]RuleValidator{
19-
"regex": &RegexValidator{},
19+
"regex": &RegexValidator{},
20+
"allowed_enum": &AllowedEnumValidator{},
2021
}

0 commit comments

Comments
 (0)