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 }