kubernetes/hack/tools/publishing-verifier/publishing-verifier.go

/*
Copyright 2024 The Kubernetes 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 main

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"golang.org/x/mod/modfile"
	"k8s.io/publishing-bot/cmd/publishing-bot/config"
)

var (
	rulesFile           string
	componentsDirectory string
)

// getGoModDependencies gets all the staging dependencies for all the modules
// in the given directory
func getGoModDependencies(dir string) (map[string][]string, error) {
	allDependencies := make(map[string][]string)
	components, err := os.ReadDir(dir)
	if err != nil {
		return nil, err
	}
	for _, component := range components {
		componentName := component.Name()
		if !component.IsDir() {
			// currently there is no hard check that the staging directory should not contain
			// other files
			continue
		}
		gomodFilePath := filepath.Join(dir, componentName, "go.mod")
		gomodFileContent, err := os.ReadFile(gomodFilePath)
		if err != nil {
			return nil, err
		}

		fmt.Printf("%s dependencies", componentName)

		allDependencies[componentName] = make([]string, 0)

		gomodFile, err := modfile.Parse(gomodFilePath, gomodFileContent, nil)
		if err != nil {
			return nil, err
		}
		// get all the other dependencies from within staging, i.e all the modules in replace
		// section
		for _, module := range gomodFile.Replace {
			dep := strings.TrimPrefix(module.Old.Path, "k8s.io/")
			if dep == componentName {
				continue
			}
			allDependencies[componentName] = append(allDependencies[componentName], dep)
		}
	}
	return allDependencies, nil
}

// diffSlice returns the difference of s1-s2
func diffSlice(s1, s2 []string) []string {
	var diff []string
	set := make(map[string]struct{}, len(s2))
	for _, s := range s2 {
		set[s] = struct{}{}
	}
	for _, s := range s1 {
		if _, ok := set[s]; !ok {
			diff = append(diff, s)
		}
	}
	return diff
}

// getKeys returns a slice with only the keys of the given map
func getKeys[K comparable, V any](m map[K]V) []K {
	var keys []K
	for k := range m {
		keys = append(keys, k)
	}
	return keys
}

// checkValidSourceDirectory checks if proper source directory fields are used in rules
func checkValidSourceDirectory(rule config.RepositoryRule) error {
	for _, branch := range rule.Branches {
		if branch.Source.Dir != "" {
			return fmt.Errorf("use of deprecated `dir` field in rules for `%s`", rule.DestinationRepository)
		}
		if len(branch.Source.Dirs) > 1 {
			return fmt.Errorf("cannot have more than one directory (%s) per source branch `%s` of `%s`",
				branch.Source.Dirs,
				branch.Source.Branch,
				rule.DestinationRepository,
			)
		}
		if !strings.HasSuffix(branch.Source.Dirs[0], rule.DestinationRepository) {
			return fmt.Errorf("copy/paste error `%s` refers to `%s`", rule.DestinationRepository, branch.Source.Dirs[0])
		}
	}
	return nil
}

// checkMasterBranch checks if the master branch of destination repository refers to the master
// of the source
func checkMasterBranch(rule config.RepositoryRule) error {
	branch := rule.Branches[0]
	if branch.Name != "master" {
		return fmt.Errorf("cannot find master branch for destination `%s`", rule.DestinationRepository)
	}

	if branch.Source.Branch != "master" {
		return fmt.Errorf("cannot find master source branch for destination `%s`", rule.DestinationRepository)
	}
	return nil
}

func checkDependencies(rule config.RepositoryRule, gomodDependencies map[string][]string) error {
	var processedDeps []string
	branch := rule.Branches[0]
	for _, dep := range gomodDependencies[rule.DestinationRepository] {
		found := false
		if len(branch.Dependencies) > 0 {
			for _, dep2 := range branch.Dependencies {
				processedDeps = append(processedDeps, dep2.Repository)
				if dep2.Branch != "master" {
					return fmt.Errorf("looking for master branch of %s and found : %s for destination", dep2.Repository, rule.DestinationRepository)
				}
				if dep2.Repository == dep {
					found = true
				}
			}
		} else {
			return fmt.Errorf("Please add %s as dependencies under destination %s", gomodDependencies[rule.DestinationRepository], rule.DestinationRepository)
		}
		if !found {
			return fmt.Errorf("Please add %s as a dependency under destination %s", dep, rule.DestinationRepository)
		} else {
			fmt.Printf("dependency %s found\n", dep)
		}
	}
	// check if all deps are processed.
	extraDeps := diffSlice(processedDeps, gomodDependencies[rule.DestinationRepository])
	if len(extraDeps) > 0 {
		return fmt.Errorf("extra dependencies in rules for %s: %s", rule.DestinationRepository, strings.Join(extraDeps, ","))
	}
	return nil
}

func verifyPublishingBotRules() error {
	rules, err := config.LoadRules(rulesFile)
	if err != nil {
		return fmt.Errorf("error loading rules: %v", err)
	}

	gomodDependencies, err := getGoModDependencies(componentsDirectory)

	var processedRepos []string
	for _, rule := range rules.Rules {
		branch := rule.Branches[0]

		// if this no longer exists in master
		if _, ok := gomodDependencies[rule.DestinationRepository]; !ok {
			// make sure we dont include a rule to publish it from master
			for _, branch := range rule.Branches {
				if branch.Name == "master" {
					err := fmt.Errorf("cannot find master branch for destination `%s`", rule.DestinationRepository)
					panic(err)
				}
			}
			// and skip the validation of publishing rules for it
			continue
		}

		if err := checkValidSourceDirectory(rule); err != nil {
			return fmt.Errorf("error validating source directory: %v", err)
		}

		if err := checkMasterBranch(rule); err != nil {
			return fmt.Errorf("error validating master branch: %v", err)
		}

		// we specify the go version for all master branches through `default-go-version`
		// so ensure we don't specify explicit go version for master branch in rules
		if branch.GoVersion != "" {
			err := fmt.Errorf("go version must not be specified for master branch for destination `%s`", rule.DestinationRepository)
			panic(err)
		}

		fmt.Printf("processing : %s", rule.DestinationRepository)
		if _, ok := gomodDependencies[rule.DestinationRepository]; !ok {
			err := fmt.Errorf("missing go.mod for `%s`", rule.DestinationRepository)
			panic(err)
		}
		processedRepos = append(processedRepos, rule.DestinationRepository)

		if err := checkDependencies(rule, gomodDependencies); err != nil {
			return fmt.Errorf("error validating dependencies: %v", err)
		}
	}

	// check if all repos are processed.
	items := diffSlice(getKeys(gomodDependencies), processedRepos)
	if len(items) > 0 {
		err := fmt.Errorf("missing rules for %s", strings.Join(items, ","))
		panic(err)
	}
	return nil
}

func main() {
	if len(os.Args) != 2 {
		panic("invalid number of arguments")
	}

	kubeRoot := os.Args[1]
	stagingDirectory := kubeRoot + "/staging/"
	rulesFile = stagingDirectory + "publishing/rules.yaml"
	componentsDirectory = stagingDirectory + "src/k8s.io/"

	if err := verifyPublishingBotRules(); err != nil {
		panic(err)
	}
}