Skip to content

Commit 9b2b3bf

Browse files
committed
feat(validators): avoid implicit blueprint fallback by adding explicit scope
1 parent 9e7df0a commit 9b2b3bf

7 files changed

Lines changed: 279 additions & 108 deletions

File tree

community/modules/compute/notebook/metadata.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ ghpc:
2424
inputs:
2525
vars: [deployment_name]
2626
pattern: "^[a-z](?:[a-z0-9]{0,6})$"
27+
scope: module
2728
error_message: "'deployment_name' must contain only lowercase letters and numbers, start with a letter, and be 1-7 characters long."

community/modules/compute/schedmd-slurm-gcp-v6-nodeset/metadata.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,6 @@ ghpc:
2323
- validator: regex
2424
inputs:
2525
vars: [slurm_cluster_name]
26+
scope: blueprint
2627
pattern: "^[a-z](?:[a-z0-9]{0,9})$"
2728
error_message: "'slurm_cluster_name' must contain only lowercase letters and numbers, start with a letter, and be 1-10 characters long."

community/modules/compute/schedmd-slurm-gcp-v6-partition/metadata.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ ghpc:
2323
inputs:
2424
vars: [partition_name]
2525
pattern: "^[a-z](?:[a-z0-9]*)$"
26+
scope: blueprint
2627
error_message: "'partition_name' must start with a lowercase letter and can only contain lowercase letters and numbers."

modules/compute/gke-node-pool/metadata.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ ghpc:
2424
inputs:
2525
vars: [name]
2626
pattern: "^[a-z]([-a-z0-9]{0,34}[a-z0-9])?$"
27+
scope: module
2728
error_message: "'name' must start with a lowercase letter, followed by up to 34 lowercase letters, numbers, or hyphens, and cannot end with a hyphen."

