package models import ( "context" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "kubernetes-validation-beeyond/conf" "kubernetes-validation-beeyond/services" ) type Constraint struct { Path string `json:"-"` Min *float32 `json:"min,omitempty"` Max *float32 `json:"max,omitempty"` Enum []string `json:"enum,omitempty"` Regex *string `json:"regex,omitempty"` Disabled bool `json:"disabled,omitempty"` GroupKindVersion GroupKindVersion `json:"-"` } // IsValid Extension method to Constraint // checks if exactly one of MinMax, Enum and Regex is filled out, but not multiple // and if MinMax is present if the valueType is integer, since it can only be used on integer fields // Parameters: // - constraint (Constraint): represents the constraint we want to validate // - valueType (string): represents the type of the property (e.g. integer, string, ...) // Returns: bool: true if valid, otherwise false func (constraint Constraint) IsValid(valueType string) bool { if constraint.Enum == nil && constraint.Min == nil && constraint.Max == nil && constraint.Regex == nil { return false } isValidEnum := constraint.Enum != nil && constraint.Min == nil && constraint.Max == nil && constraint.Regex == nil isValidMinMax := constraint.Enum == nil && constraint.Min != nil && constraint.Max != nil && constraint.Regex == nil && valueType == "integer" isValidRegex := constraint.Enum == nil && constraint.Regex != nil && constraint.Min == nil && constraint.Max == nil return isValidEnum || isValidMinMax || isValidRegex } // SaveConstraint Saves a constraint in the database // Parameter: constraint (Constraint): represents the constraint we want to store // Returns: error if anny occur when inserting func SaveConstraint(constraint Constraint) error { collection := services.GetClient(). Database(conf.Configuration.Database.Name). Collection("Constraints") _, err := collection.InsertOne(context.TODO(), constraint) return err } // GetConstraint Gets the constraint that correspond to the given groupKindVersion and path from the database // Parameters: // - path (string): represents the path of the we want to get // - groupKindVersion (*GroupKindVersion): represents the Group, Kind and Version of the constraint we want to get // Returns: *Constraints: Represents the constraint that matches the path and group kind version func GetConstraint(path string, groupKindVersion GroupKindVersion) *Constraint { var constraint Constraint err := services.GetClient(). Database(conf.Configuration.Database.Name). Collection("Constraints"). FindOne(context.TODO(), bson.M{"path": path, "groupkindversion": groupKindVersion}). Decode(&constraint) if err != nil { return nil } return &constraint } // GetConstraintsByGKV Gets all constraint that correspond to the given groupKindVersion from the database // Parameter: groupKindVersion (*GroupKindVersion): represents the Group, Kind and Version of the constraints we want to get // Returns: []*Constraints: An array of all constraints found constraints that match to given groupKindVersion func GetConstraintsByGKV(groupKindVersion *GroupKindVersion) []*Constraint { var constraints []*Constraint cur, err := services.GetClient(). Database(conf.Configuration.Database.Name). Collection("Constraints"). Find(context.TODO(), bson.M{"groupkindversion": groupKindVersion}) if err != nil { return nil } for cur.Next(context.TODO()) { var constr Constraint if err := cur.Decode(&constr); err == nil { constraints = append(constraints, &constr) } } _ = cur.Close(context.TODO()) return constraints } // DeleteConstraint Deletes all Constraints from the database that match the given path and groupKindVersion // Parameters: // - path (string): Represents the path of the constraint we want to delete // - groupKindVersion (GroupKindVersion): Represents the Group, Kind and Version of the constraint we want to delete // Returns: the deleteResult (contains the number of deleted documents) func DeleteConstraint(path string, groupKindVersion GroupKindVersion) *mongo.DeleteResult { deleteResult, _ := services.GetClient(). Database(conf.Configuration.Database.Name). Collection("Constraints"). DeleteMany(context.TODO(), bson.M{"path": path, "groupkindversion": groupKindVersion}) return deleteResult } // DeleteAll Deletes all Constraints from the database // Returns: the deleteResult (contains the number of deleted documents) func DeleteAll() *mongo.DeleteResult { deleteResult, _ := services.GetClient(). Database(conf.Configuration.Database.Name). Collection("Constraints"). DeleteMany(context.TODO(), bson.M{}) return deleteResult }
package models import ( "encoding/json" "kubernetes-validation-beeyond/conf" "net/http" "strings" ) type SchemaCollection struct { Schemas map[string]*Schema `json:"definitions"` } type Schema struct { Description string `json:"description"` Required []string `json:"required"` Type string `json:"type"` Properties map[string]*Property `json:"properties"` GroupKindVersion []GroupKindVersion `json:"x-kubernetes-group-version-kind,omitempty"` Constraint *Constraint `json:"x-constraint,omitempty"` } type Property struct { Description string `json:"description,omitempty"` Type string `json:"type,omitempty"` Format string `json:"format,omitempty"` Reference string `json:"$ref,omitempty"` Items *PropertyItem `json:"items,omitempty"` Enum []string `json:"enum,omitempty"` Constraint *Constraint `json:"x-constraint,omitempty"` IsKubernetesObject bool `json:"x-is-kubernetes-object"` } type PropertyItem struct { Type string `json:"type,omitempty"` Reference string `json:"$ref,omitempty"` } type GroupKindVersion struct { Group string `json:"group"` Kind string `json:"kind"` Version string `json:"version"` } type PathNotFoundError struct{} func (p PathNotFoundError) Error() string { return "Path not found" } // Extension function to GroupKindVersion which just puts the // properties Group Kind and Version to lowercase // Parameters: groupKindVersion (GroupKindVersion), object which we want to lower // Returns: GroupKindVersion: the object containing the lowercase properties func (groupKindVersion GroupKindVersion) ToLower() GroupKindVersion { var groupKindVersionLower GroupKindVersion groupKindVersionLower.Group = strings.ToLower(groupKindVersion.Group) groupKindVersionLower.Kind = strings.ToLower(groupKindVersion.Kind) groupKindVersionLower.Version = strings.ToLower(groupKindVersion.Version) return groupKindVersionLower } // Gets all schemas and return them in the SchemaCollection // Returns the SchemaCollection and an error if one occurred func GetSchemaCollection() (*SchemaCollection, error) { baseUrl := conf.Configuration.KubernetesJsonschema.Url kubernetesVersion := conf.Configuration.KubernetesJsonschema.KubernetesVersion versionType := kubernetesVersion + "-standalone-strict" url := baseUrl + "/" + versionType + "/_definitions.json" response, _ := http.Get(url) collection := &SchemaCollection{} err := json.NewDecoder(response.Body).Decode(collection) if err != nil { return nil, err } return collection, nil } // GetSchemaBySegments Gets the schema corresponding to the given segments // Parameter: segments ([]string): Represents the "path" to the schema (e.g.: ["Deployment-apps-v1", "spec"]) // Returns the schema and any error that occurred func GetSchemaBySegments(segments []string) (*Schema, error) { collection, err := GetSchemaCollection() if err != nil { return nil, err } var currentSchema *Schema for i, segment := range segments { // On the first element search for the GroupKindVersion if i == 0 { schemaLoop: for _, schema := range collection.Schemas { if len(schema.GroupKindVersion) > 0 { groupKindVersion := schema.GroupKindVersion[0] var group string if groupKindVersion.Group == "" { group = "" } else { group = "-" + groupKindVersion.Group } groupKindVersionString := groupKindVersion.Kind + group + "-" + groupKindVersion.Version if segment == groupKindVersionString { currentSchema = schema break schemaLoop } } } if currentSchema == nil { return nil, PathNotFoundError{} } } else { property := currentSchema.Properties[segment] if property == nil { return nil, PathNotFoundError{} } var referencePath string if property.Reference != "" { referencePath = property.Reference } else if property.Items != nil { referencePath = property.Items.Reference } // If the specified path of the user does not exist, return // This means the user requested something other than object (string, int, ...) if referencePath == "" { return nil, PathNotFoundError{} } // We want the last part of the reference // Example: #/definitions/io.k8s.api.apps.v1.DeploymentSpec split := strings.Split(referencePath, "/") definitionName := split[len(split)-1] currentSchema = collection.Schemas[definitionName] } } groupKindVersion, constraintPath := GetGroupKindVersionAndPathFromSegments(segments) // Attach constraint to the properties if it exists for propertyName, property := range currentSchema.Properties { var referencePath string if property.Reference != "" { referencePath = property.Reference } else if property.Type == "array" { referencePath = property.Items.Reference } if referencePath != "" { // turn: #/definitions/xxx // into this: xxx split := strings.Split(referencePath, "/") definitionName := split[len(split)-1] // If the reference is of type object and has properties we declare it as kubernetes object // Add new checks if type object and properties are not enough to determine a kubernetes object if collection.Schemas[definitionName].Type == "object" && collection.Schemas[definitionName].Properties != nil { property.IsKubernetesObject = true } } var path string if constraintPath == "" { path = propertyName } else { path = constraintPath + "." + propertyName } property.Constraint = GetConstraint(path, groupKindVersion) } if len(segments) > 1 { constraintPath = strings.Join(segments[1:], ".") } else { constraintPath = "" } currentSchema.Constraint = GetConstraint(constraintPath, groupKindVersion) if currentSchema.Type != "" { delete(currentSchema.Properties, "apiVersion") delete(currentSchema.Properties, "kind") return currentSchema, nil } else { return nil, PathNotFoundError{} } } // GetGroupKindVersionAndPathFromSegments Gets the group kind version and the path from segments // Parameter: segments ([]string): e.g.: ["Deployment-apps-v1", "spec", "replicas"]) // Returns: // - GroupKindVersion (GroupKindVersion) e.g.: group: apps, kind: Deployment, version: v1 // - path (string) e.g.: spec.replicas func GetGroupKindVersionAndPathFromSegments(segments []string) (GroupKindVersion, string) { var groupKindVersion GroupKindVersion parts := strings.Split(segments[0], "-") groupKindVersion.Kind = parts[0] if len(parts) == 3 { groupKindVersion.Group = parts[1] groupKindVersion.Version = parts[2] } else { groupKindVersion.Version = parts[1] } constraintPath := strings.Join(segments[1:], ".") return groupKindVersion, constraintPath } // Checks if segments represents a valid path for constraints // Parameter: segments ([]string): first element represents the Group Kind Version, remaining elements represetn the path // returns bool: true when the path is valid func IsValidConstraintPath(segments []string) bool { var lastSegment *string if len(segments) != 1 { lastSegment = &segments[len(segments)-1] segments = segments[0 : len(segments)-1] } currentSchema, err := GetSchemaBySegments(segments) return err == nil && (lastSegment == nil || currentSchema.Properties[*lastSegment] != nil) }
package models import ( "encoding/json" "fmt" "github.com/instrumenta/kubeval/kubeval" "gopkg.in/yaml.v2" "regexp" "strconv" "strings" ) type NoContentError struct{} func (e *NoContentError) Error() string { return "No Content" } type ValidationError struct { Message string `json:"message"` Value string `json:"value"` Key string `json:"key"` } // Validates the content (syntax wise) checks the constraints // Parameter: content (string) represents the content of the yaml file, // which will be validated. // returns all constraint-errors in []ValidationError and the kubeval error func ValidateContent(content string) ([]ValidationError, error) { if len(content) == 0 { return nil, &NoContentError{} } config := kubeval.NewDefaultConfig() contentBytes := []byte(content) validationResults, err := kubeval.Validate(contentBytes, config) if err != nil { return nil, err } var validationError []ValidationError for _, result := range validationResults { for _, resultError := range result.Errors { fieldDetail := resultError.Details()["field"] var field string if fieldDetail != nil { field = fieldDetail.(string) } else { field = "" } bytes, _ := json.Marshal(resultError.Value()) validationError = append(validationError, ValidationError{ Message: resultError.Description(), Value: string(bytes), Key: field, }) } } var groupKindVersion GroupKindVersion yamlMap := make(map[interface{}]interface{}) err = yaml.Unmarshal([]byte(content), &yamlMap) groupKindVersion.Kind = getValueFromPath(yamlMap, "kind").(string) groupversion := getValueFromPath(yamlMap, "apiVersion").(string) groupVersionSplit := strings.Split(groupversion, "/") if len(groupVersionSplit) == 1 { groupKindVersion.Version = groupVersionSplit[0] } else { groupKindVersion.Group = groupVersionSplit[0] groupKindVersion.Version = groupVersionSplit[1] } constraints := GetConstraintsByGKV(&groupKindVersion) for _, currentConstraint := range constraints { errorDescription := "" value := getValueFromPath(yamlMap, currentConstraint.Path) var actual string var ok bool isArray := false if currentConstraint.Disabled && currentConstraint.Path == "" { errorDescription = fmt.Sprintf("This root object is disabled") actual = fmt.Sprintf("%s", currentConstraint.GroupKindVersion) } else if currentConstraint.Disabled && value != nil { errorDescription = fmt.Sprintf("Found disabled field (%s)", currentConstraint.Path) } else { if actual, ok = value.(string); !ok { if number, ok := value.(int); ok { actual = strconv.Itoa(number) } else if arr, ok := value.([]interface{}); ok { actual = strings.Join(strings.Fields(fmt.Sprint(arr)), ", ") isArray = true } else if boolValue, ok := value.(bool); ok { actual = strconv.FormatBool(boolValue) } } if currentConstraint.Max != nil { if isArray { for _, currentValue := range value.([]interface{}) { if !isBetweenMinMax(currentConstraint, currentValue.(int)) { errorDescription = fmt.Sprintf("Given value out of range (%.0f-%.0f)", *currentConstraint.Min, *currentConstraint.Max) break } } } else if !isBetweenMinMax(currentConstraint, value.(int)) { errorDescription = fmt.Sprintf("Given value out of range (%.0f-%.0f)", *currentConstraint.Min, *currentConstraint.Max) } } else if currentConstraint.Enum != nil { if isArray { isValid := true for _, currentValue := range strings.Split(actual[1:len(actual)-1], ", ") { if !contains(currentConstraint.Enum, currentValue) { isValid = false } } if !isValid { errorDescription = "Constraint enum does not contain given one or more of the given values" } } else if !contains(currentConstraint.Enum, actual) { errorDescription = "Constraint enum does not contain given value" } } else if currentConstraint.Regex != nil { if isArray { isValid := true for _, currentValue := range strings.Split(actual[1:len(actual)-1], ", ") { if !matchesRegex(*currentConstraint.Regex, currentValue) { isValid = false } } if !isValid { errorDescription = "One or more of the given value does not match the regex" } } else if !matchesRegex(*currentConstraint.Regex, actual) { errorDescription = "Given value does not match regex" } } } if errorDescription != "" { validationError = append(validationError, ValidationError{ Message: errorDescription, Value: actual, Key: currentConstraint.Path, }) } } return validationError, nil } // Checks whether the given string array contains the given searchText // Parameters: // - enum ([]string): array which we search through // - searchText (string): the string we look for in the array // Returns boolean: true if the array contains the searchText func contains(enum []string, searchText string) bool { for _, currentValue := range enum { if currentValue == searchText { return true } } return false } // Checks whether the given text matches the given regex // Parameters: // - regex (string): represents the regex // - text (string): the text that should match the regex // Returns bool: true if the text matches the regex func matchesRegex(regex string, text string) bool { // TODO: "^"+*currentConstraint.Regex+"$" matched, _ := regexp.MatchString("^"+regex+"$", text) return matched } // Checks whether the given value is between the min and max values given within the currentConstraint // Parameters: // - currentConstraint (*Constraint): Contains the min and max values // - value (int): integer which should be between min and max // Returns: bool: true if value is between min and max, otherwise false func isBetweenMinMax(currentConstraint *Constraint, value int) bool { actualFloat := float64(value) return actualFloat <= float64(*currentConstraint.Max) && actualFloat >= float64(*currentConstraint.Min) } // Gets the value of the property by the given path from the given k8s specification (map) // Parameters: // - m (map[interface{}]interface{}): Represents the content of the given yaml file as a map // - path (string): Represents the func getValueFromPath(m map[interface{}]interface{}, path string) interface{} { var obj interface{} = m var val interface{} = nil parts := strings.Split(path, ".") for _, p := range parts { if v, ok := obj.(map[interface{}]interface{}); ok { obj = v[p] val = obj } else { return nil } } return val }
package routers import ( "kubernetes-validation-beeyond/models" "net/http" "strings" "github.com/gin-gonic/gin" ) // @Summary Find root constraints // @Description Finds all root schemes and their constraints // @Tags Constraint // @Success 200 {string} string "ok" // @Failure 500 {string} string "internal server error" // @Router /api/constraints/ [get] func listRootConstraints(c *gin.Context) { collection, err := models.GetSchemaCollection() if err != nil { c.Writer.WriteHeader(http.StatusInternalServerError) return } var kubernetesRootDefinitions []*models.Schema for _, schema := range collection.Schemas { groupKindVersions := schema.GroupKindVersion if len(groupKindVersions) > 0 { schema.Constraint = models.GetConstraint("", groupKindVersions[0]) kubernetesRootDefinitions = append(kubernetesRootDefinitions, schema) } delete(schema.Properties, "apiVersion") delete(schema.Properties, "kind") for _, property := range schema.Properties { var referencePath string if property.Reference != "" { referencePath = property.Reference } else if property.Type == "array" { referencePath = property.Items.Reference } if referencePath != "" { split := strings.Split(referencePath, "/") definitionName := split[len(split)-1] if collection.Schemas[definitionName].Type == "object" && collection.Schemas[definitionName].Properties != nil { property.IsKubernetesObject = true } } } } c.JSON(http.StatusOK, kubernetesRootDefinitions) } // @Summary Find constraints by path // @Description Finds the schema and its constraints according to the given path // @Tags Constraint // @Param "path" path string true "path" // @Success 200 {string} string "ok" // @Failure 400 {string} string "bad request" // @Router /api/constraints/{path} [get] func getConstraintsByPath(c *gin.Context) { segments := c.GetStringSlice("pathSegments") schema, err := models.GetSchemaBySegments(segments) if err != nil { c.Writer.WriteHeader(http.StatusNotFound) return } c.JSON(http.StatusOK, schema) } // @Summary Creates a new constraint // @Description creates a new constraint and adds it to the database. If the constraint already exists it gets replaced. // @Tags Constraint // @Accept json // @Param "path" path string true "path" // @Success 201 {string} string "created" // @Failure 400 {string} string "bad request" // @Failure 500 {string} string "internal server error" // @Router /api/constraints/{path} [post] func createConstraintByPath(c *gin.Context) { var constraint models.Constraint if err := c.ShouldBindJSON(&constraint); err != nil { c.Writer.WriteHeader(http.StatusBadRequest) return } lastSegment := c.GetString("lastPropertyName") schemaInterface, _ := c.Get("schema") schema := schemaInterface.(*models.Schema) if schema.Properties[lastSegment] != nil && !schema.Properties[lastSegment].IsKubernetesObject && !constraint.IsValid(schema.Properties[lastSegment].Type) { c.Writer.WriteHeader(http.StatusBadRequest) return } groupKindVersionInterface, _ := c.Get("groupKindVersion") constraint.Path = c.GetString("propertyPath") // check if constraint on apiVersion or kind if strings.HasSuffix(constraint.Path, "apiVersion") || strings.HasSuffix(constraint.Path, "kind") { c.Writer.WriteHeader(http.StatusBadRequest) return } constraint.GroupKindVersion = groupKindVersionInterface.(models.GroupKindVersion) models.DeleteConstraint(constraint.Path, constraint.GroupKindVersion) if err := models.SaveConstraint(constraint); err != nil { c.Writer.WriteHeader(http.StatusInternalServerError) return } c.Writer.WriteHeader(http.StatusCreated) } // @Summary Delete constraint // @Description Deletes the constraint with the given path // @Tags Constraint // @Param "path" path string true "path" // @Success 204 {string} string "no content" // @Failure 400 {string} string "bad request" // @Router /api/constraints/{path} [delete] func deleteConstraintByPath(c *gin.Context) { groupKindVersion, _ := c.Get("groupKindVersion") propertyPath := c.GetString("propertyPath") if models.DeleteConstraint(propertyPath, groupKindVersion.(models.GroupKindVersion)).DeletedCount == 0 { c.Writer.WriteHeader(http.StatusBadRequest) return } c.Writer.WriteHeader(http.StatusNoContent) } // @Summary Toggle disabled on constraint // @Description Toggles the "disabled" from the constraint with the given path. If the given constraint does not exist, it will be created // @Tags Constraint // @Param "path" path string true "path" // @Success 200 {string} string "ok" // @Failure 400 {string} string "bad request" // @Failure 500 {string} string "internal server error" // @Router /api/constraints/{path} [patch] func toggleDisableConstraintByPath(c *gin.Context) { groupKindVersionInterface, _ := c.Get("groupKindVersion") propertyPath := c.GetString("propertyPath") groupKindVersion := groupKindVersionInterface.(models.GroupKindVersion) constraint := models.GetConstraint(propertyPath, groupKindVersion) // If no constraint exits and the user wants to disable the path, create a new constraint if constraint == nil { constraint = &models.Constraint{ Path: propertyPath, Disabled: false, GroupKindVersion: groupKindVersion, } } lastSegment := c.GetString("lastPropertyName") schemaInterface, _ := c.Get("schema") schema := schemaInterface.(*models.Schema) for _, req := range schema.Required { if req == lastSegment { c.Writer.WriteHeader(http.StatusBadRequest) return } } constraint.Disabled = !constraint.Disabled models.DeleteConstraint(propertyPath, groupKindVersion) if models.SaveConstraint(*constraint) != nil { c.Writer.WriteHeader(http.StatusInternalServerError) return } schema.Constraint = constraint c.Writer.WriteHeader(http.StatusOK) }
package routers import ( "flag" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" "kubernetes-validation-beeyond/conf" _ "kubernetes-validation-beeyond/docs" "kubernetes-validation-beeyond/middleware" "net/http" ) // Creates an Engine with all endpoint, their paths and the used middleware // Returns: *gin.Engine: an Engine with all defined Endpoints and the used middleware func GetRouter() *gin.Engine { router := gin.Default() router.Use(middleware.Cors()) api := router.Group("/api") { api.GET("/swagger-ui", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "swagger/index.html") }) // validate api.POST("/validate", getValidationResult) api.Use(middleware.PathSegments()) api.Use(middleware.ProvideSchema()) url := ginSwagger.URL("http://localhost:8180/api/swagger/doc.json") api.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url)) // constraints constraints := api.Group("/constraints") { if flag.Lookup("test.v") == nil { constraints.Use(middleware.Oidc()) constraints.Use(middleware.Rbac()) } constraints.GET("", listRootConstraints) constraints.GET("/*path", getConstraintsByPath) pathValid := middleware.PathValid() constraints.POST("/*path", pathValid, createConstraintByPath) constraints.DELETE("/*path", pathValid, deleteConstraintByPath) constraints.PATCH("/*path", pathValid, toggleDisableConstraintByPath) } } return router } // Initialises the Router and runs it func Init() error { router := GetRouter() return router.Run(conf.Configuration.Server.HttpPort) }
package routers import ( "github.com/gin-gonic/gin" "kubernetes-validation-beeyond/models" "net/http" ) // @Summary Validate content // @Description Validates the given content // @Tags Validation // @Produce json // @Success 200 {string} string "ok" // @Router /api/validate/ [post] func getValidationResult(c *gin.Context) { data, _ := c.GetRawData() yamlContent := string(data) results, err := models.ValidateContent(yamlContent) if err != nil { // TODO: find what errors can occur and return them if ok results = append(results, models.ValidationError{ Message: "YAML-Format not valid", Value: yamlContent, Key: "content", }) } c.JSON(http.StatusOK, results) }