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