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