modules/compute/resource-policy/metadata.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ ghpc:
2323
inputs:
2424
vars: [name]
2525
pattern: "^[a-z]([-a-z0-9]{0,52}[a-z0-9])?$"
26+
scope: module
2627
error_message: "'name' must start with a lowercase letter, followed by up to 52 lowercase letters, numbers, or hyphens, and cannot end with a hyphen."
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
package validators
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"hpc-toolkit/pkg/config"
8+
"hpc-toolkit/pkg/modulereader"
9+
10+
"github.com/zclconf/go-cty/cty"
11+
)
12+
13+
// getNestedValue retrieves a cty.Value from a Dict using a dot-separated path.
14+
func getNestedValue(d config.Dict, path string) (cty.Value, bool) {
15+
parts := strings.Split(path, ".")
16+
currentVal := d.AsObject()
17+
18+
for i, part := range parts {
19+
if !currentVal.Type().IsObjectType() && !currentVal.Type().IsMapType() {
20+
return cty.NilVal, false
21+
}
22+
23+
if !currentVal.Type().HasAttribute(part) {
24+
return cty.NilVal, false
25+
}
26+
27+
val := currentVal.GetAttr(part)
28+
if i == len(parts)-1 {
29+
return val, true
30+
}
31+
currentVal = val
32+
}
33+
return cty.NilVal, false
34+
}
35+
36+
// evaluateAndFlatten converts a cty.Value into a slice of cty.Value elements.
37+
// If it's a list/tuple, returns elements; otherwise returns single-item slice.
38+
func evaluateAndFlatten(val cty.Value) []cty.Value {
39+
var values []cty.Value
40+
if val.Type().IsListType() || val.Type().IsTupleType() {
41+
for it := val.ElementIterator(); it.Next(); {
42+
_, v := it.Element()
43+
values = append(values, v)
44+
}
45+
} else {
46+
values = append(values, val)
47+
}
48+
return values
49+
}
50+
51+
// getBlueprintValues retrieves a cty.Value from blueprint variables by name,
52+
// evaluates it and returns the flattened values and the path to use for errors.
53+
func getBlueprintValues(bp config.Blueprint, varName string) ([]cty.Value, config.Path, error) {
54+
var nilPath config.Path
55+
56+
if !bp.Vars.Has(varName) {
57+
return nil, nilPath, fmt.Errorf("variable %q not found in blueprint vars", varName)
58+
}
59+
val := bp.Vars.Get(varName)
60+
if evaledVal, err := bp.Eval(val); err == nil {
61+
val = evaledVal
62+
}
63+
values := evaluateAndFlatten(val)
64+
return values, config.Root.Vars.Dot(varName), nil
65+
}
66+
67+
// getModuleSettingValues retrieves a cty.Value from module settings using a dot-separated path,
68+
// evaluates expressions via bp.Eval and returns flattened slice + path for errors.
69+
func getModuleSettingValues(bp config.Blueprint, group config.Group, modIdx int, mod config.Module, settingName string) ([]cty.Value, config.Path, error) {
70+
var nilPath config.Path
71+
72+
val, found := getNestedValue(mod.Settings, settingName)
73+
if !found {
74+
return nil, nilPath, fmt.Errorf("setting %q not found in module %q settings", settingName, mod.ID)
75+
}
76+
if evaledVal, err := bp.Eval(val); err == nil {
77+
val = evaledVal
78+
}
79+
values := evaluateAndFlatten(val)
80+
81+
groupIndex := bp.GroupIndex(group.Name)
82+
path := config.Root.Groups.At(groupIndex).Modules.At(modIdx).Settings.Dot(settingName)
83+
84+
return values, path, nil
85+
}
86+
87+
// valuesEqualBlueprint returns true when the provided flattened module values are
88+
// equal to the flattened blueprint var with the same name. Comparison is only done
89+
// for string values; non-comparable types return false.
90+
func valuesEqualBlueprint(bp config.Blueprint, varName string, moduleValues []cty.Value) bool {
91+
if !bp.Vars.Has(varName) {
92+
return false
93+
}
94+
bpVal := bp.Vars.Get(varName)
95+
if evaledBp, err := bp.Eval(bpVal); err == nil {
96+
bpVal = evaledBp
97+
}
98+
bpVals := evaluateAndFlatten(bpVal)
99+
100+
if len(bpVals) != len(moduleValues) {
101+
return false
102+
}
103+
for i := range bpVals {
104+
if bpVals[i].Type() != cty.String || moduleValues[i].Type() != cty.String {
105+
return false
106+
}
107+
if bpVals[i].AsString() != moduleValues[i].AsString() {
108+
return false
109+
}
110+
}
111+
return true
112+
}
113+
114+
// parseStringList normalizes an input that may be a single string, []interface{} or nil into []string.
115+
func parseStringList(v interface{}) ([]string, bool) {
116+
if v == nil {
117+
return nil, false
118+
}
119+
switch vv := v.(type) {
120+
case string:
121+
return []string{vv}, true
122+
case []interface{}:
123+
out := make([]string, 0, len(vv))
124+
for _, e := range vv {
125+
s, ok := e.(string)
126+
if !ok {
127+
return nil, false
128+
}
129+
out = append(out, s)
130+
}
131+
return out, true
132+
case []string:
133+
return vv, true
134+
default:
135+
return nil, false
136+
}
137+
}
138+
139+
// Target represents a resolved target (module-setting or blueprint var) for validation.
140+
type Target struct {
141+
Name string
142+
Values []cty.Value
143+
Path config.Path
144+
IsBlueprint bool // true if came from blueprint vars, false if module.settings
145+
}
146+
147+
// processModuleSettings processes a list of names interpreted as module settings.
148+
func processModuleSettings(bp config.Blueprint, mod config.Module, group config.Group, modIdx int, list []string, optional bool, handler func(Target) error) error {
149+
for _, s := range list {
150+
values, path, err := getModuleSettingValues(bp, group, modIdx, mod, s)
151+
if err != nil {
152+
if optional {
153+
continue
154+
}
155+
missingPath := config.Root.Groups.At(bp.GroupIndex(group.Name)).Modules.At(modIdx).Settings.Dot(s)
156+
return config.BpError{
157+
Err: fmt.Errorf("setting %q not found in module %q settings", s, mod.ID),
158+
Path: missingPath,
159+
}
160+
}
161+
if err := handler(Target{Name: s, Values: values, Path: path, IsBlueprint: false}); err != nil {
162+
return err
163+
}
164+
}
165+
return nil
166+
}
167+
168+
// processVarsAsBlueprint processes a list of names as blueprint vars.
169+
func processVarsAsBlueprint(bp config.Blueprint, list []string, optional bool, handler func(Target) error) error {
170+
for _, name := range list {
171+
values, path, err := getBlueprintValues(bp, name)
172+
if err != nil {
173+
if optional {
174+
continue
175+
}
176+
// legacy behavior: skip when missing blueprint var even if optional == false
177+
continue
178+
}
179+
if err := handler(Target{Name: name, Values: values, Path: path, IsBlueprint: true}); err != nil {
180+
return err
181+
}
182+
}
183+
return nil
184+
}
185+
186+
// processVarsPreferModuleSetting prefers module.setting for each name; skips if module.setting absent.
187+
func processVarsPreferModuleSetting(bp config.Blueprint, mod config.Module, group config.Group, modIdx int, list []string, handler func(Target) error) error {
188+
for _, vname := range list {
189+
if val, ok := getNestedValue(mod.Settings, vname); ok {
190+
if evaled, err := bp.Eval(val); err == nil {
191+
val = evaled
192+
}
193+
values := evaluateAndFlatten(val)
194+
path := config.Root.Groups.At(bp.GroupIndex(group.Name)).Modules.At(modIdx).Settings.Dot(vname)
195+
if err := handler(Target{Name: vname, Values: values, Path: path, IsBlueprint: false}); err != nil {
196+
return err
197+
}
198+
}
199+
// else: skip (do not fallback to blueprint var)
200+
}
201+
return nil
202+
}
203+
204+
// IterateRuleTargets resolves vars/settings from a validation rule according to scope and optional semantics,
205+
// and calls the provided handler for each resolved Target. The handler may return an error to stop iteration.
206+
func IterateRuleTargets(
207+
bp config.Blueprint,
208+
mod config.Module,
209+
rule modulereader.ValidationRule,
210+
group config.Group,
211+
modIdx int,
212+
handler func(Target) error,
213+
) error {
214+
215+
varsList, _ := parseStringList(rule.Inputs["vars"])
216+
settingsList, _ := parseStringList(rule.Inputs["settings"])
217+
scope, _ := rule.Inputs["scope"].(string)
218+
optional := true
219+
if v, ok := rule.Inputs["optional"]; ok {
220+
if b, ok := v.(bool); ok {
221+
optional = b
222+
}
223+
}
224+
225+
switch scope {
226+
case "module":
227+
if len(settingsList) > 0 {
228+
return processModuleSettings(bp, mod, group, modIdx, settingsList, optional, handler)
229+
}
230+
return processModuleSettings(bp, mod, group, modIdx, varsList, optional, handler)
231+
232+
case "blueprint":
233+
return processVarsAsBlueprint(bp, varsList, optional, handler)
234+
235+
default:
236+
if len(settingsList) > 0 {
237+
if err := processModuleSettings(bp, mod, group, modIdx, settingsList, optional, handler); err != nil {
238+
return err
239+
}
240+
}
241+
return processVarsPreferModuleSetting(bp, mod, group, modIdx, varsList, handler)
242+
}
243+
}

0 commit comments

Comments
 (0)