github.com/khulnasoft/cli@v0.0.0-20240402070845-01bcad7beefa/cli/compose/schema/schema.go (about)

     1  // FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
     2  //go:build go1.19
     3  
     4  package schema
     5  
     6  import (
     7  	"embed"
     8  	"fmt"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/pkg/errors"
    13  	"github.com/xeipuuv/gojsonschema"
    14  )
    15  
    16  const (
    17  	defaultVersion = "3.12"
    18  	versionField   = "version"
    19  )
    20  
    21  type portsFormatChecker struct{}
    22  
    23  func (checker portsFormatChecker) IsFormat(_ any) bool {
    24  	// TODO: implement this
    25  	return true
    26  }
    27  
    28  type durationFormatChecker struct{}
    29  
    30  func (checker durationFormatChecker) IsFormat(input any) bool {
    31  	value, ok := input.(string)
    32  	if !ok {
    33  		return false
    34  	}
    35  	_, err := time.ParseDuration(value)
    36  	return err == nil
    37  }
    38  
    39  func init() {
    40  	gojsonschema.FormatCheckers.Add("expose", portsFormatChecker{})
    41  	gojsonschema.FormatCheckers.Add("ports", portsFormatChecker{})
    42  	gojsonschema.FormatCheckers.Add("duration", durationFormatChecker{})
    43  }
    44  
    45  // Version returns the version of the config, defaulting to the latest "3.x"
    46  // version (3.12). If only the major version "3" is specified, it is used as
    47  // version "3.x" and returns the default version (latest 3.x).
    48  func Version(config map[string]any) string {
    49  	version, ok := config[versionField]
    50  	if !ok {
    51  		return defaultVersion
    52  	}
    53  	return normalizeVersion(fmt.Sprintf("%v", version))
    54  }
    55  
    56  func normalizeVersion(version string) string {
    57  	switch version {
    58  	case "", "3":
    59  		return defaultVersion
    60  	default:
    61  		return version
    62  	}
    63  }
    64  
    65  //go:embed data/config_schema_v*.json
    66  var schemas embed.FS
    67  
    68  // Validate uses the jsonschema to validate the configuration
    69  func Validate(config map[string]any, version string) error {
    70  	version = normalizeVersion(version)
    71  	schemaData, err := schemas.ReadFile("data/config_schema_v" + version + ".json")
    72  	if err != nil {
    73  		return errors.Errorf("unsupported Compose file version: %s", version)
    74  	}
    75  
    76  	schemaLoader := gojsonschema.NewStringLoader(string(schemaData))
    77  	dataLoader := gojsonschema.NewGoLoader(config)
    78  
    79  	result, err := gojsonschema.Validate(schemaLoader, dataLoader)
    80  	if err != nil {
    81  		return err
    82  	}
    83  
    84  	if !result.Valid() {
    85  		return toError(result)
    86  	}
    87  
    88  	return nil
    89  }
    90  
    91  func toError(result *gojsonschema.Result) error {
    92  	err := getMostSpecificError(result.Errors())
    93  	return err
    94  }
    95  
    96  const (
    97  	jsonschemaOneOf = "number_one_of"
    98  	jsonschemaAnyOf = "number_any_of"
    99  )
   100  
   101  func getDescription(err validationError) string {
   102  	switch err.parent.Type() {
   103  	case "invalid_type":
   104  		if expectedType, ok := err.parent.Details()["expected"].(string); ok {
   105  			return fmt.Sprintf("must be a %s", humanReadableType(expectedType))
   106  		}
   107  	case jsonschemaOneOf, jsonschemaAnyOf:
   108  		if err.child == nil {
   109  			return err.parent.Description()
   110  		}
   111  		return err.child.Description()
   112  	}
   113  	return err.parent.Description()
   114  }
   115  
   116  func humanReadableType(definition string) string {
   117  	if definition[0:1] == "[" {
   118  		allTypes := strings.Split(definition[1:len(definition)-1], ",")
   119  		for i, t := range allTypes {
   120  			allTypes[i] = humanReadableType(t)
   121  		}
   122  		return fmt.Sprintf(
   123  			"%s or %s",
   124  			strings.Join(allTypes[0:len(allTypes)-1], ", "),
   125  			allTypes[len(allTypes)-1],
   126  		)
   127  	}
   128  	if definition == "object" {
   129  		return "mapping"
   130  	}
   131  	if definition == "array" {
   132  		return "list"
   133  	}
   134  	return definition
   135  }
   136  
   137  type validationError struct {
   138  	parent gojsonschema.ResultError
   139  	child  gojsonschema.ResultError
   140  }
   141  
   142  func (err validationError) Error() string {
   143  	description := getDescription(err)
   144  	return fmt.Sprintf("%s %s", err.parent.Field(), description)
   145  }
   146  
   147  func getMostSpecificError(errs []gojsonschema.ResultError) validationError {
   148  	mostSpecificError := 0
   149  	for i, err := range errs {
   150  		if specificity(err) > specificity(errs[mostSpecificError]) {
   151  			mostSpecificError = i
   152  			continue
   153  		}
   154  
   155  		if specificity(err) == specificity(errs[mostSpecificError]) {
   156  			// Invalid type errors win in a tie-breaker for most specific field name
   157  			if err.Type() == "invalid_type" && errs[mostSpecificError].Type() != "invalid_type" {
   158  				mostSpecificError = i
   159  			}
   160  		}
   161  	}
   162  
   163  	if mostSpecificError+1 == len(errs) {
   164  		return validationError{parent: errs[mostSpecificError]}
   165  	}
   166  
   167  	switch errs[mostSpecificError].Type() {
   168  	case "number_one_of", "number_any_of":
   169  		return validationError{
   170  			parent: errs[mostSpecificError],
   171  			child:  errs[mostSpecificError+1],
   172  		}
   173  	default:
   174  		return validationError{parent: errs[mostSpecificError]}
   175  	}
   176  }
   177  
   178  func specificity(err gojsonschema.ResultError) int {
   179  	return len(strings.Split(err.Field(), "."))
   180  }