Skip to content

Commit 1cad207

Browse files
authored
Merge pull request #111 from ethpandaops/feat/validate-mode
feat: add `validate` command to validate configuration files
2 parents 9515da4 + 896e71b commit 1cad207

File tree

3 files changed

+237
-4
lines changed

3 files changed

+237
-4
lines changed

cmd/validate.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
6+
"github.com/ethpandaops/assertoor/pkg/coordinator"
7+
"github.com/sirupsen/logrus"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
var validateCmd = &cobra.Command{
12+
Use: "validate",
13+
Short: "Validate assertoor configuration",
14+
Long: `Validates the assertoor configuration file for syntax and semantic correctness`,
15+
Args: cobra.NoArgs,
16+
Run: func(_ *cobra.Command, _ []string) {
17+
// Set up minimal logging for error output
18+
logrus.SetLevel(logrus.ErrorLevel)
19+
if verbose {
20+
logrus.SetLevel(logrus.DebugLevel)
21+
}
22+
23+
// Check if config file is specified
24+
if cfgFile == "" {
25+
logrus.Error("no configuration file specified")
26+
os.Exit(1)
27+
}
28+
29+
// Load configuration
30+
config, err := coordinator.NewConfig(cfgFile)
31+
if err != nil {
32+
logrus.WithError(err).Error("failed to load configuration")
33+
os.Exit(1)
34+
}
35+
36+
// Validate configuration
37+
if err := config.Validate(); err != nil {
38+
logrus.WithError(err).Error("configuration validation failed")
39+
os.Exit(1)
40+
}
41+
42+
// Validate external test files exist and can be parsed
43+
for _, extTest := range config.ExternalTests {
44+
if extTest.File != "" {
45+
// Check if external test file exists
46+
if _, err := os.Stat(extTest.File); os.IsNotExist(err) {
47+
logrus.WithField("test", extTest.File).Error("external test file does not exist")
48+
os.Exit(1)
49+
}
50+
}
51+
}
52+
53+
// Success - exit cleanly with no output
54+
os.Exit(0)
55+
},
56+
}
57+
58+
func init() {
59+
rootCmd.AddCommand(validateCmd)
60+
}

pkg/coordinator/config.go

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package coordinator
22

