github.com/snyk/vervet/v4@v4.27.2/internal/linter/optic/linter.go (about) 1 // Package optic supports linting OpenAPI specs with Optic CI and Sweater Comb. 2 package optic 3 4 import ( 5 "bufio" 6 "context" 7 "crypto/sha256" 8 "encoding/hex" 9 "encoding/json" 10 "fmt" 11 "io" 12 "io/ioutil" 13 "log" 14 "os" 15 "os/exec" 16 "path/filepath" 17 "regexp" 18 "sort" 19 "strings" 20 "time" 21 22 "github.com/ghodss/yaml" 23 "go.uber.org/multierr" 24 25 "github.com/snyk/vervet/v4" 26 "github.com/snyk/vervet/v4/config" 27 "github.com/snyk/vervet/v4/internal/files" 28 "github.com/snyk/vervet/v4/internal/linter" 29 ) 30 31 // Optic runs a Docker image containing Optic CI and built-in rules. 32 type Optic struct { 33 image string 34 script string 35 fromSource files.FileSource 36 toSource files.FileSource 37 runner commandRunner 38 timeNow func() time.Time 39 debug bool 40 extraArgs []string 41 exceptions map[string][]string 42 } 43 44 type commandRunner interface { 45 run(cmd *exec.Cmd) error 46 bulkInput(interface{}) 47 } 48 49 type execCommandRunner struct{} 50 51 func (*execCommandRunner) bulkInput(interface{}) {} 52 53 func (*execCommandRunner) run(cmd *exec.Cmd) error { 54 return cmd.Run() 55 } 56 57 // New returns a new Optic instance configured to run the given OCI image and 58 // file sources. File sources may be a Git "treeish" (commit hash or anything 59 // that resolves to one such as a branch or tag) where the current working 60 // directory is a cloned git repository. If `from` is empty string, comparison 61 // assumes all changes are new "from scratch" additions. If `to` is empty 62 // string, spec files are assumed to be relative to the current working 63 // directory. 64 // 65 // Temporary resources may be created by the linter, which are reclaimed when 66 // the context cancels. 67 func New(ctx context.Context, cfg *config.OpticCILinter) (*Optic, error) { 68 image, script, from, to := cfg.Image, cfg.Script, cfg.Original, cfg.Proposed 69 var fromSource, toSource files.FileSource 70 var err error 71 72 if !isDocker(script) { 73 image = "" 74 } 75 76 if from == "" { 77 fromSource = files.NilSource{} 78 } else { 79 fromSource, err = newGitRepoSource(".", from) 80 if err != nil { 81 return nil, err 82 } 83 } 84 85 if to == "" { 86 toSource = files.LocalFSSource{} 87 } else { 88 toSource, err = newGitRepoSource(".", to) 89 if err != nil { 90 return nil, err 91 } 92 } 93 94 go func() { 95 <-ctx.Done() 96 fromSource.Close() 97 toSource.Close() 98 }() 99 return &Optic{ 100 image: image, 101 script: script, 102 fromSource: fromSource, 103 toSource: toSource, 104 runner: &execCommandRunner{}, 105 timeNow: time.Now, 106 debug: cfg.Debug, 107 extraArgs: cfg.ExtraArgs, 108 exceptions: cfg.Exceptions, 109 }, nil 110 } 111 112 func isDocker(script string) bool { 113 return script == "" 114 } 115 116 // Match implements linter.Linter. 117 func (o *Optic) Match(rcConfig *config.ResourceSet) ([]string, error) { 118 fromFiles, err := o.fromSource.Match(rcConfig) 119 if err != nil { 120 return nil, err 121 } 122 toFiles, err := o.toSource.Match(rcConfig) 123 if err != nil { 124 return nil, err 125 } 126 // Unique set of files 127 // TODO: normalization needed? or if not needed, tested to prove it? 128 filesMap := map[string]struct{}{} 129 for i := range fromFiles { 130 filesMap[fromFiles[i]] = struct{}{} 131 } 132 for i := range toFiles { 133 filesMap[toFiles[i]] = struct{}{} 134 } 135 var result []string 136 for k := range filesMap { 137 result = append(result, k) 138 } 139 sort.Strings(result) 140 return result, nil 141 } 142 143 // WithOverride implements linter.Linter. 144 func (*Optic) WithOverride(ctx context.Context, override *config.Linter) (linter.Linter, error) { 145 if override.OpticCI == nil { 146 return nil, fmt.Errorf("invalid linter override") 147 } 148 return New(ctx, override.OpticCI) 149 } 150 151 // Run runs Optic CI on the given paths. Linting output is written to standard 152 // output by Optic CI. Returns an error when lint fails configured rules. 153 func (o *Optic) Run(ctx context.Context, root string, paths ...string) error { 154 var errs error 155 var comparisons []comparison 156 localFrom, err := o.fromSource.Prefetch(root) 157 if err != nil { 158 return err 159 } 160 localTo, err := o.toSource.Prefetch(root) 161 if err != nil { 162 return err 163 } 164 var dockerArgs []string 165 var fromFilter, toFilter func(string) string 166 if localFrom != "" { 167 dockerArgs = append(dockerArgs, "-v", localFrom+":/from/"+root) 168 if o.isDocker() { 169 fromFilter = func(s string) string { 170 return strings.Replace(s, localFrom, "/from/"+root, 1) 171 } 172 } 173 } 174 if localTo != "" { 175 dockerArgs = append(dockerArgs, "-v", localTo+":/to/"+root) 176 if o.isDocker() { 177 toFilter = func(s string) string { 178 return strings.Replace(s, localTo, "/to/"+root, 1) 179 } 180 } 181 } 182 for i := range paths { 183 comparison, volumeArgs, err := o.newComparison(paths[i], fromFilter, toFilter) 184 if err == errHasException { 185 continue 186 } else if err != nil { 187 errs = multierr.Append(errs, err) 188 } else { 189 comparisons = append(comparisons, comparison) 190 dockerArgs = append(dockerArgs, volumeArgs...) 191 } 192 } 193 if o.isDocker() { 194 err = o.bulkCompareDocker(ctx, comparisons, dockerArgs) 195 } else { 196 err = o.bulkCompareScript(ctx, comparisons) 197 } 198 errs = multierr.Append(errs, err) 199 return errs 200 } 201 202 func (o *Optic) isDocker() bool { 203 return isDocker(o.script) 204 } 205 206 type comparison struct { 207 From string `json:"from,omitempty"` 208 To string `json:"to,omitempty"` 209 Context Context `json:"context,omitempty"` 210 } 211 212 type bulkCompareInput struct { 213 Comparisons []comparison `json:"comparisons,omitempty"` 214 } 215 216 func (o *Optic) newComparison(path string, fromFilter, toFilter func(string) string) (comparison, []string, error) { 217 var volumeArgs []string 218 219 // TODO: This assumes the file being linted is a resource version spec 220 // file, and not a compiled one. We don't yet have rules that support 221 // diffing _compiled_ specs; that will require a different context and rule 222 // set for Vervet Underground integration. 223 opticCtx, err := o.contextFromPath(path) 224 if err != nil { 225 return comparison{}, nil, fmt.Errorf("failed to get context from path %q: %w", path, err) 226 } 227 228 cmp := comparison{ 229 Context: *opticCtx, 230 } 231 232 fromFile, err := o.fromSource.Fetch(path) 233 if err != nil { 234 return comparison{}, nil, err 235 } 236 if ok, err := o.hasException(path, fromFile); err != nil { 237 return comparison{}, nil, err 238 } else if ok { 239 return comparison{}, nil, errHasException 240 } 241 cmp.From = fromFile 242 if fromFilter != nil { 243 cmp.From = fromFilter(cmp.From) 244 } 245 246 toFile, err := o.toSource.Fetch(path) 247 if err != nil { 248 return comparison{}, nil, err 249 } 250 if ok, err := o.hasException(path, toFile); err != nil { 251 return comparison{}, nil, err 252 } else if ok { 253 return comparison{}, nil, errHasException 254 } 255 cmp.To = toFile 256 if toFilter != nil { 257 cmp.To = toFilter(cmp.To) 258 } 259 260 return cmp, volumeArgs, nil 261 } 262 263 var errHasException = fmt.Errorf("file is skipped due to lint exception") 264 265 func (o *Optic) hasException(key, path string) (bool, error) { 266 if path == "" { 267 return false, nil 268 } 269 sum, ok := o.exceptions[key] 270 if !ok { 271 return false, nil 272 } 273 contents, err := ioutil.ReadFile(path) 274 if err != nil { 275 return false, err 276 } 277 fileSum := sha256.Sum256(contents) 278 matchSum := hex.EncodeToString(fileSum[:]) 279 for i := range sum { 280 if strings.ToLower(sum[i]) == matchSum { 281 return true, nil 282 } 283 } 284 return false, nil 285 } 286 287 func (o *Optic) bulkCompareScript(ctx context.Context, comparisons []comparison) error { 288 input := &bulkCompareInput{ 289 Comparisons: comparisons, 290 } 291 o.runner.bulkInput(input) 292 inputFile, err := ioutil.TempFile("", "*-input.json") 293 if err != nil { 294 return err 295 } 296 defer inputFile.Close() 297 err = json.NewEncoder(inputFile).Encode(&input) 298 if err != nil { 299 return err 300 } 301 if o.debug { 302 log.Println("input.json:") 303 err = json.NewEncoder(os.Stderr).Encode(&input) 304 if err != nil { 305 return err 306 } 307 } 308 if err := inputFile.Sync(); err != nil { 309 return err 310 } 311 312 if o.debug { 313 log.Print("bulk-compare input:") 314 if err := json.NewEncoder(os.Stdout).Encode(&input); err != nil { 315 log.Println("failed to encode input to stdout!") 316 } 317 log.Println() 318 } 319 320 extraArgs := o.extraArgs 321 if ok, reason := o.checkUploadEnabled(); ok { 322 extraArgs = append(extraArgs, "--upload-results") 323 } else { 324 log.Printf("not uploading to Optic Cloud: %s", reason) 325 } 326 327 args := append([]string{"bulk-compare", "--input", inputFile.Name()}, extraArgs...) 328 cmd := exec.CommandContext(ctx, o.script, args...) 329 330 pipeReader, pipeWriter := io.Pipe() 331 ch := make(chan struct{}) 332 defer func() { 333 err := pipeWriter.Close() 334 if err != nil { 335 log.Printf("warning: failed to close output: %v", err) 336 } 337 select { 338 case <-ch: 339 return 340 case <-ctx.Done(): 341 return 342 case <-time.After(cmdTimeout): 343 log.Printf("warning: timeout waiting for output to flush") 344 return 345 } 346 }() 347 go func() { 348 defer pipeReader.Close() 349 sc := bufio.NewScanner(pipeReader) 350 for sc.Scan() { 351 line := sc.Text() 352 // TODO: this wanton breakage of FileSource encapsulation indicates 353 // we probably need an abstraction if/when we support other 354 // sources. VU might be such a future source... 355 if fromGit, ok := o.fromSource.(*gitRepoSource); ok { 356 for root, tempDir := range fromGit.roots { 357 line = strings.ReplaceAll(line, tempDir, "("+fromGit.Name()+"):"+root) 358 } 359 } 360 if toGit, ok := o.toSource.(*gitRepoSource); ok { 361 for root, tempDir := range toGit.roots { 362 line = strings.ReplaceAll(line, tempDir, "("+toGit.Name()+"):"+root) 363 } 364 } 365 fmt.Println(line) 366 } 367 if err := sc.Err(); err != nil { 368 fmt.Fprintf(os.Stderr, "error reading stdout: %v", err) 369 } 370 close(ch) 371 }() 372 cmd.Stdin = os.Stdin 373 cmd.Stdout = pipeWriter 374 cmd.Stderr = os.Stderr 375 err = o.runner.run(cmd) 376 if err != nil { 377 return fmt.Errorf("lint failed: %w", err) 378 } 379 return nil 380 } 381 382 func (o *Optic) checkUploadEnabled() (bool, string) { 383 if os.Getenv("GITHUB_TOKEN") == "" { 384 return false, "GITHUB_TOKEN not set" 385 } 386 if os.Getenv("OPTIC_TOKEN") == "" { 387 return false, "OPTIC_TOKEN not set" 388 } 389 ciContextPath, err := filepath.Abs("ci-context.json") 390 if err != nil { 391 return false, err.Error() 392 } 393 if _, err := os.Stat(ciContextPath); err != nil { 394 return false, err.Error() 395 } 396 return true, "" 397 } 398 399 var fromDockerOutputRE = regexp.MustCompile(`/from/`) 400 var toDockerOutputRE = regexp.MustCompile(`/to/`) 401 402 func (o *Optic) bulkCompareDocker(ctx context.Context, comparisons []comparison, dockerArgs []string) error { 403 input := &bulkCompareInput{ 404 Comparisons: comparisons, 405 } 406 o.runner.bulkInput(input) 407 inputFile, err := ioutil.TempFile("", "*-input.json") 408 if err != nil { 409 return err 410 } 411 defer inputFile.Close() 412 err = json.NewEncoder(inputFile).Encode(&input) 413 if err != nil { 414 return err 415 } 416 if err := inputFile.Sync(); err != nil { 417 return err 418 } 419 420 if o.debug { 421 log.Print("bulk-compare input:") 422 if err := json.NewEncoder(os.Stdout).Encode(&input); err != nil { 423 log.Println("failed to encode input to stdout!") 424 } 425 log.Println() 426 } 427 428 // Pull latest image 429 cmd := exec.CommandContext(ctx, "docker", "pull", o.image) 430 cmd.Stdout = os.Stdout 431 cmd.Stderr = os.Stderr 432 err = o.runner.run(cmd) 433 if err != nil { 434 return err 435 } 436 437 extraArgs := o.extraArgs 438 if ok, reason := o.checkUploadEnabled(); ok { 439 extraArgs = append(extraArgs, "--upload-results") 440 dockerArgs = append(dockerArgs, 441 "-e", "GITHUB_TOKEN="+os.Getenv("GITHUB_TOKEN"), 442 "-e", "OPTIC_TOKEN="+os.Getenv("OPTIC_TOKEN"), 443 ) 444 } else { 445 log.Printf("not uploading to Optic Cloud: %s", reason) 446 } 447 448 // Optic CI documentation: https://www.useoptic.com/docs/optic-ci 449 cmdline := append([]string{"run", "--rm", "-v", inputFile.Name() + ":/input.json"}, dockerArgs...) 450 cmdline = append(cmdline, o.image, "bulk-compare", "--input", "/input.json") 451 cmdline = append(cmdline, extraArgs...) 452 if o.debug { 453 log.Printf("running: docker %s", strings.Join(cmdline, " ")) 454 } 455 cmd = exec.CommandContext(ctx, "docker", cmdline...) 456 457 pipeReader, pipeWriter := io.Pipe() 458 ch := make(chan struct{}) 459 defer func() { 460 err := pipeWriter.Close() 461 if err != nil { 462 log.Printf("warning: failed to close output: %v", err) 463 } 464 select { 465 case <-ch: 466 return 467 case <-ctx.Done(): 468 return 469 case <-time.After(cmdTimeout): 470 log.Printf("warning: timeout waiting for output to flush") 471 return 472 } 473 }() 474 go func() { 475 defer pipeReader.Close() 476 sc := bufio.NewScanner(pipeReader) 477 for sc.Scan() { 478 line := sc.Text() 479 line = fromDockerOutputRE.ReplaceAllString(line, "("+o.fromSource.Name()+"):") 480 line = toDockerOutputRE.ReplaceAllString(line, "("+o.toSource.Name()+"):") 481 fmt.Println(line) 482 } 483 if err := sc.Err(); err != nil { 484 fmt.Fprintf(os.Stderr, "error reading stdout: %v", err) 485 } 486 close(ch) 487 }() 488 cmd.Stdin = os.Stdin 489 cmd.Stdout = pipeWriter 490 cmd.Stderr = os.Stderr 491 err = o.runner.run(cmd) 492 if err != nil { 493 return fmt.Errorf("lint failed: %w", err) 494 } 495 return nil 496 } 497 498 func (o *Optic) contextFromPath(path string) (*Context, error) { 499 dateDir := filepath.Dir(path) 500 resourceDir := filepath.Dir(dateDir) 501 date, resource := filepath.Base(dateDir), filepath.Base(resourceDir) 502 if _, err := time.Parse("2006-01-02", date); err != nil { 503 return nil, err 504 } 505 stability, err := o.loadStability(path) 506 if err != nil { 507 return nil, err 508 } 509 if _, err := vervet.ParseStability(stability); err != nil { 510 return nil, err 511 } 512 return &Context{ 513 ChangeDate: o.timeNow().UTC().Format("2006-01-02"), 514 ChangeResource: resource, 515 ChangeVersion: Version{ 516 Date: date, 517 Stability: stability, 518 }, 519 }, nil 520 } 521 522 func (o *Optic) loadStability(path string) (string, error) { 523 var ( 524 doc struct { 525 Stability string `json:"x-snyk-api-stability"` 526 } 527 contentsFile string 528 err error 529 ) 530 contentsFile, err = o.fromSource.Fetch(path) 531 if err != nil { 532 return "", err 533 } 534 if contentsFile == "" { 535 contentsFile, err = o.toSource.Fetch(path) 536 if err != nil { 537 return "", err 538 } 539 } 540 contents, err := ioutil.ReadFile(contentsFile) 541 if err != nil { 542 return "", err 543 } 544 err = yaml.Unmarshal(contents, &doc) 545 if err != nil { 546 return "", err 547 } 548 return doc.Stability, nil 549 } 550 551 const cmdTimeout = time.Second * 30