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 }