blob: 0764848a69d56d9b8b3e3504b60912252894db7a [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"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"go.fuchsia.dev/shac-project/shac/internal/sandbox"
"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.
//
// content must not be modified.
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, check, 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
// AllFiles tells to consider all files as affected.
AllFiles bool
// Recurse tells the engine to run all Main files found in subdirectories.
Recurse bool
// main source file to run. Defaults to shac.star. Only used in unit tests.
main string
// config is the configuration file. Defaults to shac.textproto. Only used in
// unit tests.
config string
}
// 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"
}
p := filepath.Join(root, config)
var b []byte
doc := Document{}
if b, err = os.ReadFile(p); err == nil {
if err = prototext.Unmarshal(b, &doc); err != nil {
return err
}
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}
if err = doc.Validate(); err != nil {
return err
}
scm, err := getSCM(ctx, root, o.AllFiles)
if err != nil {
return err
}
tmpdir, err := os.MkdirTemp("", "shac")
if err != nil {
return nil
}
pkgMgr := PackageManager{Root: tmpdir}
packages, err := pkgMgr.RetrievePackages(ctx, root, &doc)
if err != nil {
return err
}
err = runInner(ctx, root, tmpdir, main, o.Report, doc.AllowNetwork, o.Recurse, scm, packages)
if err2 := os.RemoveAll(tmpdir); err == nil {
err = err2
}
return err
}
func runInner(ctx context.Context, root, tmpdir, main string, r Report, allowNetwork, recurse bool, scm scmCheckout, packages map[string]fs.FS) error {
sb, err := sandbox.New(tmpdir)
if err != nil {
return err
}
env := starlarkEnv{
globals: getPredeclared(),
sources: map[string]*loadedSource{},
packages: packages,
}
var shacStates []*shacState
if recurse {
// Each found shac.star is run in its own interpreter for maximum
// parallelism.
// Discover all the main files via the SCM. This enables us to not walk
// ignored files.
// TODO(olivernewman): Because we use scm to discover shac.star files,
// shac will ignore any unstaged shac.star files. This is
// counterintuitive, we should respect unstaged shac.star files.
files, err := scm.allFiles(ctx, false)
if err != nil {
return err
}
for i := range files {
n := files[i].path
if filepath.Base(n) == main {
d := strings.ReplaceAll(filepath.Dir(n), "\\", "/")
dd := d + "/"
if d == "." {
d = ""
dd = ""
}
shacStates = append(shacStates,
&shacState{
env: &env,
r: r,
allowNetwork: allowNetwork,
main: main,
root: root,
subdir: d,
tmpdir: filepath.Join(tmpdir, strconv.Itoa(i)),
scm: &subdirSCM{s: scm, subdir: dd},
sandbox: sb,
})
}
}
if len(shacStates) == 0 {
return fmt.Errorf("no %s files found", main)
}
} else {
shacStates = []*shacState{
{
env: &env,
r: r,
allowNetwork: allowNetwork,
main: main,
root: root,
tmpdir: filepath.Join(tmpdir, "0"),
scm: scm,
sandbox: sb,
},
}
}
// Parse the starlark files. Run everything from our errgroup.
eg, ctx := errgroup.WithContext(ctx)
eg.SetLimit(runtime.NumCPU() + 2)
// Make it so each shac can submit at least one item.
ch := make(chan func() error, len(shacStates))
done := make(chan struct{})
for _, s := range shacStates {
s := s
eg.Go(func() error {
err := s.parseAndBuffer(ctx, ch)
done <- struct{}{}
return err
})
}
count := len(shacStates)
for loop := true; loop; {
select {
case cb := <-ch:
if cb == nil {
loop = false
} else {
eg.Go(cb)
}
case <-done:
count--
if count == 0 {
// All shac.star processing is done, we can now send a nil to the
// channel to tell it to stop.
// Since we are pushing from the same loop that we are pulling, this is
// blocking. Instead of making the channel buffered, which would slow
// it down, use a one time goroutine. It's kind of a gross hack but
// it'll work just fine.
go func() {
ch <- nil
}()
}
}
}
if err := eg.Wait(); err != nil {
return err
}
// If any check failed, return an error.
for _, s := range shacStates {
for i := range s.checks {
if s.checks[i].highestLevel == Error {
return ErrCheckFailed
}
}
}
return nil
}
// shacState represents a parsing state of one shac.star.
type shacState struct {
env *starlarkEnv
r Report
allowNetwork bool
main string
// root is the root for the root shac.star that was executed. Native path
// style.
root string
// subdir is the directory into which this shac.star is located. Only set
// when Options.Recurse is set to true. POSIX path style.
subdir string
tmpdir string
// scm is a filtered view of runState.scm.
scm scmCheckout
// sandbox is the object that can be used for sandboxing subprocesses.
sandbox sandbox.Sandbox
// 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 []registeredCheck
// 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
tmpdirIndex int
}
// 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"
// parseAndBuffer parses and run a single shac.star file, then buffer all its checks.
func (s *shacState) parseAndBuffer(ctx context.Context, ch chan<- func() error) 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.
s.bufferAllChecks(ctx, ch)
return nil
}
// parse parses a single shac.star file.
func (s *shacState) parse(ctx context.Context) error {
pi := func(th *starlark.Thread, msg string) {
// Detect if print() was called while loading. Calling either print() or
// shac.register_check() makes a shac.star valid.
s.mu.Lock()
s.printCalled = true
s.mu.Unlock()
pos := th.CallFrame(1).Pos
s.r.Print(ctx, "", pos.Filename(), int(pos.Line), msg)
}
p := path.Join(s.subdir, s.main)
if _, err := s.env.load(ctx, sourceKey{orig: p, pkg: "__main__", relpath: p}, pi); err != nil {
var evalErr *starlark.EvalError
if errors.As(err, &evalErr) {
return &evalError{evalErr}
}
return err
}
s.doneLoading = true
return nil
}
// bufferAllChecks adds all the checks to the channel for execution.
func (s *shacState) bufferAllChecks(ctx context.Context, ch chan<- func() error) {
args := starlark.Tuple{getCtx(path.Join(s.root, s.subdir))}
args.Freeze()
for i := range s.checks {
i := i
ch <- func() error {
start := time.Now()
pi := func(th *starlark.Thread, msg string) {
pos := th.CallFrame(1).Pos
s.r.Print(ctx, s.checks[i].name, pos.Filename(), int(pos.Line), msg)
}
err := s.checks[i].call(ctx, s.env, args, pi)
if err != nil && ctx.Err() != nil {
// Don't report the check completion if the context was
// canceled. The error was probably caused by the context being
// canceled as a side effect of another check failing. Only the
// original check failure should be reported, not the canceled
// check failures.
return ctx.Err()
}
s.r.CheckCompleted(ctx, s.checks[i].name, start, time.Since(start), s.checks[i].highestLevel, err)
return err
}
}
}
func (s *shacState) newTempDir() (string, error) {
var err error
s.mu.Lock()
i := s.tmpdirIndex
s.tmpdirIndex++
if i == 0 {
// First use, lazy create the temporary directory.
err = os.Mkdir(s.tmpdir, 0o700)
}
s.mu.Unlock()
if err != nil {
return "", err
}
p := filepath.Join(s.tmpdir, strconv.Itoa(i))
if err = os.Mkdir(p, 0o700); err != nil {
return "", err
}
return p, nil
}
// registeredCheck represents one check that has been registered by
// shac.register_check().
type registeredCheck struct {
*check
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 *registeredCheck from the context.
//
// Returns nil when not run inside a check.
func ctxCheck(ctx context.Context) *registeredCheck {
c, _ := ctx.Value(&checkCtxKey).(*registeredCheck)
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 *registeredCheck) call(ctx context.Context, env *starlarkEnv, args starlark.Tuple, pi printImpl) error {
ctx = context.WithValue(ctx, &checkCtxKey, c)
th := env.thread(ctx, c.name, pi)
if r, err := starlark.Call(th, c.impl, 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
}