blob: 959c62af255cb3e97fab023d02e19ff6ef5c6b89 [file] [log] [blame]
package blacklist
import (
"encoding/json"
"fmt"
"os"
"regexp"
"sync"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/git/repograph"
"go.skia.org/infra/go/util"
)
const (
MAX_NAME_CHARS = 50
)
var (
DEFAULT_RULES = []*Rule{}
ERR_NO_SUCH_RULE = fmt.Errorf("No such rule.")
)
// Blacklist is a struct which contains rules specifying tasks which should
// not be scheduled.
type Blacklist struct {
backingFile string
Rules map[string]*Rule `json:"rules"`
mtx sync.RWMutex
}
// Match determines whether the given taskSpec/commit pair matches one of the
// Rules in the Blacklist.
func (b *Blacklist) Match(taskSpec, commit string) bool {
return b.MatchRule(taskSpec, commit) != ""
}
// MatchRule determines whether the given taskSpec/commit pair matches one of the
// Rules in the Blacklist. Returns the name of the matched Rule or the empty
// string if no Rules match.
func (b *Blacklist) MatchRule(taskSpec, commit string) string {
b.mtx.RLock()
defer b.mtx.RUnlock()
for _, rule := range b.Rules {
if rule.Match(taskSpec, commit) {
return rule.Name
}
}
return ""
}
// ensureDefaults adds the necessary default blacklist rules if necessary.
func (b *Blacklist) ensureDefaults() error {
for _, rule := range DEFAULT_RULES {
if err := b.removeRule(rule.Name); err != nil {
if err.Error() != ERR_NO_SUCH_RULE.Error() {
return err
}
}
if err := b.addRule(rule); err != nil {
return err
}
}
return nil
}
// writeOut writes the Blacklist to its backing file. Assumes that the caller
// holds a write lock.
func (b *Blacklist) writeOut() error {
f, err := os.Create(b.backingFile)
if err != nil {
return err
}
defer util.Close(f)
return json.NewEncoder(f).Encode(b)
}
// Add adds a new Rule to the Blacklist.
func (b *Blacklist) AddRule(r *Rule, repos repograph.Map) error {
if err := ValidateRule(r, repos); err != nil {
return err
}
return b.addRule(r)
}
// addRule adds a new Rule to the Blacklist.
func (b *Blacklist) addRule(r *Rule) error {
b.mtx.Lock()
defer b.mtx.Unlock()
if _, ok := b.Rules[r.Name]; ok {
return fmt.Errorf("Blacklist already contains a rule named %q", r.Name)
}
b.Rules[r.Name] = r
if err := b.writeOut(); err != nil {
delete(b.Rules, r.Name)
return err
}
return nil
}
// NewCommitRangeRule creates a new Rule which covers a range of commits.
func NewCommitRangeRule(name, user, description string, taskSpecPatterns []string, startCommit, endCommit string, repos repograph.Map) (*Rule, error) {
_, repoName, _, err := repos.FindCommit(startCommit)
if err != nil {
return nil, err
}
_, repo2, _, err := repos.FindCommit(endCommit)
if err != nil {
return nil, err
}
if repo2 != repoName {
return nil, fmt.Errorf("Commit %s is in a different repo (%s) from %s (%s)", endCommit, repo2, startCommit, repoName)
}
repo, ok := repos[repoName]
if !ok {
return nil, fmt.Errorf("Unknown repo %s", repoName)
}
commits, err := repo.Repo().RevList(fmt.Sprintf("%s..%s", startCommit, endCommit))
if err != nil {
return nil, err
}
if len(commits) == 0 {
return nil, fmt.Errorf("No commits in range %s..%s", startCommit, endCommit)
}
// `git rev-list ${startCommit}..${endCommit}` returns a list of commits
// which does not include startCommit but does include endCommit. For
// blacklisting rules, we want to include startCommit and not endCommit.
// The rev-list command returns commits in order of newest to oldest, so
// we remove the first element of the slice (endCommit), and append
// startCommit to the end.
commits = append(commits[1:], startCommit)
if util.In(endCommit, commits) {
return nil, fmt.Errorf("Failed to adjust commit range; still includes endCommit.")
}
if !util.In(startCommit, commits) {
return nil, fmt.Errorf("Failed to adjust commit range; does not include startCommit.")
}
rule := &Rule{
AddedBy: user,
TaskSpecPatterns: taskSpecPatterns,
Commits: commits,
Description: description,
Name: name,
}
if err := ValidateRule(rule, repos); err != nil {
return nil, err
}
return rule, nil
}
// removeRule removes the Rule from the Blacklist.
func (b *Blacklist) removeRule(name string) error {
b.mtx.Lock()
defer b.mtx.Unlock()
r, ok := b.Rules[name]
if !ok {
return ERR_NO_SUCH_RULE
}
delete(b.Rules, name)
if err := b.writeOut(); err != nil {
b.Rules[name] = r
return err
}
return nil
}
// RemoveRule removes the Rule from the Blacklist.
func (b *Blacklist) RemoveRule(name string) error {
for _, r := range DEFAULT_RULES {
if r.Name == name {
return fmt.Errorf("Cannot remove built-in rule %q", name)
}
}
return b.removeRule(name)
}
// Rule is a struct which indicates a specific task or set of tasks which
// should not be scheduled.
//
// TaskSpecPatterns consists of regular expressions used to match taskSpecs
// which should not be triggered according to this Rule.
//
// Commits are simply commit hashes for which the rule applies. If the list is
// empty, the Rule applies for all commits.
//
// A Rule should specify TaskSpecPatterns or Commits or both.
type Rule struct {
AddedBy string `json:"added_by"`
TaskSpecPatterns []string `json:"task_spec_patterns"`
Commits []string `json:"commits"`
Description string `json:"description"`
Name string `json:"name"`
}
// ValidateRule returns an error if the given Rule is not valid.
func ValidateRule(r *Rule, repos repograph.Map) error {
if r.Name == "" {
return fmt.Errorf("Rules must have a name.")
}
if len(r.Name) > MAX_NAME_CHARS {
return fmt.Errorf("Rule names must be shorter than %d characters. Use the Description field for detailed information.", MAX_NAME_CHARS)
}
if r.AddedBy == "" {
return fmt.Errorf("Rules must have an AddedBy user.")
}
if len(r.TaskSpecPatterns) == 0 && len(r.Commits) == 0 {
return fmt.Errorf("Rules must include a taskSpec pattern and/or a commit/range.")
}
for _, c := range r.Commits {
if _, _, _, err := repos.FindCommit(c); err != nil {
return err
}
}
return nil
}
// matchTaskSpec determines whether the taskSpec portion of the Rule matches.
func (r *Rule) matchTaskSpec(taskSpec string) bool {
// If no taskSpecs are specified, then the rule applies for ALL taskSpecs.
if len(r.TaskSpecPatterns) == 0 {
return true
}
// If any pattern matches the taskSpec, then the rule applies.
for _, b := range r.TaskSpecPatterns {
match, err := regexp.MatchString(b, taskSpec)
if err != nil {
sklog.Warningf("Rule regexp returned error for input %q: %s: %s", taskSpec, b, err)
return false
}
if match {
return true
}
}
return false
}
// matchCommit determines whether the commit portion of the Rule matches.
func (r *Rule) matchCommit(commit string) bool {
// If no commit is specified, then the rule applies for ALL commits.
k := len(r.Commits)
if k == 0 {
return true
}
// If at least one commit is specified, do simple string comparisons.
for _, c := range r.Commits {
if commit == c {
return true
}
}
return false
}
// Match returns true iff the Rule matches the given taskSpec and commit.
func (r *Rule) Match(taskSpec, commit string) bool {
return r.matchTaskSpec(taskSpec) && r.matchCommit(commit)
}
// FromFile returns a Blacklist instance based on the given file. If the file
// does not exist, the Blacklist will be empty and will attempt to use the file
// for writing.
func FromFile(file string) (*Blacklist, error) {
b := &Blacklist{
backingFile: file,
mtx: sync.RWMutex{},
}
f, err := os.Open(file)
if err != nil {
if os.IsNotExist(err) {
b.Rules = map[string]*Rule{}
if err := b.writeOut(); err != nil {
return nil, err
}
} else {
return nil, err
}
} else {
defer util.Close(f)
if err := json.NewDecoder(f).Decode(b); err != nil {
return nil, err
}
}
if err := b.ensureDefaults(); err != nil {
return nil, err
}
return b, nil
}