github.com/iasthc/atlas/cmd/atlas@v0.0.0-20230523071841-73246df3f88d/internal/lint/run.go (about)

     1  // Copyright 2021-present The Atlas Authors. All rights reserved.
     2  // This source code is licensed under the Apache 2.0 license found
     3  // in the LICENSE file in the root directory of this source tree.
     4  
     5  package lint
     6  
     7  import (
     8  	"context"
     9  	"encoding/json"
    10  	"errors"
    11  	"fmt"
    12  	"io"
    13  	"io/fs"
    14  	"strings"
    15  	"text/template"
    16  
    17  	"github.com/iasthc/atlas/sql/migrate"
    18  	"github.com/iasthc/atlas/sql/sqlcheck"
    19  	"github.com/iasthc/atlas/sql/sqlclient"
    20  
    21  	"golang.org/x/exp/slices"
    22  )
    23  
    24  // Runner is used to execute CI jobs.
    25  type Runner struct {
    26  	// DevClient configures the "dev driver" to calculate
    27  	// migration changes by the driver.
    28  	Dev *sqlclient.Client
    29  
    30  	// RunChangeDetector configures the ChangeDetector to
    31  	// be used by the runner.
    32  	ChangeDetector ChangeDetector
    33  
    34  	// Dir is used for scanning and validating the migration directory.
    35  	Dir migrate.Dir
    36  
    37  	// Analyzers defines the analysis to be run in the CI job.
    38  	Analyzers []sqlcheck.Analyzer
    39  
    40  	// ReportWriter writes the summary report.
    41  	ReportWriter ReportWriter
    42  
    43  	// summary report. reset on each run.
    44  	sum *SummaryReport
    45  }
    46  
    47  // Run executes the CI job.
    48  func (r *Runner) Run(ctx context.Context) error {
    49  	switch err := r.summary(ctx); err.(type) {
    50  	case nil:
    51  		if err := r.ReportWriter.WriteReport(r.sum); err != nil {
    52  			return err
    53  		}
    54  		// If any of the analyzers returns
    55  		// an error, fail silently.
    56  		for _, f := range r.sum.Files {
    57  			if f.Error != "" {
    58  				return SilentError{}
    59  			}
    60  		}
    61  		return nil
    62  	case *FileError:
    63  		if err := r.ReportWriter.WriteReport(r.sum); err != nil {
    64  			return err
    65  		}
    66  		return SilentError{error: err}
    67  	default:
    68  		return err
    69  	}
    70  }
    71  
    72  const (
    73  	stepIntegrityCheck = "Migration Integrity Check"
    74  	stepDetectChanges  = "Detect New Migration Files"
    75  	stepLoadChanges    = "Replay Migration Files"
    76  	stepAnalyzeFile    = "Analyze %s"
    77  )
    78  
    79  func (r *Runner) summary(ctx context.Context) error {
    80  	r.sum = NewSummaryReport(r.Dev, r.Dir)
    81  
    82  	// Integrity check.
    83  	switch err := migrate.Validate(r.Dir); {
    84  	case errors.Is(err, migrate.ErrChecksumNotFound):
    85  	case err != nil:
    86  		err := &FileError{File: migrate.HashFileName, Err: err}
    87  		r.sum.Files = append(r.sum.Files, &FileReport{Name: migrate.HashFileName, Error: err.Error()})
    88  		return r.sum.StepError(stepIntegrityCheck, fmt.Sprintf("File %s is invalid", migrate.HashFileName), err)
    89  	default:
    90  		// If the hash file exists, it is valid.
    91  		if _, err := fs.Stat(r.Dir, migrate.HashFileName); err == nil {
    92  			r.sum.StepResult(stepIntegrityCheck, fmt.Sprintf("File %s is valid", migrate.HashFileName), nil)
    93  		}
    94  	}
    95  
    96  	// Detect new migration files.
    97  	base, feat, err := r.ChangeDetector.DetectChanges(ctx)
    98  	if err != nil {
    99  		return r.sum.StepError(stepDetectChanges, "Failed find new migration files", err)
   100  	}
   101  	r.sum.StepResult(stepDetectChanges, fmt.Sprintf("Found %d new migration files (from %d total)", len(feat), len(base)+len(feat)), nil)
   102  
   103  	// Load files into changes.
   104  	l := &DevLoader{Dev: r.Dev}
   105  	diff, err := l.LoadChanges(ctx, base, feat)
   106  	if err != nil {
   107  		if fr := (&FileError{}); errors.As(err, &fr) {
   108  			r.sum.Files = append(r.sum.Files, &FileReport{Name: fr.File, Error: err.Error()})
   109  		}
   110  		return r.sum.StepError(stepLoadChanges, "Failed loading changes on dev database", err)
   111  	}
   112  	r.sum.StepResult(stepLoadChanges, fmt.Sprintf("Loaded %d changes on dev database", len(diff.Files)), nil)
   113  	r.sum.WriteSchema(r.Dev, diff)
   114  
   115  	// Analyze files.
   116  	for _, f := range diff.Files {
   117  		var (
   118  			es []string
   119  			nl = nolintRules(f)
   120  			fr = NewFileReport(f)
   121  		)
   122  		if nl.ignored {
   123  			continue
   124  		}
   125  		for _, az := range r.Analyzers {
   126  			err := az.Analyze(ctx, &sqlcheck.Pass{
   127  				File:     f,
   128  				Dev:      r.Dev,
   129  				Reporter: nl.reporterFor(fr, az),
   130  			})
   131  			// If the last report was skipped,
   132  			// skip emitting its error.
   133  			if err != nil && !nl.skipped {
   134  				es = append(es, err.Error())
   135  			}
   136  		}
   137  		fr.Error = strings.Join(es, "; ")
   138  		r.sum.Files = append(r.sum.Files, fr)
   139  		r.sum.StepResult(
   140  			fmt.Sprintf(stepAnalyzeFile, f.Name()),
   141  			fmt.Sprintf("%d reports were found in analysis", len(fr.Reports)),
   142  			fr,
   143  		)
   144  	}
   145  	return nil
   146  }
   147  
   148  var (
   149  	// TemplateFuncs are global functions available in templates.
   150  	TemplateFuncs = template.FuncMap{
   151  		"json": func(v any, args ...string) (string, error) {
   152  			var (
   153  				b   []byte
   154  				err error
   155  			)
   156  			switch len(args) {
   157  			case 0:
   158  				b, err = json.Marshal(v)
   159  			case 1:
   160  				b, err = json.MarshalIndent(v, "", args[0])
   161  			default:
   162  				b, err = json.MarshalIndent(v, args[0], args[1])
   163  			}
   164  			return string(b), err
   165  		},
   166  	}
   167  	// DefaultTemplate is the default template used by the CI job.
   168  	DefaultTemplate = template.Must(template.New("report").
   169  			Funcs(TemplateFuncs).
   170  			Parse(`
   171  {{- range $f := .Files }}
   172  	{{- /* If there is an error but not diagnostics, print it. */}}
   173  	{{- if and $f.Error (not $f.Reports) }}
   174  		{{- printf "%s: %s\n" $f.Name $f.Error }}
   175  	{{- else }}
   176  		{{- range $r := $f.Reports }}
   177  			{{- if $r.Text }}
   178  				{{- printf "%s: %s:\n\n" $f.Name $r.Text }}
   179  			{{- else if $r.Diagnostics }}
   180  				{{- printf "Unnamed diagnostics for file %s:\n\n" $f.Name }}
   181  			{{- end }}
   182  			{{- range $d := $r.Diagnostics }}
   183  				{{- printf "\tL%d: %s\n" ($f.Line $d.Pos) $d.Text }}
   184  			{{- end }}
   185  			{{- if $r.Diagnostics }}
   186  				{{- print "\n" }}
   187  			{{- end }}
   188  		{{- end }}
   189  	{{- end }}
   190  {{- end -}}
   191  `))
   192  )
   193  
   194  type (
   195  	// A SummaryReport contains a summary of the analysis of all files.
   196  	// It is used as an input to templates to report the CI results.
   197  	SummaryReport struct {
   198  		// Env holds the environment information.
   199  		Env struct {
   200  			Driver string         `json:"Driver,omitempty"` // Driver name.
   201  			URL    *sqlclient.URL `json:"URL,omitempty"`    // URL to dev database.
   202  			Dir    string         `json:"Dir,omitempty"`    // Path to migration directory.
   203  		}
   204  
   205  		// Steps of the analysis. Added in verbose mode.
   206  		Steps []struct {
   207  			Name   string      `json:"Name,omitempty"`   // Step name.
   208  			Text   string      `json:"Text,omitempty"`   // Step description.
   209  			Error  string      `json:"Error,omitempty"`  // Error that cause the execution to halt.
   210  			Result *FileReport `json:"Result,omitempty"` // Result of the step. For example, a diagnostic.
   211  		}
   212  
   213  		// Schema versions found by the runner.
   214  		Schema struct {
   215  			Current string `json:"Current,omitempty"` // Current schema.
   216  			Desired string `json:"Desired,omitempty"` // Desired schema.
   217  		}
   218  
   219  		// Files reports. Non-empty in case there are findings.
   220  		Files []*FileReport `json:"Files,omitempty"`
   221  	}
   222  
   223  	// FileReport contains a summary of the analysis of a single file.
   224  	FileReport struct {
   225  		Name    string            `json:"Name,omitempty"`    // Name of the file.
   226  		Text    string            `json:"Text,omitempty"`    // Contents of the file.
   227  		Reports []sqlcheck.Report `json:"Reports,omitempty"` // List of reports.
   228  		Error   string            `json:"Error,omitempty"`   // File specific error.
   229  	}
   230  
   231  	// ReportWriter is a type of report writer that writes a summary of analysis reports.
   232  	ReportWriter interface {
   233  		WriteReport(*SummaryReport) error
   234  	}
   235  
   236  	// A TemplateWriter is a type of writer that writes output according to a template.
   237  	TemplateWriter struct {
   238  		T *template.Template
   239  		W io.Writer
   240  	}
   241  
   242  	// SilentError is returned in case the wrapped error is already
   243  	// printed by the runner and should not be printed by its caller
   244  	SilentError struct{ error }
   245  )
   246  
   247  // NewSummaryReport returns a new SummaryReport.
   248  func NewSummaryReport(c *sqlclient.Client, dir migrate.Dir) *SummaryReport {
   249  	sum := &SummaryReport{
   250  		Env: struct {
   251  			Driver string         `json:"Driver,omitempty"`
   252  			URL    *sqlclient.URL `json:"URL,omitempty"`
   253  			Dir    string         `json:"Dir,omitempty"`
   254  		}{
   255  			Driver: c.Name,
   256  			URL:    c.URL,
   257  		},
   258  		Files: make([]*FileReport, 0),
   259  	}
   260  	if p, ok := dir.(interface{ Path() string }); ok {
   261  		sum.Env.Dir = p.Path()
   262  	}
   263  	return sum
   264  }
   265  
   266  // StepResult appends step result to the summary.
   267  func (f *SummaryReport) StepResult(name, text string, result *FileReport) {
   268  	f.Steps = append(f.Steps, struct {
   269  		Name   string      `json:"Name,omitempty"`
   270  		Text   string      `json:"Text,omitempty"`
   271  		Error  string      `json:"Error,omitempty"`
   272  		Result *FileReport `json:"Result,omitempty"`
   273  	}{
   274  		Name:   name,
   275  		Text:   text,
   276  		Result: result,
   277  	})
   278  }
   279  
   280  // StepError appends step error to the summary.
   281  func (f *SummaryReport) StepError(name, text string, err error) error {
   282  	f.Steps = append(f.Steps, struct {
   283  		Name   string      `json:"Name,omitempty"`
   284  		Text   string      `json:"Text,omitempty"`
   285  		Error  string      `json:"Error,omitempty"`
   286  		Result *FileReport `json:"Result,omitempty"`
   287  	}{
   288  		Name:  name,
   289  		Text:  text,
   290  		Error: err.Error(),
   291  	})
   292  	return err
   293  }
   294  
   295  // WriteSchema writes the current and desired schema to the summary.
   296  func (f *SummaryReport) WriteSchema(c *sqlclient.Client, diff *Changes) {
   297  	if curr, err := c.MarshalSpec(diff.From); err == nil {
   298  		f.Schema.Current = string(curr)
   299  	}
   300  	if desired, err := c.MarshalSpec(diff.To); err == nil {
   301  		f.Schema.Desired = string(desired)
   302  	}
   303  }
   304  
   305  // NewFileReport returns a new FileReport.
   306  func NewFileReport(f migrate.File) *FileReport {
   307  	return &FileReport{Name: f.Name(), Text: string(f.Bytes())}
   308  }
   309  
   310  // Line returns the line number from a position.
   311  func (f *FileReport) Line(pos int) int {
   312  	return strings.Count(f.Text[:pos], "\n") + 1
   313  }
   314  
   315  // WriteReport implements sqlcheck.ReportWriter.
   316  func (f *FileReport) WriteReport(r sqlcheck.Report) {
   317  	f.Reports = append(f.Reports, r)
   318  }
   319  
   320  // WriteReport implements ReportWriter.
   321  func (w *TemplateWriter) WriteReport(r *SummaryReport) error {
   322  	return w.T.Execute(w.W, r)
   323  }
   324  
   325  func nolintRules(f *sqlcheck.File) *skipRules {
   326  	s := &skipRules{pos2rules: make(map[int][]string)}
   327  	if l, ok := f.File.(*migrate.LocalFile); ok {
   328  		ds := l.Directive("nolint")
   329  		// A file directive without specific classes/codes
   330  		// (e.g. atlas:nolint) ignores the entire file.
   331  		if s.ignored = len(ds) == 1 && ds[0] == ""; s.ignored {
   332  			return s
   333  		}
   334  		// A file directive with specific classes/codes applies these
   335  		// rules on all statements (e.g., atlas:nolint destructive).
   336  		for _, d := range ds {
   337  			for _, c := range f.Changes {
   338  				s.pos2rules[c.Stmt.Pos] = append(s.pos2rules[c.Stmt.Pos], strings.Split(d, " ")...)
   339  			}
   340  		}
   341  	}
   342  	for _, c := range f.Changes {
   343  		for _, d := range c.Stmt.Directive("nolint") {
   344  			s.pos2rules[c.Stmt.Pos] = append(s.pos2rules[c.Stmt.Pos], strings.Split(d, " ")...)
   345  		}
   346  	}
   347  	return s
   348  }
   349  
   350  type skipRules struct {
   351  	pos2rules map[int][]string // statement positions to rules
   352  	ignored   bool             // file is ignored. i.e., no analysis is performed
   353  	skipped   bool             // if the last report was skipped by the rules
   354  }
   355  
   356  func (s *skipRules) reporterFor(rw sqlcheck.ReportWriter, az sqlcheck.Analyzer) sqlcheck.ReportWriter {
   357  	return sqlcheck.ReportWriterFunc(func(r sqlcheck.Report) {
   358  		var (
   359  			ds     = make([]sqlcheck.Diagnostic, 0, len(r.Diagnostics))
   360  			az, ok = az.(sqlcheck.NamedAnalyzer)
   361  		)
   362  		for _, d := range r.Diagnostics {
   363  			switch rules := s.pos2rules[d.Pos]; {
   364  			case
   365  				// A directive without specific classes/codes
   366  				// (e.g. atlas:nolint) ignore all diagnostics.
   367  				len(rules) == 1 && rules[0] == "",
   368  				// Match a specific code/diagnostic. e.g. atlas:nolint DS101.
   369  				slices.Contains(rules, d.Code),
   370  				// Skip the entire analyzer (class of changes).
   371  				ok && slices.Contains(rules, az.Name()):
   372  			default:
   373  				ds = append(ds, d)
   374  			}
   375  		}
   376  		if s.skipped = len(ds) == 0; !s.skipped {
   377  			rw.WriteReport(sqlcheck.Report{Text: r.Text, Diagnostics: ds})
   378  		}
   379  	})
   380  }