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