blob: 9b4cc6827acbd6ab424c4eaf4775625733c9cd76 [file] [log] [blame]
// Copyright 2023 The Shac Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package engine
//go:generate go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30.0
//go:generate protoc --go_out=. --go_opt=paths=source_relative shac.proto
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"go.chromium.org/luci/starlark/interpreter"
"go.starlark.net/resolve"
"go.starlark.net/starlark"
"golang.org/x/sync/errgroup"
"google.golang.org/protobuf/encoding/prototext"
)
func init() {
// Enable not-yet-standard Starlark features.
resolve.AllowRecursion = true
resolve.AllowSet = true
}
// Cursor represents a point in a content; generally a source file but it can
// also be a change description.
type Cursor struct {
Line int
Col int
// Require keyed arguments.
_ struct{}
}
// Span represents a section in a source file or a change description.
type Span struct {
// Start is the beginning of the span. If Col is specified, Line must be
// specified.
Start Cursor
// End is the end of the span. If not specified, the span has only one line.
// If Col is specified, Start.Col must be specified too. It is inclusive.
// That is, it is impossible to do a 0 width span.
End Cursor
// Require keyed arguments.
_ struct{}
}
// Level is one of "notice", "warning" or "error".
//
// A check is only considered failed if it emits at least one annotation with
// level "error".
type Level string
// Valid Level values.
const (
Notice Level = "notice"
Warning Level = "warning"
Error Level = "error"
Nothing Level = ""
)
func (l Level) isValid() bool {
switch l {
case Notice, Warning, Error:
return true
default:
return false
}
}
// Report exposes callbacks that the engine calls for everything generated by
// the starlark code.
type Report interface {
// EmitAnnotation emits an annotation by a check for a specific file. This is
// not a failure by itself, unless level "error" is used.
EmitAnnotation(ctx context.Context, check string, level Level, message, root, file string, s Span, replacements []string) error
// EmitArtifact emits an artifact by a check.
//
// Only one of root or content can be specified. If root is specified, it is
// a file on disk. The file may disappear after this function is called. If
// root is not specified, content is the artifact. Either way, file is the
// display name of the artifact.
EmitArtifact(ctx context.Context, check, root, file string, content []byte) error
// CheckCompleted is called when a check is completed.
//
// It is called with the start time, wall clock duration, the highest level emitted and an error
// if an abnormal error occurred.
CheckCompleted(ctx context.Context, check string, start time.Time, d time.Duration, r Level, err error)
// Print is called when print() starlark function is called.
Print(ctx context.Context, file string, line int, message string)
}
// Options is the options for Run().
type Options struct {
// Report gets all the emitted annotations and artifacts from the checks.
//
// This is the only required argument. It is recommended to use
// reporting.Get() which returns the right implementation based on the
// environment (CI, interactive, etc).
Report Report
// Root directory. Defaults to the current working directory.
Root string
// Main source file to run. Defaults to shac.star.
Main string
// Configuration file. Defaults to shac.textproto.
Config string
// AllFiles tells to consider all files as affected.
AllFiles bool
// Recurse tells the engine to run all Main files found in subdirectories.
Recurse bool
// Require keyed arguments.
_ struct{}
}
// Run loads a main shac.star file from a root directory and runs it.
func Run(ctx context.Context, o *Options) error {
root := o.Root
if root == "" {
root = "."
}
root, err := filepath.Abs(root)
if err != nil {
return err
}
main := o.Main
if main == "" {
main = "shac.star"
}
if filepath.IsAbs(main) {
return errors.New("main file must not be an absolute path")
}
config := o.Config
if config == "" {
config = "shac.textproto"
}
allowNetwork := false
p := filepath.Join(root, config)
var b []byte
if b, err = os.ReadFile(p); err == nil {
doc := Document{}
if err = prototext.Unmarshal(b, &doc); err != nil {
return err
}
if doc.MinShacVersion != "" {
v := parseVersion(doc.MinShacVersion)
if v == nil || len(v) > len(version) {
return errors.New("invalid min_shac_version")
}
for i := range v {
if v[i] > version[i] {
return fmt.Errorf("unsupported min_shac_version %q, running %d.%d.%d", doc.MinShacVersion, version[0], version[1], version[2])
}
if v[i] < version[i] {
break
}
}
}
allowNetwork = doc.AllowNetwork
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}
scm, err := getSCM(ctx, root, o.AllFiles)
if err != nil {
return err
}
// Each found shac.star is run in its own interpreter for maximum
// parallelism.
shacStates := []*shacState{
{
code: interpreter.FileSystemLoader(root),
r: o.Report,
allowNetwork: allowNetwork,
main: main,
root: root,
scm: scm,
},
}
if o.Recurse {
// Discover all the main files via the SCM. This enables us to not walk
// ignored files.
files, err := scm.allFiles(ctx)
if err != nil {
return err
}
for i := range files {
n := files[i].path
if filepath.Base(n) == main {
d := filepath.Dir(n)
if d == "." {
continue
}
nr := filepath.Join(root, d)
shacStates = append(shacStates,
&shacState{
code: interpreter.FileSystemLoader(nr),
r: o.Report,
allowNetwork: allowNetwork,
main: main,
root: nr,
scm: &subdirSCM{s: scm, subdir: d + "/"},
})
}
}
}
// Parse the starlark files.
// TODO(maruel): Run in parallel.
for _, s := range shacStates {
if err := s.parseAndRun(ctx); err != nil {
return err
}
}
return nil
}
// shacState represents a parsing state of one shac.star.
type shacState struct {
code interpreter.Loader
intr *interpreter.Interpreter
r Report
allowNetwork bool
main string
// root is the root for this shac.star.
root string
// scm is a filtered view of runState.scm.
scm scmCheckout
// checks is the list of registered checks callbacks via
// shac.register_check().
//
// Checks are added serially, so no lock is needed.
//
// Checks are executed sequentially after all Starlark code is loaded and not
// mutated. They run checks and emit results (results and comments).
checks []check
// Set when fail() is called. This happens only during the first phase, thus
// no mutex is needed.
failErr *failure
// Set when the first phase of starlark interpretation is complete. This
// complete the serial part, after which execution becomes concurrent.
doneLoading bool
mu sync.Mutex
printCalled bool
}
// ctxShacState pulls out *runState from the context.
//
// Panics if not there.
func ctxShacState(ctx context.Context) *shacState {
return ctx.Value(&shacStateCtxKey).(*shacState)
}
var shacStateCtxKey = "shac.shacState"
// parseAndRun parses and run a single shac.star file.
func (s *shacState) parseAndRun(ctx context.Context) error {
ctx = context.WithValue(ctx, &shacStateCtxKey, s)
if err := s.parse(ctx); err != nil {
return err
}
if len(s.checks) == 0 && !s.printCalled {
return errors.New("did you forget to call shac.register_check?")
}
// Last phase where checks are called.
if err := s.callAllChecks(ctx); err != nil {
return err
}
// If any check failed, return an error.
for i := range s.checks {
if s.checks[i].highestLevel == Error {
return ErrCheckFailed
}
}
return nil
}
// parse parses a single shac.star file.
func (s *shacState) parse(ctx context.Context) error {
s.intr = &interpreter.Interpreter{
Predeclared: getPredeclared(),
Packages: map[string]interpreter.Loader{interpreter.MainPkg: s.code},
Logger: func(file string, line int, message string) {
s.mu.Lock()
s.printCalled = true
s.mu.Unlock()
s.r.Print(ctx, file, line, message)
},
}
var err error
if err = s.intr.Init(ctx); err == nil {
_, err = s.intr.ExecModule(ctx, interpreter.MainPkg, s.main)
}
if err != nil {
if s.failErr != nil {
// We got a fail() call, use this instead.
return s.failErr
}
var evalErr *starlark.EvalError
if errors.As(err, &evalErr) {
return &evalError{evalErr}
}
return err
}
s.doneLoading = true
return nil
}
// callAllChecks calls all the checks.
//
// It creates a separate thread per check, limited by the number of CPU cores +
// 2. This permits to run them concurrently.
func (s *shacState) callAllChecks(ctx context.Context) error {
eg, ctx := errgroup.WithContext(ctx)
eg.SetLimit(runtime.NumCPU() + 2)
args := starlark.Tuple{getCtx(s.root)}
args.Freeze()
for i := range s.checks {
i := i
eg.Go(func() error {
start := time.Now()
err := s.checks[i].call(ctx, s.intr, args)
s.r.CheckCompleted(ctx, s.checks[i].name, start, time.Since(start), s.checks[i].highestLevel, err)
return err
})
}
return eg.Wait()
}
// check represents one check added via shac.register_check().
type check struct {
cb starlark.Callable
name string
failErr *failure // set when fail() is called from within the check, an abnormal failure.
highestLevel Level // highest level emitted by EmitAnnotation.
}
var checkCtxKey = "shac.check"
// ctxCheck pulls out *check from the context.
//
// Returns nil when not run inside a check.
func ctxCheck(ctx context.Context) *check {
c, _ := ctx.Value(&checkCtxKey).(*check)
return c
}
// call calls the check callback and returns an error if an abnormal error happened.
//
// A "normal" error will still have this function return nil.
func (c *check) call(ctx context.Context, intr *interpreter.Interpreter, args starlark.Tuple) error {
ctx = context.WithValue(ctx, &checkCtxKey, c)
th := intr.Thread(ctx)
th.Name = c.name
if r, err := starlark.Call(th, c.cb, args, nil); err != nil {
if c.failErr != nil {
// fail() was called, return this error since this is an abnormal failure.
return c.failErr
}
var evalErr *starlark.EvalError
if errors.As(err, &evalErr) {
return &evalError{evalErr}
}
} else if r != starlark.None {
return fmt.Errorf("check %q returned an object of type %s, expected None", c.name, r.Type())
}
return nil
}
func parseVersion(s string) []int {
var out []int
for _, x := range strings.Split(s, ".") {
i, err := strconv.Atoi(x)
if err != nil {
return nil
}
out = append(out, i)
}
return out
}