33
import (
4+
"fmt"
5+
"net/url"
46
"os"
7+
"strconv"
8+
"strings"
59

610
"github.com/ethpandaops/assertoor/pkg/coordinator/clients"
711
"github.com/ethpandaops/assertoor/pkg/coordinator/db"
812
"github.com/ethpandaops/assertoor/pkg/coordinator/helper"
913
"github.com/ethpandaops/assertoor/pkg/coordinator/names"
14+
"github.com/ethpandaops/assertoor/pkg/coordinator/test"
1015
"github.com/ethpandaops/assertoor/pkg/coordinator/types"
1116
web_types "github.com/ethpandaops/assertoor/pkg/coordinator/web/types"
1217
"gopkg.in/yaml.v3"
@@ -26,7 +31,7 @@ type Config struct {
2631
ValidatorNames *names.Config `yaml:"validatorNames" json:"validatorNames"`
2732

2833
// Global variables
29-
GlobalVars map[string]interface{} `yaml:"globalVars" json:"globalVars"`
34+
GlobalVars map[string]any `yaml:"globalVars" json:"globalVars"`
3035

3136
// Coordinator config
3237
Coordinator *CoordinatorConfig `yaml:"coordinator" json:"coordinator"`
@@ -57,7 +62,7 @@ func DefaultConfig() *Config {
5762
ConsensusURL: "http://localhost:5052",
5863
},
5964
},
60-
GlobalVars: make(map[string]interface{}),
65+
GlobalVars: make(map[string]any),
6166
Coordinator: &CoordinatorConfig{},
6267
Tests: []*types.TestConfig{},
6368
ExternalTests: []*types.ExternalTestConfig{},
@@ -82,3 +87,113 @@ func NewConfig(path string) (*Config, error) {
8287

8388
return config, nil
8489
}
90+
91+
func (c *Config) Validate() error {
92+
var errs []error
93+
94+
// Validate database config
95+
if c.Database != nil {
96+
if c.Database.Engine != "" && c.Database.Engine != "sqlite" && c.Database.Engine != "postgres" {
97+
errs = append(errs, fmt.Errorf("invalid database engine: %s", c.Database.Engine))
98+
}
99+
}
100+
101+
// Validate endpoints
102+
for i, endpoint := range c.Endpoints {
103+
if endpoint.Name == "" {
104+
errs = append(errs, fmt.Errorf("endpoint[%d]: name cannot be empty", i))
105+
}
106+
107+
if endpoint.ConsensusURL == "" && endpoint.ExecutionURL == "" {
108+
errs = append(errs, fmt.Errorf("endpoint[%d] '%s': must have at least one URL", i, endpoint.Name))
109+
}
110+
// Validate URLs are parseable
111+
if endpoint.ConsensusURL != "" {
112+
if _, err := url.Parse(endpoint.ConsensusURL); err != nil {
113+
errs = append(errs, fmt.Errorf("endpoint[%d] '%s': invalid consensus URL: %v", i, endpoint.Name, err))
114+
}
115+
}
116+
117+
if endpoint.ExecutionURL != "" {
118+
if _, err := url.Parse(endpoint.ExecutionURL); err != nil {
119+
errs = append(errs, fmt.Errorf("endpoint[%d] '%s': invalid execution URL: %v", i, endpoint.Name, err))
120+
}
121+
}
122+
}
123+
124+
// Validate web config
125+
if c.Web != nil {
126+
if c.Web.Frontend != nil && c.Web.Frontend.Enabled {
127+
// Validate port is in valid range
128+
if c.Web.Server.Port != "" {
129+
if port, err := strconv.Atoi(c.Web.Server.Port); err != nil {
130+
errs = append(errs, fmt.Errorf("invalid web server port: %s (must be a number)", c.Web.Server.Port))
131+
} else if port < 1 || port > 65535 {
132+
errs = append(errs, fmt.Errorf("invalid web server port: %d (must be between 1 and 65535)", port))
133+
}
134+
}
135+
}
136+
}
137+
138+
// Validate coordinator config
139+
if c.Coordinator != nil {
140+
if err := c.Coordinator.Validate(); err != nil {
141+
errs = append(errs, fmt.Errorf("coordinator config: %v", err))
142+
}
143+
}
144+
145+
// Validate tests
146+
for i, testCfg := range c.Tests {
147+
if testCfg.ID == "" {
148+
errs = append(errs, fmt.Errorf("test[%d]: ID cannot be empty", i))
149+
}
150+
151+
if testCfg.Name == "" {
152+
errs = append(errs, fmt.Errorf("test[%d] '%s': name cannot be empty", i, testCfg.ID))
153+
}
154+
155+
// Validate task configurations
156+
if err := test.ValidateTestConfig(testCfg); err != nil {
157+
errs = append(errs, fmt.Errorf("test[%d] '%s': %v", i, testCfg.ID, err))
158+
}
159+
}
160+
161+
// Validate external tests
162+
for i, extTest := range c.ExternalTests {
163+
if extTest.File == "" {
164+
errs = append(errs, fmt.Errorf("external test[%d]: file cannot be empty", i))
165+
}
166+
167+
if extTest.ID == "" {
168+
errs = append(errs, fmt.Errorf("external test[%d]: ID cannot be empty", i))
169+
}
170+
}
171+
172+
if len(errs) > 0 {
173+
return fmt.Errorf("configuration validation failed:\n%s", formatErrors(errs))
174+
}
175+
176+
return nil
177+
}
178+
179+
func formatErrors(errs []error) string {
180+
var buf strings.Builder
181+
for _, err := range errs {
182+
buf.WriteString(" - ")
183+
buf.WriteString(err.Error())
184+
buf.WriteString("\n")
185+
}
186+
187+
return strings.TrimSuffix(buf.String(), "\n")
188+
}
189+
190+
func (c *CoordinatorConfig) Validate() error {
191+
if c.TestRetentionTime.Duration != 0 {
192+
// Duration is valid if it parsed successfully
193+
if c.TestRetentionTime.Duration < 0 {
194+
return fmt.Errorf("testRetentionTime cannot be negative")
195+
}
196+
}
197+
198+
return nil
199+
}

pkg/coordinator/test/test.go

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import (
66
"time"
77

88
"github.com/ethpandaops/assertoor/pkg/coordinator/db"
9+
"github.com/ethpandaops/assertoor/pkg/coordinator/logger"
910
"github.com/ethpandaops/assertoor/pkg/coordinator/scheduler"
11+
"github.com/ethpandaops/assertoor/pkg/coordinator/tasks"
1012
"github.com/ethpandaops/assertoor/pkg/coordinator/types"
1113
"github.com/ethpandaops/assertoor/pkg/coordinator/vars"
1214
"github.com/jmoiron/sqlx"
@@ -31,11 +33,11 @@ type Test struct {
3133
timeout time.Duration
3234
}
3335

34-
func CreateTest(runID uint64, descriptor types.TestDescriptor, logger logrus.FieldLogger, services types.TaskServices, configOverrides map[string]any) (types.TestRunner, error) {
36+
func CreateTest(runID uint64, descriptor types.TestDescriptor, log logrus.FieldLogger, services types.TaskServices, configOverrides map[string]any) (types.TestRunner, error) {
3537
test := &Test{
3638
runID: runID,
3739
services: services,
38-
logger: logger.WithField("RunID", runID).WithField("TestID", descriptor.ID()),
40+
logger: log.WithField("RunID", runID).WithField("TestID", descriptor.ID()),
3941
descriptor: descriptor,
4042
config: descriptor.Config(),
4143
status: types.TestStatusPending,
@@ -252,3 +254,59 @@ func (t *Test) GetTaskScheduler() types.TaskScheduler {
252254
func (t *Test) GetTestVariables() types.Variables {
253255
return t.variables
254256
}
257+
258+
// ValidateTestConfig validates a test configuration including its task configurations
259+
func ValidateTestConfig(config *types.TestConfig) error {
260+
if len(config.Tasks) == 0 {
261+
return fmt.Errorf("test must have at least one task")
262+
}
263+
264+
// Import the tasks package to get access to GetTaskDescriptor
265+
tasksRegistry := tasks.GetTaskDescriptor
266+
267+
// Validate each task configuration
268+
for i, rawTask := range config.Tasks {
269+
// Parse the raw task message into TaskOptions
270+
var taskOptions types.TaskOptions
271+
if err := rawTask.Unmarshal(&taskOptions); err != nil {
272+
return fmt.Errorf("task[%d]: failed to parse task configuration: %v", i, err)
273+
}
274+
275+
// Check if task type exists
276+
taskDescriptor := tasksRegistry(taskOptions.Name)
277+
if taskDescriptor == nil {
278+
return fmt.Errorf("task[%d]: unknown task type '%s'", i, taskOptions.Name)
279+
}
280+
281+
// For validation purposes, we only need to check if the config can be loaded
282+
// We don't need to create the actual task instance since that requires runtime context
283+
// Instead, we'll create a minimal task instance just to validate the config structure
284+
285+
// Check if task has a config
286+
if taskOptions.Config == nil {
287+
// Some tasks don't require config, that's ok
288+
continue
289+
}
290+
291+
// Create a temporary task context for validation
292+
tmpCtx := &types.TaskContext{
293+
Logger: logger.NewLogger(&logger.ScopeOptions{
294+
Parent: logrus.StandardLogger(),
295+
}),
296+
Vars: vars.NewVariables(nil), // Empty variables for validation
297+
}
298+
299+
// Try to create the task with minimal context
300+
taskInst, err := taskDescriptor.NewTask(tmpCtx, &taskOptions)
301+
if err != nil {
302+
return fmt.Errorf("task[%d] '%s': failed to create task instance: %v", i, taskOptions.Name, err)
303+
}
304+
305+
// Load and validate the task configuration
306+
if err := taskInst.LoadConfig(); err != nil {
307+
return fmt.Errorf("task[%d] '%s': %v", i, taskOptions.Name, err)
308+
}
309+
}
310+
311+
return nil
312+
}

0 commit comments

Comments
 (0)