Skip to content

Add gomodder utility to verify / modify go module imports and structure #10463

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ clean:

ci-preflight-checks:
$(MAKE) check-go-mod
$(MAKE) verify-go-mods
$(MAKE) check-dockerfiles
$(MAKE) check-language
$(MAKE) generate
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ require (
go.etcd.io/etcd/client/v2 v2.305.21
go.etcd.io/etcd/client/v3 v3.5.21
golang.org/x/crypto v0.38.0
golang.org/x/mod v0.24.0
golang.org/x/net v0.40.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.14.0
Expand Down Expand Up @@ -332,7 +333,7 @@ require (
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gopkg.in/yaml.v3 v3.0.1
k8s.io/cloud-provider v0.32.4 // indirect
k8s.io/component-helpers v0.32.4 // indirect
k8s.io/controller-manager v0.32.4 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,8 @@ 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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
Expand Down
3 changes: 3 additions & 0 deletions gomodder_config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
restrictedDirectDependencies:
allowedDirectDependencies:
requireExplicitDirectDependencies: false
194 changes: 194 additions & 0 deletions hack/cmd/gomodder/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Copyright (c) 2025 Tigera, Inc. All rights reserved.
//
// 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.

// gomodder is a utility to verify go module, like ensuring all go modules in the project have the same go version and
// modules adhere to specific import restrictions.

package main

import (
"fmt"
"gopkg.in/yaml.v3"
"os"
"path/filepath"

"golang.org/x/mod/modfile"
)

const (
goModFileName = "go.mod"
configFileName = "gomodder_config.yaml"
)

type config struct {
RequireExplicitDirectDependencies bool `yaml:"requireExplicitDirectDependencies"`
AllowedDirectDependencies []string `yaml:"allowedDirectDependencies"`
allowedDirectDependenciesMap map[string]struct{} `yaml:"-"`
RestrictedDirectDependencies []string `yaml:"restrictedDirectDependencies"`
restrictedDirectDependenciesMap map[string]struct{} `yaml:"-"`
}

func (cfg config) directDependencyAllowed(dep string) error {
if cfg.RequireExplicitDirectDependencies {
if _, ok := cfg.allowedDirectDependenciesMap[dep]; !ok {
return fmt.Errorf("'%s' is not allowed as a direct (requireExplicitDirectDependencies is set), either remove the import or add it to the 'allowedDirectDependencies' list", dep)
}
}

if _, ok := cfg.restrictedDirectDependenciesMap[dep]; ok {
return fmt.Errorf("'%s' is a restricted direct dependency, either remove the import or remove it from the 'restrictedDirectDependencies' list", dep)
}

return nil
}

func main() {
runVerifyGoModImportRestrictions()
}

func runVerifyGoModImportRestrictions() {
modFolders, err := findGoModuleFolders()
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error finding go module folders: %v\n", err)
os.Exit(1)
}

// Get the root go.mod so we can use it for the standard to compare against other go.mods (like for version matching).
rootGoMod, err := getGoMod(".")
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error parsing root go.mod: %v\n", err)
os.Exit(1)
}

cfg, err := getConfig(".")
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error getting config: %v\n", err)
os.Exit(1)
}

// Verify the root go.mod first.
if err := verifyModuleImports(cfg, rootGoMod); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Failed to verify module imports for '.': %v\n", err)
os.Exit(1)
}

for _, folder := range modFolders {
fmt.Printf("Verifying go module in '%s' ...\n", folder)
goMod, err := getGoMod(folder)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error parsing go.mod: %v\n", err)
os.Exit(1)
}

cfg, err := getConfig(folder)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error getting config: %v\n", err)
os.Exit(1)
}

if goMod.Go.Version != rootGoMod.Go.Version {
_, _ = fmt.Fprintf(os.Stderr, "Error: go.mod version in '%s' ('%s') does not match root go.mod version ('%s')\n", folder, goMod.Go.Version, rootGoMod.Go.Version)
os.Exit(1)
}

if err := verifyModuleImports(cfg, goMod); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Failed to verify module imports for: %v\n", err)
os.Exit(1)
}
}
}

// verifyModuleImports checks module dependencies against allowed and restricted lists and returns an error if any violations exist.
// It looks for files named after the allowedGoImportsFileName and restrictedGoImportsFileName constants in the given folder,
// and if found, it uses the contents of those files to restrict what imports are allowed in the given goMod.
//
// Rules follow:
// - If allowedDependencies are found, then all direct dependencies in the goMod file must be in this list
// - If restrictedDependencies are found, then all direct dependencies must not be in this list
func verifyModuleImports(cfg config, goMod *modfile.File) error {
for _, req := range goMod.Require {
// If you want all dependencies, remove this condition
if !req.Indirect {
if err := cfg.directDependencyAllowed(req.Mod.Path); err != nil {
return err
}
}
}

return nil
}

func getConfig(modFolder string) (config, error) {
var cfg config

configPath := filepath.Join(modFolder, configFileName)
data, err := os.ReadFile(configPath)
if err != nil {
if !os.IsNotExist(err) {
return cfg, fmt.Errorf("failed to read config file %s: %w\n", configPath, err)
}
} else {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("invalid gomodder config found at %s: %w \n", configPath, err)
}
}

cfg.allowedDirectDependenciesMap = make(map[string]struct{})
cfg.restrictedDirectDependenciesMap = make(map[string]struct{})

for _, dep := range cfg.AllowedDirectDependencies {
cfg.allowedDirectDependenciesMap[dep] = struct{}{}
}
for _, dep := range cfg.RestrictedDirectDependencies {
cfg.restrictedDirectDependenciesMap[dep] = struct{}{}
}

return cfg, nil
}

func getGoMod(folder string) (*modfile.File, error) {
data, err := os.ReadFile(filepath.Join(folder, "go.mod"))
if err != nil {
return nil, fmt.Errorf("error reading go.mod: %w\n", err)
}
goMod, err := modfile.Parse("go.mod", data, nil)
if err != nil {
return nil, fmt.Errorf("Error parsing go.mod: %w\n", err)
}
return goMod, nil
}

func findGoModuleFolders() ([]string, error) {
root := "."
var goModFolders []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}

if path == root {
return nil
}

if info.IsDir() {
modPath := filepath.Join(path, goModFileName)
if _, err := os.Stat(modPath); err == nil {
goModFolders = append(goModFolders, path)
}
}
return nil
})

return goModFolders, err
}
6 changes: 6 additions & 0 deletions lib.Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,12 @@ fix-changed go-fmt-changed goimports-changed:
fix-all go-fmt-all goimports-all:
$(DOCKER_RUN) $(CALICO_BUILD) $(REPO_DIR)/hack/format-all-files.sh

GOMODDER=$(REPO_DIR)/hack/cmd/gomodder/main.go

.PHONY: verify-go-mods
verify-go-mods:
$(DOCKER_RUN) $(CALICO_BUILD) go run $(GOMODDER)

.PHONY: pre-commit
pre-commit:
$(DOCKER_RUN) $(CALICO_BUILD) git-hooks/pre-commit-in-container
Expand Down
4 changes: 4 additions & 0 deletions lib/std/gomodder_config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
requireExplicitDirectDependencies: true
allowedDirectDependencies:
- github.com/sirupsen/logrus
- github.com/snowzach/rotatefilehook