go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config/validation/validation.go (about)

     1  // Copyright 2017 The LUCI Authors.
     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 validation provides a helper for performing config validations.
    16  package validation
    17  
    18  import (
    19  	"context"
    20  	"fmt"
    21  	"strings"
    22  
    23  	"go.chromium.org/luci/common/errors"
    24  	"go.chromium.org/luci/common/logging"
    25  
    26  	configpb "go.chromium.org/luci/common/proto/config"
    27  )
    28  
    29  // Error is an error with details of validation issues.
    30  //
    31  // Returned by Context.Finalize().
    32  type Error struct {
    33  	// Errors is a list of individual validation errors.
    34  	//
    35  	// Each one is annotated with "file" string, logical path pointing to
    36  	// the element that contains the error, and its severity. It is provided as a
    37  	// slice of strings in "element" annotation.
    38  	Errors errors.MultiError
    39  }
    40  
    41  // Error makes *Error implement 'error' interface.
    42  func (e *Error) Error() string {
    43  	return e.Errors.Error()
    44  }
    45  
    46  // WithSeverity returns a multi-error with errors of a given severity only.
    47  func (e *Error) WithSeverity(s Severity) error {
    48  	var filtered errors.MultiError
    49  	for _, valErr := range e.Errors {
    50  		if severity, ok := SeverityTag.In(valErr); ok && severity == s {
    51  			filtered = append(filtered, valErr)
    52  		}
    53  	}
    54  	if len(filtered) != 0 {
    55  		return filtered
    56  	}
    57  	return nil
    58  }
    59  
    60  // ToValidationResultMsgs converts `Error` to a slice of
    61  // `configpb.ValidationResult.Message`s.
    62  func (e *Error) ToValidationResultMsgs(ctx context.Context) []*configpb.ValidationResult_Message {
    63  	if e == nil || len(e.Errors) == 0 {
    64  		return nil
    65  	}
    66  	ret := make([]*configpb.ValidationResult_Message, len(e.Errors))
    67  	for i, err := range e.Errors {
    68  		// validation.Context supports just 2 severities now,
    69  		// but defensively default to ERROR level in unexpected cases.
    70  		msgSeverity := configpb.ValidationResult_ERROR
    71  		switch severity, ok := SeverityTag.In(err); {
    72  		case !ok:
    73  			logging.Errorf(ctx, "unset validation.Severity in %s", err)
    74  		case severity == Warning:
    75  			msgSeverity = configpb.ValidationResult_WARNING
    76  		case severity != Blocking:
    77  			logging.Errorf(ctx, "unrecognized validation.Severity %d in %s", severity, err)
    78  		}
    79  		file, ok := fileTag.In(err)
    80  		if !ok || file == "" {
    81  			file = "unspecified file"
    82  		}
    83  		ret[i] = &configpb.ValidationResult_Message{
    84  			Path:     file,
    85  			Severity: msgSeverity,
    86  			Text:     err.Error(),
    87  		}
    88  	}
    89  	return ret
    90  }
    91  
    92  // Context is an accumulator for validation errors.
    93  //
    94  // It is passed to a function that does config validation. Such function may
    95  // validate a bunch of files (using SetFile to indicate which one is processed
    96  // now). Each file may have some internal nested structure. The logical path
    97  // inside this structure is captured through Enter and Exit calls.
    98  type Context struct {
    99  	Context context.Context
   100  
   101  	errors  errors.MultiError // all accumulated errors, including those with Warning severity.
   102  	file    string            // the currently validated file
   103  	element []string          // logical path of a sub-element we validate, see Enter
   104  }
   105  
   106  type fileTagType struct{ Key errors.TagKey }
   107  
   108  func (f fileTagType) With(name string) errors.TagValue {
   109  	return errors.TagValue{Key: f.Key, Value: name}
   110  }
   111  func (f fileTagType) In(err error) (v string, ok bool) {
   112  	d, ok := errors.TagValueIn(f.Key, err)
   113  	if ok {
   114  		v = d.(string)
   115  	}
   116  	return
   117  }
   118  
   119  type elementTagType struct{ Key errors.TagKey }
   120  
   121  func (e elementTagType) With(elements []string) errors.TagValue {
   122  	return errors.TagValue{Key: e.Key, Value: append([]string(nil), elements...)}
   123  }
   124  func (e elementTagType) In(err error) (v []string, ok bool) {
   125  	d, ok := errors.TagValueIn(e.Key, err)
   126  	if ok {
   127  		v = d.([]string)
   128  	}
   129  	return
   130  }
   131  
   132  // Severity of the validation message.
   133  //
   134  // Only Blocking and Warning severities are supported.
   135  type Severity int
   136  
   137  const (
   138  	// Blocking severity blocks config from being accepted.
   139  	//
   140  	// Corresponds to ValidationResponseMessage_Severity:ERROR.
   141  	Blocking Severity = 0
   142  	// Warning severity doesn't block config from being accepted.
   143  	//
   144  	// Corresponds to ValidationResponseMessage_Severity:WARNING.
   145  	Warning Severity = 1
   146  )
   147  
   148  type severityTagType struct{ Key errors.TagKey }
   149  
   150  func (s severityTagType) With(severity Severity) errors.TagValue {
   151  	return errors.TagValue{Key: s.Key, Value: severity}
   152  }
   153  func (s severityTagType) In(err error) (v Severity, ok bool) {
   154  	d, ok := errors.TagValueIn(s.Key, err)
   155  	if ok {
   156  		v = d.(Severity)
   157  	}
   158  	return
   159  }
   160  
   161  var fileTag = fileTagType{errors.NewTagKey("holds the file name for tests")}
   162  var elementTag = elementTagType{errors.NewTagKey("holds the elements for tests")}
   163  
   164  // SeverityTag holds the severity of the given validation error.
   165  var SeverityTag = severityTagType{errors.NewTagKey("holds the severity")}
   166  
   167  // Errorf records the given format string and args as a blocking validation error.
   168  func (v *Context) Errorf(format string, args ...any) {
   169  	v.record(Blocking, errors.Reason(format, args...).Err())
   170  }
   171  
   172  // Error records the given error as a blocking validation error.
   173  func (v *Context) Error(err error) {
   174  	v.record(Blocking, err)
   175  }
   176  
   177  // Warningf records the given format string and args as a validation warning.
   178  func (v *Context) Warningf(format string, args ...any) {
   179  	v.record(Warning, errors.Reason(format, args...).Err())
   180  }
   181  
   182  // Warning records the given error as a validation warning.
   183  func (v *Context) Warning(err error) {
   184  	v.record(Warning, err)
   185  }
   186  
   187  func (v *Context) record(severity Severity, err error) {
   188  	ctx := ""
   189  	if v.file != "" {
   190  		ctx = fmt.Sprintf("in %q", v.file)
   191  	} else {
   192  		ctx = "in <unspecified file>"
   193  	}
   194  	if len(v.element) != 0 {
   195  		ctx += " (" + strings.Join(v.element, " / ") + ")"
   196  	}
   197  	// Make the file and the logical path also usable through error inspection.
   198  	v.errors = append(v.errors, errors.Annotate(err, "%s", ctx).Tag(
   199  		fileTag.With(v.file), elementTag.With(v.element), SeverityTag.With(severity)).Err())
   200  }
   201  
   202  // SetFile records that what follows is errors for this particular file.
   203  //
   204  // Changing the file resets the current element (see Enter/Exit).
   205  func (v *Context) SetFile(path string) {
   206  	if v.file != path {
   207  		v.file = path
   208  		v.element = nil
   209  	}
   210  }
   211  
   212  // Enter descends into a sub-element when validating a nested structure.
   213  //
   214  // Useful for defining context. A current path of elements shows up in
   215  // validation messages.
   216  //
   217  // The reverse is Exit.
   218  func (v *Context) Enter(title string, args ...any) {
   219  	e := fmt.Sprintf(title, args...)
   220  	v.element = append(v.element, e)
   221  }
   222  
   223  // Exit pops the current element we are visiting from the stack.
   224  //
   225  // This is the reverse of Enter. Each Enter must have corresponding Exit. Use
   226  // functions and defers to ensure this, if it's otherwise hard to track.
   227  func (v *Context) Exit() {
   228  	if len(v.element) != 0 {
   229  		v.element = v.element[:len(v.element)-1]
   230  	}
   231  }
   232  
   233  // Finalize returns *Error if some validation errors were recorded.
   234  //
   235  // Returns nil otherwise.
   236  func (v *Context) Finalize() error {
   237  	if len(v.errors) == 0 {
   238  		return nil
   239  	}
   240  	return &Error{
   241  		Errors: append(errors.MultiError{}, v.errors...),
   242  	}
   243  }