diff --git a/.gomodcheck.yaml b/.gomodcheck.yaml new file mode 100644 index 0000000000..8dbe45fac0 --- /dev/null +++ b/.gomodcheck.yaml @@ -0,0 +1,14 @@ +upstreamRefs: + - k8s.io/api + - k8s.io/apiextensions-apiserver + - k8s.io/apimachinery + - k8s.io/apiserver + - k8s.io/client-go + - k8s.io/component-base + - k8s.io/klog/v2 + # k8s.io/utils -> conflicts with k/k deps + +excludedModules: + # --- test dependencies: + - github.com/onsi/ginkgo/v2 + - github.com/onsi/gomega diff --git a/Makefile b/Makefile index 438613b3eb..52aa05c2a6 100644 --- a/Makefile +++ b/Makefile @@ -92,6 +92,13 @@ GOLANGCI_LINT_PKG := github.com/golangci/golangci-lint/cmd/golangci-lint $(GOLANGCI_LINT): # Build golangci-lint from tools folder. GOBIN=$(TOOLS_BIN_DIR) $(GO_INSTALL) $(GOLANGCI_LINT_PKG) $(GOLANGCI_LINT_BIN) $(GOLANGCI_LINT_VER) +GO_MOD_CHECK_DIR := $(abspath ./hack/tools/cmd/gomodcheck) +GO_MOD_CHECK := $(abspath $(TOOLS_BIN_DIR)/gomodcheck) +GO_MOD_CHECK_IGNORE := $(abspath .gomodcheck.yaml) +.PHONY: $(GO_MOD_CHECK) +$(GO_MOD_CHECK): # Build gomodcheck + go build -C $(GO_MOD_CHECK_DIR) -o $(GO_MOD_CHECK) + ## -------------------------------------- ## Linting ## -------------------------------------- @@ -130,16 +137,15 @@ clean-bin: ## Remove all generated binaries. rm -rf hack/tools/bin .PHONY: verify-modules -verify-modules: modules ## Verify go modules are up to date +verify-modules: modules $(GO_MOD_CHECK) ## Verify go modules are up to date @if !(git diff --quiet HEAD -- go.sum go.mod $(TOOLS_DIR)/go.mod $(TOOLS_DIR)/go.sum $(ENVTEST_DIR)/go.mod $(ENVTEST_DIR)/go.sum $(SCRATCH_ENV_DIR)/go.sum); then \ git diff; \ echo "go module files are out of date, please run 'make modules'"; exit 1; \ fi + $(GO_MOD_CHECK) $(GO_MOD_CHECK_IGNORE) APIDIFF_OLD_COMMIT ?= $(shell git rev-parse origin/main) .PHONY: apidiff verify-apidiff: $(GO_APIDIFF) ## Check for API differences $(GO_APIDIFF) $(APIDIFF_OLD_COMMIT) --print-compatible - - diff --git a/go.mod b/go.mod index fdb8ffa1a8..0d2ca57f0b 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,8 @@ require ( sigs.k8s.io/yaml v1.3.0 ) +require golang.org/x/mod v0.15.0 + require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect diff --git a/go.sum b/go.sum index 11eb5c0ba3..58b291e93a 100644 --- a/go.sum +++ b/go.sum @@ -156,6 +156,8 @@ golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6R golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= diff --git a/hack/tools/.keep b/hack/tools/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/hack/tools/cmd/gomodcheck/main.go b/hack/tools/cmd/gomodcheck/main.go new file mode 100644 index 0000000000..5cbaf377e2 --- /dev/null +++ b/hack/tools/cmd/gomodcheck/main.go @@ -0,0 +1,204 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + + "go.uber.org/zap" + "golang.org/x/mod/modfile" + "sigs.k8s.io/yaml" +) + +const ( + modFile = "./go.mod" +) + +type config struct { + UpstreamRefs []string `json:"upstreamRefs"` + ExcludedModules []string `json:"excludedModules"` +} + +type upstream struct { + Ref string `json:"ref"` + Version string `json:"version"` +} + +// representation of an out of sync module +type oosMod struct { + Name string `json:"name"` + Version string `json:"version"` + Upstreams []upstream `json:"upstreams"` +} + +func main() { + l, _ := zap.NewProduction() + logger := l.Sugar() + + if len(os.Args) < 2 { + fmt.Printf("USAGE: %s [PATH_TO_CONFIG_FILE]\n", os.Args[0]) + os.Exit(1) + } + + // --- 1. parse config + b, err := os.ReadFile(os.Args[1]) + if err != nil { + fatal(err) + } + + cfg := new(config) + if err := yaml.Unmarshal(b, cfg); err != nil { + fatal(err) + } + + excludedMods := make(map[string]any) + for _, mod := range cfg.ExcludedModules { + excludedMods[mod] = nil + } + + // --- 2. project mods + projectModules, err := modulesFromGoModFile() + if err != nil { + fatal(err) + } + + // --- 3. upstream mods + upstreamModules, err := modulesFromUpstreamModGraph(cfg.UpstreamRefs) + if err != nil { + fatal(err) + } + + oosMods := make([]oosMod, 0) + + // --- 4. validate + // for each module in our project, + // if it matches an upstream module, + // then for each upstream module, + // if project module version doesn't match upstream version, + // then we add the version and the ref to the list of out of sync modules. + for mod, version := range projectModules { + if _, ok := excludedMods[mod]; ok { + logger.Infof("skipped: %s", mod) + continue + } + + if versionToRef, ok := upstreamModules[mod]; ok { + outOfSyncUpstream := make([]upstream, 0) + + for upstreamVersion, upstreamRef := range versionToRef { + if version == upstreamVersion { // pass if version in sync. + continue + } + + outOfSyncUpstream = append(outOfSyncUpstream, upstream{ + Ref: upstreamRef, + Version: upstreamVersion, + }) + } + + if len(outOfSyncUpstream) == 0 { // pass if no out of sync upstreams. + continue + } + + oosMods = append(oosMods, oosMod{ + Name: mod, + Version: version, + Upstreams: outOfSyncUpstream, + }) + } + } + + if len(oosMods) == 0 { + fmt.Println("🎉 Success!") + os.Exit(0) + } + + b, err = json.MarshalIndent(map[string]any{"outOfSyncModules": oosMods}, "", " ") + if err != nil { + fatal(err) + } + + fmt.Println(string(b)) + os.Exit(1) +} + +func modulesFromGoModFile() (map[string]string, error) { + b, err := os.ReadFile(modFile) + if err != nil { + return nil, err + } + + f, err := modfile.Parse(modFile, b, nil) + if err != nil { + return nil, err + } + + out := make(map[string]string) + for _, mod := range f.Require { + out[mod.Mod.Path] = mod.Mod.Version + } + + return out, nil +} + +func modulesFromUpstreamModGraph(upstreamRefList []string) (map[string]map[string]string, error) { + b, err := exec.Command("go", "mod", "graph").Output() + if err != nil { + return nil, err + } + + graph := string(b) + + // upstreamRefs is a set of user specified upstream modules. + // The set has 2 functions: + // 1. Check if `go mod graph` modules are one of the user specified upstream modules. + // 2. Mark if a user specified upstream module was found in the module graph. + // If a user specified upstream module is not found, gomodcheck will exit with an error. + upstreamRefs := make(map[string]bool) + for _, ref := range upstreamRefList { + upstreamRefs[ref] = false + } + + modToVersionToUpstreamRef := make(map[string]map[string]string) + for _, line := range strings.Split(graph, "\n") { + ref := strings.SplitN(line, "@", 2)[0] + + if _, ok := upstreamRefs[ref]; !ok { + continue + } + + upstreamRefs[ref] = true // mark the ref as found + + kv := strings.SplitN(strings.SplitN(line, " ", 2)[1], "@", 2) + name := kv[0] + version := kv[1] + + if _, ok := modToVersionToUpstreamRef[name]; !ok { + modToVersionToUpstreamRef[name] = make(map[string]string) + } + + modToVersionToUpstreamRef[name][version] = ref + } + + notFoundErr := "" + for ref, found := range upstreamRefs { + if !found { + notFoundErr = fmt.Sprintf("%s%s, ", notFoundErr, ref) + } + } + + if notFoundErr != "" { + return nil, fmt.Errorf("cannot verify modules: "+ + "the following specified upstream module(s) cannot be found in go.mod: [ %s ]", + strings.TrimSuffix(notFoundErr, ", ")) + } + + return modToVersionToUpstreamRef, nil +} + +func fatal(err error) { + fmt.Printf("❌ %s\n", err.Error()) + os.Exit(1) +}