airshipctl/krm-functions/kubeval-validator/image/main.go
Ruslan Aliev f7fca469de Add KRM function to validate site documents
Proposed KRM function can perform static validation of the site
documents based on Kubeval. It allows to specify additional CRD
locations to be able to validate custom resources. As input,
it uses document bundle created from desired phase's document
entry point or output from render command.

Change-Id: Ib76d88a6e6c7f3b29b29cab0abe038eef380686f
Signed-off-by: Ruslan Aliev <raliev@mirantis.com>
Relates-To: #19
2021-04-22 11:13:40 -05:00

327 lines
9.7 KiB
Go

/*
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"bytes"
"errors"
"fmt"
"io"
"io/ioutil"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/instrumenta/kubeval/kubeval"
"sigs.k8s.io/kustomize/kyaml/kio"
"sigs.k8s.io/kustomize/kyaml/yaml"
)
const (
manifestsMountPoint = "/manifests"
schemaLocationDir = "/workdir/schemas-cache"
fileScheme = "file"
openAPISchemaFile = "openapischema"
crdKind = "CustomResourceDefinition"
kubevalOptsKind = "KubevalOptions"
phaseRenderedFile = "phase-rendered.yaml"
crdListFile = "crd-list"
cleanupEnv = "VALIDATOR_PREVENT_CLEANUP"
planEnv = "VALIDATOR_PLAN_VALIDATION"
defaultKubernetesVersion = "1.16.0"
defaultStrict = true
defaultIgnoreMissingSchemas = false
)
func main() {
rw := &kio.ByteReadWriter{
Reader: os.Stdin,
Writer: os.Stdout,
OmitReaderAnnotations: true,
KeepReaderAnnotations: true,
}
p := kio.Pipeline{
Inputs: []kio.Reader{rw}, // read the inputs into a slice
Filters: []kio.Filter{kubevalFilter{rw: rw}}, // filters input by validation
Outputs: []kio.Writer{rw}, // copy the inputs to the output
}
if err := p.Execute(); err != nil {
printMsg("%v\n", err)
// Clean up working directory
if err = os.RemoveAll(schemaLocationDir); err != nil {}
os.Exit(1)
}
}
// kubevalFilter implements kio.Filter
type kubevalFilter struct {
rw *kio.ByteReadWriter
}
// CRDConfigMap is a map with appropriate validation configs for plans/phases
type CRDConfigMap map[string]*CRDSpec
// Config defines the input config schema as a struct
type Config struct {
Spec *Spec `yaml:"siteConfig"`
PlanName string `yaml:"planName,omitempty"`
PlanConfigs CRDConfigMap `yaml:"planConfigs,omitempty"`
PhaseName string `yaml:"phaseName,omitempty"`
PhaseConfigs CRDConfigMap `yaml:"phaseConfigs,omitempty"`
}
// Spec holds main kubeval parameters
type Spec struct {
// Strict disallows additional properties not in schema if set
Strict bool `yaml:"strict,omitempty"`
// IgnoreMissingSchemas skips validation for resource
// definitions without a schema.
IgnoreMissingSchemas bool `yaml:"ignoreMissingSchemas,omitempty"`
// KubernetesVersion is the version of Kubernetes to validate
// against (default "master").
KubernetesVersion string `yaml:"kubernetesVersion,omitempty"`
}
// CRDSpec holds special options for plan/phase which kinds to skip and which additional CRDs to include
type CRDSpec struct {
// KindsToSkip defines Kinds which will be skipped during validation
KindsToSkip []string `yaml:"kindsToSkip,omitempty"`
// CRDList defines additional CRD locations
CRDList []string `yaml:"crdList,omitempty"`
}
// CrdConfig is a small struct to process CRD list
type CrdConfig struct {
SchemasLocation string `yaml:"schemasLocation"`
CrdList []string `yaml:"crdList"`
}
// Filter checks each resource for validity, otherwise returning an error
func (f kubevalFilter) Filter(in []*yaml.RNode) ([]*yaml.RNode, error) {
cfg, err := f.parseConfig()
if err != nil {
return nil, err
}
kubevalConfig := kubeval.NewDefaultConfig()
kubevalConfig.Strict = cfg.Spec.Strict
kubevalConfig.IgnoreMissingSchemas = cfg.Spec.IgnoreMissingSchemas
kubevalConfig.KubernetesVersion = cfg.Spec.KubernetesVersion
kubevalConfig.AdditionalSchemaLocations = []string{fileScheme + "://" + schemaLocationDir}
kubevalConfig.KindsToSkip = []string{crdKind, kubevalOptsKind}
var crdList []string
if _, plan := os.LookupEnv(planEnv); plan {
// Setup plan specific options
if _, exists := cfg.PlanConfigs[cfg.PlanName]; exists {
kubevalConfig.KindsToSkip = append(kubevalConfig.KindsToSkip, cfg.PlanConfigs[cfg.PlanName].KindsToSkip...)
crdList = cfg.PlanConfigs[cfg.PlanName].CRDList
}
} else {
// Setup phase specific options
if _, exists := cfg.PhaseConfigs[cfg.PhaseName]; exists {
kubevalConfig.KindsToSkip = append(kubevalConfig.KindsToSkip, cfg.PhaseConfigs[cfg.PhaseName].KindsToSkip...)
crdList = cfg.PhaseConfigs[cfg.PhaseName].CRDList
}
}
// Calculate schema location directory for kubeval and openapi2jsonschema based on options
schemasLocation := filepath.Join(schemaLocationDir,
fmt.Sprintf("v%s-%s", kubevalConfig.KubernetesVersion, "standalone"))
if kubevalConfig.Strict {
schemasLocation += "-strict"
}
// Create it if doesn't exist
if _, err := os.Stat(schemasLocation); os.IsNotExist(err) {
if err = os.MkdirAll(schemasLocation, 0755); err != nil {
return nil, err
}
}
// Filter CRDs from input
crdRNodes, err := filterCRD(in)
if err != nil {
return nil, err
}
if len(crdRNodes) > 0 {
// Save filtered CRDs in file to future processing
renderedCRDFile := filepath.Join(schemaLocationDir, phaseRenderedFile)
buf := bytes.Buffer{}
for _, rNode := range crdRNodes {
buf.Write([]byte("---\n" + rNode.MustString()))
}
if err = ioutil.WriteFile(renderedCRDFile, buf.Bytes(), 0600); err != nil {
return nil, err
}
// Prepend rendered CRD to give them priority for processing
crdList = append([]string{renderedCRDFile}, crdList...)
}
if len(crdList) > 0 {
// Process each additional CRD in the list (CRD -> OpenAPIV3 Schema -> Json Schema)
if err := processCRDList(crdList, schemasLocation); err != nil {
return nil, err
}
}
// Validate each Resource
for _, r := range in {
meta, err := r.GetMeta()
if err != nil {
return nil, err
}
if err := validate(r.MustString(), kubevalConfig); err != nil {
// if there's an issue found with document - it will be printed as well
printMsg("Resource invalid: (Kind: %s, Name: %s)\n---\n%s---\n", meta.Kind, meta.Name, r.MustString())
return nil, err
}
// inform document is ok
printMsg("Resource valid: (Kind: %s, Name: %s)\n", meta.Kind, meta.Name)
}
// if prevent cleanup variable is not set then cleanup working directory
if _, cleanup := os.LookupEnv(cleanupEnv); !cleanup {
if err := os.RemoveAll(schemaLocationDir); err != nil {
return nil, err
}
}
// Don't return output list, we satisfied with exit code and stdout/stderr
return nil, nil
}
// filterCRD filters CRD documents from input slice of *yaml.RNodes
func filterCRD(in []*yaml.RNode) ([]*yaml.RNode, error) {
var out []*yaml.RNode
for _, r := range in {
meta, err := r.GetMeta()
if err != nil {
return nil, err
}
if meta.Kind == crdKind {
out = append(out, r)
}
}
return out, nil
}
// validate runs kubeval.Validate and analyzes results
func validate(r string, config *kubeval.Config) error {
results, err := kubeval.Validate([]byte(r), config)
if err != nil {
return err
}
return checkResults(results)
}
// checkResults processes results of validation, so we can filter some of the errors ourselves
func checkResults(results []kubeval.ValidationResult) error {
if len(results) == 0 {
return nil
}
var errs []string
for _, r := range results {
for _, e := range r.Errors {
errs = append(errs, e.String())
}
}
if len(errs) > 0 {
return errors.New(strings.Join(errs, "\n"))
}
return nil
}
// parseConfig parses the functionConfig into an Config struct.
func (f *kubevalFilter) parseConfig() (*Config, error) {
// Initialize default values
cfg := &Config{
Spec: &Spec{
Strict: defaultStrict,
IgnoreMissingSchemas: defaultIgnoreMissingSchemas,
KubernetesVersion: defaultKubernetesVersion,
},
}
if err := yaml.Unmarshal([]byte(f.rw.FunctionConfig.MustString()), &cfg); err != nil {
return nil, err
}
return cfg, nil
}
// processCRDList takes each CRD from crdList, converts paths to URLs,
// saves it to file and calls conversion script
func processCRDList(crdList []string, schemasLocation string) error {
configMap := CrdConfig{
SchemasLocation: schemasLocation,
CrdList: []string{},
}
// Walk through all additional CRD
for _, crdPath := range crdList {
// Parse provided CRD path as URL
crdURL, err := url.Parse(crdPath)
if err != nil {
return err
}
// Add 'file' scheme if not specified and convert relative path to absolute
if crdURL.Scheme == "" {
crdURL.Scheme = fileScheme
if filepath.Base(crdPath) != phaseRenderedFile {
crdURL.Path = filepath.Join(manifestsMountPoint, crdPath)
}
}
configMap.CrdList = append(configMap.CrdList, crdURL.String())
}
crdData, err := yaml.Marshal(configMap)
if err != nil {
return err
}
crdListFile := filepath.Join(schemaLocationDir, crdListFile)
if err := ioutil.WriteFile(crdListFile, crdData, 0600); err != nil {
return err
}
return openAPI2Json(schemasLocation)
}
// Convert OpenAPI schemas to JSON
func openAPI2Json(schemasLocation string) error {
printMsg("Converting OpenAPI schemas to JSON\n")
openAPISchemaPath := filepath.Join(schemaLocationDir, openAPISchemaFile)
openAPI2JsonCmd := exec.Command("extract-openapi.py", "--strict", "--expanded", "--stand-alone",
"--kubernetes", "-o", schemasLocation, openAPISchemaPath)
// allows to observe realtime output from script
w := io.Writer(os.Stderr)
openAPI2JsonCmd.Stdout = w
openAPI2JsonCmd.Stderr = w
return openAPI2JsonCmd.Run()
}
// printMsg is a convenient function to print output to stderr
func printMsg(format string, a ...interface{}) {
if _, err := fmt.Fprintf(os.Stderr, format, a...); err != nil {}
}