Skip to content

Commit f58062d

Browse files
feat(validations): Add generalized early regex validation (#4919)
2 parents 1b73a57 + 9b20edb commit f58062d

13 files changed

Lines changed: 515 additions & 0 deletions

File tree

community/modules/compute/gke-nodeset/metadata.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,10 @@ spec:
1818
services:
1919
- container.googleapis.com
2020
- storage.googleapis.com
21+
ghpc:
22+
validators:
23+
- validator: regex
24+
inputs:
25+
vars: [slurm_cluster_name]
26+
pattern: "^[a-z](?:[a-z0-9]{0,9})$"
27+
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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,9 @@ spec:
1818
services: []
1919
ghpc:
2020
has_to_be_used: true
21+
validators:
22+
- validator: regex
23+
inputs:
24+
vars: [partition_name]
25+
pattern: "^[a-z](?:[a-z0-9]*)$"
26+
error_message: "'partition_name' must start with a lowercase letter and can only contain lowercase letters and numbers."

community/modules/scheduler/schedmd-slurm-gcp-v6-controller/metadata.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,10 @@ spec:
1919
- compute.googleapis.com
2020
- iam.googleapis.com
2121
- storage.googleapis.com
22+
ghpc:
23+
validators:
24+
- validator: regex
25+
inputs:
26+
vars: [slurm_cluster_name]
27+
pattern: "^[a-z](?:[a-z0-9]{0,9})$"
28+
error_message: "'slurm_cluster_name' must contain only lowercase letters and numbers, start with a letter, and be 1-10 characters long."

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,9 @@ spec:
1919
- container.googleapis.com
2020
ghpc:
2121
inject_module_id: internal_ghpc_module_id
22+
validators:
23+
- validator: regex
24+
inputs:
25+
vars: [name]
26+
pattern: "^[a-z]([-a-z0-9]{0,34}[a-z0-9])?$"
27+
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: 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: regex
23+
inputs:
24+
vars: [name]
25+
pattern: "^[a-z]([-a-z0-9]{0,52}[a-z0-9])?$"
26+
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."

pkg/config/config.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@ func (g *Group) Clone() Group {
8585
return c
8686
}
8787

88+
// ModuleIndex returns the index of the input module in the group
89+
// return -1 if not found
90+
func (g Group) ModuleIndex(id ModuleID) int {
91+
for i, m := range g.Modules {
92+
if m.ID == id {
93+
return i
94+
}
95+
}
96+
return -1
97+
}
98+
8899
// Kind returns the kind of all the modules in the group.
89100
// If the group contains modules of different kinds, it returns UnknownKind
90101
func (g Group) Kind() ModuleKind {
@@ -280,6 +291,9 @@ type Blueprint struct {
280291
path string
281292
// records of intentions to stage file (populated by ghpc_stage function)
282293
stagedFiles map[string]string
294+
// YamlCtx holds parsed YAML positions so validators can tell if a module setting
295+
// was explicitly present in the user's source (runtime-only, not serialized).
296+
YamlCtx *YamlCtx `yaml:"-"`
283297
}
284298

285299
func (bp *Blueprint) Clone() Blueprint {
@@ -456,6 +470,9 @@ func NewBlueprint(path string) (Blueprint, *YamlCtx, error) {
456470
return Blueprint{}, &ctx, err
457471
}
458472
bp.path = absPath
473+
// Attach parsed YAML context to the Blueprint so validators can determine
474+
// whether a module.setting path was explicitly present in the user's YAML.
475+
bp.YamlCtx = &ctx
459476
return bp, &ctx, nil
460477
}
461478

pkg/config/config_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,9 @@ func (s *zeroSuite) TestNewBlueprint(c *C) {
355355
c.Assert(err, IsNil)
356356

357357
bp.path = outFile // set expected path
358+
// NewBlueprint populates a runtime-only YamlCtx (positions in source YAML).
359+
// Reflect that in the expected blueprint before doing a DeepEquals compare.
360+
bp.YamlCtx = newBp.YamlCtx
358361
c.Assert(bp, DeepEquals, newBp)
359362
}
360363

pkg/modulereader/metadata.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ type MetadataGhpc struct {
4949
InjectModuleId string `yaml:"inject_module_id"`
5050
// If set to true, the creation will fail if the module is not used.
5151
HasToBeUsed bool `yaml:"has_to_be_used"`
52+
// NEW: Add a slice to hold validation rules
53+
Validators []ValidationRule `yaml:"validators"`
54+
}
55+
56+
// NEW: Define the struct for a single validation rule
57+
type ValidationRule struct {
58+
Validator string `yaml:"validator"` // e.g., "regex", "onlyOneOf", "min", "max"
59+
Inputs map[string]interface{} `yaml:"inputs"` // Flexible parameters for the rule (e.g., pattern, vars)
60+
61+
ErrorMessage string `yaml:"error_message"`
5262
}
5363

5464
// GetMetadata reads and parses `metadata.yaml` from module root.
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright 2025 "Google LLC"
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package validators
16+
17+
import (
18+
"fmt"
19+
"strings"
20+
21+
"hpc-toolkit/pkg/config"
22+
"hpc-toolkit/pkg/modulereader"
23+
24+
"github.com/zclconf/go-cty/cty"
25+
)
26+
27+
// getNestedValue retrieves a cty.Value from a Dict using a dot-separated path.
28+
func getNestedValue(d config.Dict, path string) (cty.Value, bool) {
29+
parts := strings.Split(path, ".")
30+
currentVal := d.AsObject()
31+
32+
for i, part := range parts {
33+
if !currentVal.Type().IsObjectType() && !currentVal.Type().IsMapType() {
34+
return cty.NilVal, false
35+
}
36+
37+
if !currentVal.Type().HasAttribute(part) {
38+
return cty.NilVal, false
39+
}
40+
41+
val := currentVal.GetAttr(part)
42+
if i == len(parts)-1 {
43+
return val, true
44+
}
45+
currentVal = val
46+
}
47+
return cty.NilVal, false
48+
}
49+
50+
// evaluateAndFlatten converts a cty.Value into a slice of cty.Value elements.
51+
// If it's a list/tuple, returns elements; otherwise returns single-item slice.
52+
func evaluateAndFlatten(val cty.Value) []cty.Value {
53+
var values []cty.Value
54+
if val.Type().IsListType() || val.Type().IsTupleType() {
55+
for it := val.ElementIterator(); it.Next(); {
56+
_, v := it.Element()
57+
values = append(values, v)
58+
}
59+
} else {
60+
values = append(values, val)
61+
}
62+
return values
63+
}
64+
65+
// getModuleSettingValues retrieves a cty.Value from module settings using a dot-separated path,
66+
// evaluates expressions via bp.Eval and returns flattened slice + path for errors.
67+
func getModuleSettingValues(bp config.Blueprint, group config.Group, modIdx int, mod config.Module, settingName string) ([]cty.Value, config.Path, error) {
68+
var nilPath config.Path
69+
70+
// Determine canonical path for this module.setting in the blueprint.
71+
groupIndex := bp.GroupIndex(group.Name)
72+
path := config.Root.Groups.At(groupIndex).Modules.At(modIdx).Settings.Dot(settingName)
73+
74+
// If YAML context exists and the path is not present in the user's YAML,
75+
// treat the setting as absent so validators skip it (honoring optional:true).
76+
77+
if bp.YamlCtx != nil {
78+
if _, ok := bp.YamlCtx.Pos(path); !ok {
79+
return nil, nilPath, fmt.Errorf("setting %q not present in blueprint YAML for module %q", settingName, mod.ID)
80+
}
81+
}
82+
83+
val, _ := getNestedValue(mod.Settings, settingName)
84+
85+
if evaledVal, err := bp.Eval(val); err == nil {
86+
val = evaledVal
87+
}
88+
values := evaluateAndFlatten(val)
89+
90+
return values, path, nil
91+
}
92+
93+
// parseStringList normalizes an input that may be a single string, []interface{} or nil into []string.
94+
func parseStringList(v interface{}) ([]string, bool) {
95+
if v == nil {
96+
return nil, false
97+
}
98+
switch vv := v.(type) {
99+
case string:
100+
return []string{vv}, true
101+
case []interface{}:
102+
out := make([]string, 0, len(vv))
103+
for _, e := range vv {
104+
s, ok := e.(string)
105+
if !ok {
106+
return nil, false
107+
}
108+
out = append(out, s)
109+
}
110+
return out, true
111+
case []string:
112+
return vv, true
113+
default:
114+
return nil, false
115+
}
116+
}
117+
118+
// Target represents a resolved target (module-setting or blueprint var) for validation.
119+
type Target struct {
120+
Name string
121+
Values []cty.Value
122+
Path config.Path
123+
IsBlueprint bool // true if came from blueprint vars, false if module.settings
124+
}
125+
126+
// processModuleSettings processes a list of names interpreted as module settings.
127+
func processModuleSettings(bp config.Blueprint, mod config.Module, group config.Group, modIdx int, list []string, optional bool, handler func(Target) error) error {
128+
for _, s := range list {
129+
values, path, err := getModuleSettingValues(bp, group, modIdx, mod, s)
130+
if err != nil {
131+
if optional {
132+
continue
133+
}
134+
missingPath := config.Root.Groups.At(bp.GroupIndex(group.Name)).Modules.At(modIdx).Settings.Dot(s)
135+
return config.BpError{
136+
Err: fmt.Errorf("setting %q not found in module %q settings", s, mod.ID),
137+
Path: missingPath,
138+
}
139+
}
140+
if err := handler(Target{Name: s, Values: values, Path: path, IsBlueprint: false}); err != nil {
141+
return err
142+
}
143+
}
144+
return nil
145+
}
146+
147+
// IterateRuleTargets resolves vars/settings from a validation rule according to scope and optional semantics,
148+
// and calls the provided handler for each resolved Target. The handler may return an error to stop iteration.
149+
func IterateRuleTargets(
150+
bp config.Blueprint,
151+
mod config.Module,
152+
rule modulereader.ValidationRule,
153+
group config.Group,
154+
modIdx int,
155+
handler func(Target) error,
156+
) error {
157+
158+
// Only support `vars:` in module metadata validators (each treated as a module.setting name).
159+
// `settings:` support has been removed to enforce a single convention.
160+
varsList, _ := parseStringList(rule.Inputs["vars"])
161+
optional := true
162+
if v, ok := rule.Inputs["optional"]; ok {
163+
if b, ok := v.(bool); ok {
164+
optional = b
165+
}
166+
}
167+
168+
// Interpret each var name as module.settings.<name>
169+
return processModuleSettings(bp, mod, group, modIdx, varsList, optional, handler)
170+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License and limitations under the License.
11+
12+
package validators
13+
14+
import (
15+
"fmt"
16+
"regexp"
17+
18+
"hpc-toolkit/pkg/config"
19+
"hpc-toolkit/pkg/modulereader"
20+
21+
"github.com/zclconf/go-cty/cty"
22+
)
23+
24+
// RegexValidator implements the Validator interface for 'regex' type.
25+
type RegexValidator struct{}
26+
27+
// Validate checks if the variables specified in the rule match the provided regex pattern.
28+
// This function focuses on the predicate and uses IterateRuleTargets from targets.go to resolve targets.
29+
func (r *RegexValidator) Validate(
30+
bp config.Blueprint,
31+
mod config.Module,
32+
rule modulereader.ValidationRule,
33+
group config.Group,
34+
modIdx int) error {
35+
36+
// Extract pattern
37+
patternRaw, ok := rule.Inputs["pattern"].(string)
38+
if !ok || patternRaw == "" {
39+
return config.BpError{
40+
Err: fmt.Errorf(
41+
"validation rule for module %q is missing a string 'pattern' in inputs", mod.ID),
42+
Path: config.Root.Groups.At(bp.GroupIndex(group.Name)).Modules.At(modIdx).Source,
43+
}
44+
}
45+
46+
// compile regex
47+
re, err := regexp.Compile(patternRaw)
48+
if err != nil {
49+
return config.BpError{
50+
Err: fmt.Errorf("failed to compile regex for module %q: %v", mod.ID, err),
51+
Path: config.Root.Groups.At(bp.GroupIndex(group.Name)).Modules.At(modIdx).Source,
52+
}
53+
}
54+
55+
// helper: validate flattened cty.Values against regex, returning first error
56+
validateValues := func(values []cty.Value, path config.Path) error {
57+
for _, val := range values {
58+
if val.Type() != cty.String {
59+
continue
60+
}
61+
if !re.MatchString(val.AsString()) {
62+
msg := rule.ErrorMessage
63+
if msg == "" {
64+
msg = fmt.Sprintf("value %q does not match pattern %q", val.AsString(), patternRaw)
65+
}
66+
return config.BpError{Err: fmt.Errorf("%s", msg), Path: path}
67+
}
68+
}
69+
return nil
70+
}
71+
72+
// iterate targets using shared logic
73+
err = IterateRuleTargets(bp, mod, rule, group, modIdx, func(t Target) error {
74+
return validateValues(t.Values, t.Path)
75+
})
76+
return err
77+
}

0 commit comments

Comments
 (0)