github.com/Racer159/jackal@v0.32.7-0.20240401174413-0bd2339e4f2e/src/pkg/packager/lint/lint.go (about)

     1  // SPDX-License-Identifier: Apache-2.0
     2  // SPDX-FileCopyrightText: 2021-Present The Jackal Authors
     3  
     4  // Package lint contains functions for verifying jackal yaml files are valid
     5  package lint
     6  
     7  import (
     8  	"embed"
     9  	"fmt"
    10  	"os"
    11  	"path/filepath"
    12  	"regexp"
    13  	"strings"
    14  
    15  	"github.com/Racer159/jackal/src/config"
    16  	"github.com/Racer159/jackal/src/config/lang"
    17  	"github.com/Racer159/jackal/src/pkg/layout"
    18  	"github.com/Racer159/jackal/src/pkg/packager/composer"
    19  	"github.com/Racer159/jackal/src/pkg/packager/creator"
    20  	"github.com/Racer159/jackal/src/pkg/transform"
    21  	"github.com/Racer159/jackal/src/pkg/utils"
    22  	"github.com/Racer159/jackal/src/types"
    23  	"github.com/defenseunicorns/pkg/helpers"
    24  	"github.com/xeipuuv/gojsonschema"
    25  )
    26  
    27  // JackalSchema is exported so main.go can embed the schema file
    28  var JackalSchema embed.FS
    29  
    30  func getSchemaFile() ([]byte, error) {
    31  	return JackalSchema.ReadFile("jackal.schema.json")
    32  }
    33  
    34  // Validate validates a jackal file against the jackal schema, returns *validator with warnings or errors if they exist
    35  // along with an error if the validation itself failed
    36  func Validate(createOpts types.JackalCreateOptions) (*Validator, error) {
    37  	validator := Validator{}
    38  	var err error
    39  
    40  	if err := utils.ReadYaml(filepath.Join(createOpts.BaseDir, layout.JackalYAML), &validator.typedJackalPackage); err != nil {
    41  		return nil, err
    42  	}
    43  
    44  	if err := utils.ReadYaml(filepath.Join(createOpts.BaseDir, layout.JackalYAML), &validator.untypedJackalPackage); err != nil {
    45  		return nil, err
    46  	}
    47  
    48  	if err := os.Chdir(createOpts.BaseDir); err != nil {
    49  		return nil, fmt.Errorf("unable to access directory '%s': %w", createOpts.BaseDir, err)
    50  	}
    51  
    52  	validator.baseDir = createOpts.BaseDir
    53  
    54  	lintComponents(&validator, &createOpts)
    55  
    56  	if validator.jsonSchema, err = getSchemaFile(); err != nil {
    57  		return nil, err
    58  	}
    59  
    60  	if err = validateSchema(&validator); err != nil {
    61  		return nil, err
    62  	}
    63  
    64  	return &validator, nil
    65  }
    66  
    67  func lintComponents(validator *Validator, createOpts *types.JackalCreateOptions) {
    68  	for i, component := range validator.typedJackalPackage.Components {
    69  		arch := config.GetArch(validator.typedJackalPackage.Metadata.Architecture)
    70  
    71  		if !composer.CompatibleComponent(component, arch, createOpts.Flavor) {
    72  			continue
    73  		}
    74  
    75  		chain, err := composer.NewImportChain(component, i, validator.typedJackalPackage.Metadata.Name, arch, createOpts.Flavor)
    76  		baseComponent := chain.Head()
    77  
    78  		var badImportYqPath string
    79  		if baseComponent != nil {
    80  			if baseComponent.Import.URL != "" {
    81  				badImportYqPath = fmt.Sprintf(".components.[%d].import.url", i)
    82  			}
    83  			if baseComponent.Import.Path != "" {
    84  				badImportYqPath = fmt.Sprintf(".components.[%d].import.path", i)
    85  			}
    86  		}
    87  		if err != nil {
    88  			validator.addError(validatorMessage{
    89  				description:    err.Error(),
    90  				packageRelPath: ".",
    91  				packageName:    validator.typedJackalPackage.Metadata.Name,
    92  				yqPath:         badImportYqPath,
    93  			})
    94  		}
    95  
    96  		node := baseComponent
    97  		for node != nil {
    98  			checkForVarInComponentImport(validator, node)
    99  			fillComponentTemplate(validator, node, createOpts)
   100  			lintComponent(validator, node)
   101  			node = node.Next()
   102  		}
   103  	}
   104  }
   105  
   106  func fillComponentTemplate(validator *Validator, node *composer.Node, createOpts *types.JackalCreateOptions) {
   107  	err := creator.ReloadComponentTemplate(&node.JackalComponent)
   108  	if err != nil {
   109  		validator.addWarning(validatorMessage{
   110  			description:    err.Error(),
   111  			packageRelPath: node.ImportLocation(),
   112  			packageName:    node.OriginalPackageName(),
   113  		})
   114  	}
   115  	templateMap := map[string]string{}
   116  
   117  	setVarsAndWarn := func(templatePrefix string, deprecated bool) {
   118  		yamlTemplates, err := utils.FindYamlTemplates(node, templatePrefix, "###")
   119  		if err != nil {
   120  			validator.addWarning(validatorMessage{
   121  				description:    err.Error(),
   122  				packageRelPath: node.ImportLocation(),
   123  				packageName:    node.OriginalPackageName(),
   124  			})
   125  		}
   126  
   127  		for key := range yamlTemplates {
   128  			if deprecated {
   129  				validator.addWarning(validatorMessage{
   130  					description:    fmt.Sprintf(lang.PkgValidateTemplateDeprecation, key, key, key),
   131  					packageRelPath: node.ImportLocation(),
   132  					packageName:    node.OriginalPackageName(),
   133  				})
   134  			}
   135  			_, present := createOpts.SetVariables[key]
   136  			if !present {
   137  				validator.addWarning(validatorMessage{
   138  					description:    lang.UnsetVarLintWarning,
   139  					packageRelPath: node.ImportLocation(),
   140  					packageName:    node.OriginalPackageName(),
   141  				})
   142  			}
   143  		}
   144  		for key, value := range createOpts.SetVariables {
   145  			templateMap[fmt.Sprintf("%s%s###", templatePrefix, key)] = value
   146  		}
   147  	}
   148  
   149  	setVarsAndWarn(types.JackalPackageTemplatePrefix, false)
   150  
   151  	// [DEPRECATION] Set the Package Variable syntax as well for backward compatibility
   152  	setVarsAndWarn(types.JackalPackageVariablePrefix, true)
   153  
   154  	utils.ReloadYamlTemplate(node, templateMap)
   155  }
   156  
   157  func isPinnedImage(image string) (bool, error) {
   158  	transformedImage, err := transform.ParseImageRef(image)
   159  	if err != nil {
   160  		if strings.Contains(image, types.JackalPackageTemplatePrefix) ||
   161  			strings.Contains(image, types.JackalPackageVariablePrefix) {
   162  			return true, nil
   163  		}
   164  		return false, err
   165  	}
   166  	return (transformedImage.Digest != ""), err
   167  }
   168  
   169  func isPinnedRepo(repo string) bool {
   170  	return (strings.Contains(repo, "@"))
   171  }
   172  
   173  func lintComponent(validator *Validator, node *composer.Node) {
   174  	checkForUnpinnedRepos(validator, node)
   175  	checkForUnpinnedImages(validator, node)
   176  	checkForUnpinnedFiles(validator, node)
   177  }
   178  
   179  func checkForUnpinnedRepos(validator *Validator, node *composer.Node) {
   180  	for j, repo := range node.Repos {
   181  		repoYqPath := fmt.Sprintf(".components.[%d].repos.[%d]", node.Index(), j)
   182  		if !isPinnedRepo(repo) {
   183  			validator.addWarning(validatorMessage{
   184  				yqPath:         repoYqPath,
   185  				packageRelPath: node.ImportLocation(),
   186  				packageName:    node.OriginalPackageName(),
   187  				description:    "Unpinned repository",
   188  				item:           repo,
   189  			})
   190  		}
   191  	}
   192  }
   193  
   194  func checkForUnpinnedImages(validator *Validator, node *composer.Node) {
   195  	for j, image := range node.Images {
   196  		imageYqPath := fmt.Sprintf(".components.[%d].images.[%d]", node.Index(), j)
   197  		pinnedImage, err := isPinnedImage(image)
   198  		if err != nil {
   199  			validator.addError(validatorMessage{
   200  				yqPath:         imageYqPath,
   201  				packageRelPath: node.ImportLocation(),
   202  				packageName:    node.OriginalPackageName(),
   203  				description:    "Invalid image reference",
   204  				item:           image,
   205  			})
   206  			continue
   207  		}
   208  		if !pinnedImage {
   209  			validator.addWarning(validatorMessage{
   210  				yqPath:         imageYqPath,
   211  				packageRelPath: node.ImportLocation(),
   212  				packageName:    node.OriginalPackageName(),
   213  				description:    "Image not pinned with digest",
   214  				item:           image,
   215  			})
   216  		}
   217  	}
   218  }
   219  
   220  func checkForUnpinnedFiles(validator *Validator, node *composer.Node) {
   221  	for j, file := range node.Files {
   222  		fileYqPath := fmt.Sprintf(".components.[%d].files.[%d]", node.Index(), j)
   223  		if file.Shasum == "" && helpers.IsURL(file.Source) {
   224  			validator.addWarning(validatorMessage{
   225  				yqPath:         fileYqPath,
   226  				packageRelPath: node.ImportLocation(),
   227  				packageName:    node.OriginalPackageName(),
   228  				description:    "No shasum for remote file",
   229  				item:           file.Source,
   230  			})
   231  		}
   232  	}
   233  }
   234  
   235  func checkForVarInComponentImport(validator *Validator, node *composer.Node) {
   236  	if strings.Contains(node.Import.Path, types.JackalPackageTemplatePrefix) {
   237  		validator.addWarning(validatorMessage{
   238  			yqPath:         fmt.Sprintf(".components.[%d].import.path", node.Index()),
   239  			packageRelPath: node.ImportLocation(),
   240  			packageName:    node.OriginalPackageName(),
   241  			description:    "Jackal does not evaluate variables at component.x.import.path",
   242  			item:           node.Import.Path,
   243  		})
   244  	}
   245  	if strings.Contains(node.Import.URL, types.JackalPackageTemplatePrefix) {
   246  		validator.addWarning(validatorMessage{
   247  			yqPath:         fmt.Sprintf(".components.[%d].import.url", node.Index()),
   248  			packageRelPath: node.ImportLocation(),
   249  			packageName:    node.OriginalPackageName(),
   250  			description:    "Jackal does not evaluate variables at component.x.import.url",
   251  			item:           node.Import.URL,
   252  		})
   253  	}
   254  }
   255  
   256  func makeFieldPathYqCompat(field string) string {
   257  	if field == "(root)" {
   258  		return field
   259  	}
   260  	// \b is a metacharacter that will stop at the next non-word character (including .)
   261  	// https://regex101.com/r/pIRPk0/1
   262  	re := regexp.MustCompile(`(\b\d+\b)`)
   263  
   264  	wrappedField := re.ReplaceAllString(field, "[$1]")
   265  
   266  	return fmt.Sprintf(".%s", wrappedField)
   267  }
   268  
   269  func validateSchema(validator *Validator) error {
   270  	schemaLoader := gojsonschema.NewBytesLoader(validator.jsonSchema)
   271  	documentLoader := gojsonschema.NewGoLoader(validator.untypedJackalPackage)
   272  
   273  	result, err := gojsonschema.Validate(schemaLoader, documentLoader)
   274  	if err != nil {
   275  		return err
   276  	}
   277  
   278  	if !result.Valid() {
   279  		for _, desc := range result.Errors() {
   280  			validator.addError(validatorMessage{
   281  				yqPath:         makeFieldPathYqCompat(desc.Field()),
   282  				description:    desc.Description(),
   283  				packageRelPath: ".",
   284  				packageName:    validator.typedJackalPackage.Metadata.Name,
   285  			})
   286  		}
   287  	}
   288  
   289  	return err
   290  }