diff --git a/GNUmakefile b/GNUmakefile index 592183923e2..336efd326a7 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -144,6 +144,12 @@ presubmit: ## Format go files and imports. gomods: ## Install gomods go install github.com/jmank88/gomods@v0.1.4 +.PHONY: gomodslocalupdate +gomodslocalupdate: gomods ## Run gomod-local-update + go install ./tools/gomod-local-update/cmd/gomod-local-update + gomods -w gomod-local-update + gomods tidy + .PHONY: mockery mockery: $(mockery) ## Install mockery. go install github.com/vektra/mockery/v2@v2.46.3 diff --git a/core/scripts/go.mod b/core/scripts/go.mod index 9ba31d8dde1..5c62541b31a 100644 --- a/core/scripts/go.mod +++ b/core/scripts/go.mod @@ -9,6 +9,13 @@ replace github.com/smartcontractkit/chainlink/v2 => ../../ replace github.com/smartcontractkit/chainlink/deployment => ../../deployment +// Using a separate `require` here to avoid surrounding line changes +// creating potential merge conflicts. +require ( + github.com/smartcontractkit/chainlink/deployment v0.0.0-20241206210521-125d98cdaf66 + github.com/smartcontractkit/chainlink/v2 v2.0.0-20241206210521-125d98cdaf66 +) + require ( github.com/docker/docker v27.3.1+incompatible github.com/docker/go-connections v0.5.0 @@ -27,8 +34,6 @@ require ( github.com/shopspring/decimal v1.4.0 github.com/smartcontractkit/chainlink-automation v0.8.1 github.com/smartcontractkit/chainlink-common v0.3.1-0.20241210192653-a9c706f99e83 - github.com/smartcontractkit/chainlink/deployment v0.0.0-00010101000000-000000000000 - github.com/smartcontractkit/chainlink/v2 v2.14.0-mercury-20240807.0.20241106193309-5560cd76211a github.com/smartcontractkit/libocr v0.0.0-20241007185508-adbe57025f12 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 diff --git a/deployment/go.mod b/deployment/go.mod index 66cae399c79..b9303425daa 100644 --- a/deployment/go.mod +++ b/deployment/go.mod @@ -7,6 +7,10 @@ toolchain go1.23.4 // Make sure we're working with the latest chainlink libs replace github.com/smartcontractkit/chainlink/v2 => ../ +// Using a separate inline `require` here to avoid surrounding line changes +// creating potential merge conflicts. +require github.com/smartcontractkit/chainlink/v2 v2.0.0-20241206210521-125d98cdaf66 + require ( github.com/Khan/genqlient v0.7.0 github.com/Masterminds/semver/v3 v3.3.0 @@ -29,7 +33,6 @@ require ( github.com/smartcontractkit/chainlink-common v0.3.1-0.20241210192653-a9c706f99e83 github.com/smartcontractkit/chainlink-protos/job-distributor v0.6.0 github.com/smartcontractkit/chainlink-testing-framework/lib v1.50.13 - github.com/smartcontractkit/chainlink/v2 v2.0.0-00010101000000-000000000000 github.com/smartcontractkit/libocr v0.0.0-20241007185508-adbe57025f12 github.com/stretchr/testify v1.9.0 github.com/test-go/testify v1.1.4 diff --git a/integration-tests/go.mod b/integration-tests/go.mod index e109a56b393..33835b781f3 100644 --- a/integration-tests/go.mod +++ b/integration-tests/go.mod @@ -9,6 +9,13 @@ replace github.com/smartcontractkit/chainlink/v2 => ../ replace github.com/smartcontractkit/chainlink/deployment => ../deployment +// Using a separate `require` here to avoid surrounding line changes +// creating potential merge conflicts. +require ( + github.com/smartcontractkit/chainlink/deployment v0.0.0-20241206210521-125d98cdaf66 + github.com/smartcontractkit/chainlink/v2 v2.0.0-20241206210521-125d98cdaf66 +) + require ( dario.cat/mergo v1.0.1 github.com/AlekSi/pointer v1.1.0 @@ -47,8 +54,6 @@ require ( github.com/smartcontractkit/chainlink-testing-framework/lib/grafana v1.50.0 github.com/smartcontractkit/chainlink-testing-framework/seth v1.50.9 github.com/smartcontractkit/chainlink-testing-framework/wasp v1.50.2 - github.com/smartcontractkit/chainlink/deployment v0.0.0-00010101000000-000000000000 - github.com/smartcontractkit/chainlink/v2 v2.14.0-mercury-20240807.0.20241120195829-bd7a1943ad07 github.com/smartcontractkit/libocr v0.0.0-20241007185508-adbe57025f12 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 diff --git a/integration-tests/load/go.mod b/integration-tests/load/go.mod index 7c976e495bf..c83e66f8ee3 100644 --- a/integration-tests/load/go.mod +++ b/integration-tests/load/go.mod @@ -11,6 +11,14 @@ replace github.com/smartcontractkit/chainlink/deployment => ../../deployment replace github.com/smartcontractkit/chainlink/integration-tests => ../ +// Using a separate `require` here to avoid surrounding line changes +// creating potential merge conflicts. +require ( + github.com/smartcontractkit/chainlink/deployment v0.0.0-20241206210521-125d98cdaf66 + github.com/smartcontractkit/chainlink/integration-tests v0.0.0-20241206210521-125d98cdaf66 + github.com/smartcontractkit/chainlink/v2 v2.0.0-20241206210521-125d98cdaf66 +) + require ( github.com/K-Phoen/grabana v0.22.2 github.com/ethereum/go-ethereum v1.14.11 @@ -23,9 +31,6 @@ require ( github.com/smartcontractkit/chainlink-testing-framework/lib v1.50.18 github.com/smartcontractkit/chainlink-testing-framework/seth v1.50.9 github.com/smartcontractkit/chainlink-testing-framework/wasp v1.50.2 - github.com/smartcontractkit/chainlink/deployment v0.0.0-20241120141814-47da13e86197 - github.com/smartcontractkit/chainlink/integration-tests v0.0.0-20241030133659-9ec788e78b4f - github.com/smartcontractkit/chainlink/v2 v2.14.0-mercury-20240807.0.20241120195829-bd7a1943ad07 github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20241009055228-33d0c0bf38de github.com/stretchr/testify v1.9.0 github.com/wiremock/go-wiremock v1.9.0 diff --git a/tools/gomod-local-update/README.md b/tools/gomod-local-update/README.md new file mode 100644 index 00000000000..a49a663ecaa --- /dev/null +++ b/tools/gomod-local-update/README.md @@ -0,0 +1,41 @@ +# gomod-local-update + +Updates any module that is `replace`'d with a local path to have its required module version in `go.mod` to match the latest git SHA from a remote branch. + +Is meant to run within each directory where a `go.mod` file is present. + +## Configuration + +Command Line Flags: + +```shell +Optional: + -org-name Organization name (default: smartcontractkit) + -repo-name Repository name (default: chainlink) + -repo-remote Git remote to use (default: origin) + -branch-trunk Branch to get SHA from (default: develop) + -dry-run Preview changes without applying them (default: false) +``` + +## Installation + +The installed binary will be placed in your `$GOPATH/bin` directory. Make sure this directory is in your system's PATH to run the command from anywhere. From the root of this repository, run: + +```shell +go install ./tools/gomod-local-update/cmd/gomod-local-update +``` + +## Usage Examples + +Run from the root of a go module directory. + +```shell +gomod-local-update +``` + +Was designed to be used with [gomods](https://github.com/jmank88/gomods) like: + +```shell +gomods -w gomod-local-update +gomods tidy +``` diff --git a/tools/gomod-local-update/cmd/gomod-local-update/main.go b/tools/gomod-local-update/cmd/gomod-local-update/main.go new file mode 100644 index 00000000000..c471e8db776 --- /dev/null +++ b/tools/gomod-local-update/cmd/gomod-local-update/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/smartcontractkit/chainlink/v2/tools/gomod-local-update/internal/updater" +) + +const ( + goBinaryName = "gomod-local-update" +) + +var version = "dev" +var usage = fmt.Sprintf(`%s version %%s + +Usage: + cd /path/to/go/module + %s [flags] +`, goBinaryName, goBinaryName) + +func main() { + cfg, err := updater.ParseFlags(os.Args[1:], version) + if err != nil { + fmt.Fprintf(os.Stderr, usage, version) + log.Fatal(err) + } + + if cfg.ShowVersion { + fmt.Printf("%s version %s\n", goBinaryName, version) + os.Exit(0) + } + + if err := cfg.Validate(); err != nil { + fmt.Fprintf(os.Stderr, usage, version) + log.Fatal(err) + } + + u := updater.New( + cfg, + updater.NewSystemOperator(), + ) + + if err := u.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/tools/gomod-local-update/internal/updater/config.go b/tools/gomod-local-update/internal/updater/config.go new file mode 100644 index 00000000000..444193b8086 --- /dev/null +++ b/tools/gomod-local-update/internal/updater/config.go @@ -0,0 +1,69 @@ +package updater + +import ( + "flag" + "fmt" +) + +const ( + DefaultRepoRemote = "origin" + DefaultBranchTrunk = "develop" + DefaultOrgName = "smartcontractkit" + DefaultRepoName = "chainlink" +) + +type Config struct { + RepoRemote string + BranchTrunk string + DryRun bool + ShowVersion bool + OrgName string + RepoName string +} + +func ParseFlags(args []string, version string) (*Config, error) { + flags := flag.NewFlagSet("default", flag.ContinueOnError) + + cfg := &Config{} + + flags.StringVar(&cfg.RepoRemote, "repo-remote", DefaultRepoRemote, "Git remote to use") + flags.StringVar(&cfg.BranchTrunk, "branch-trunk", DefaultBranchTrunk, "Branch to get SHA from") + flags.BoolVar(&cfg.DryRun, "dry-run", false, "Preview changes without applying them") + flags.BoolVar(&cfg.ShowVersion, "version", false, "Show version information") + flags.StringVar(&cfg.OrgName, "org-name", DefaultOrgName, "GitHub organization name") + flags.StringVar(&cfg.RepoName, "repo-name", DefaultRepoName, "GitHub repository name") + + if err := flags.Parse(args); err != nil { + return nil, err + } + + return cfg, nil +} + +func (c *Config) Validate() error { + if c.ShowVersion { + return nil + } + + if c.OrgName == "" { + return fmt.Errorf("%w: org name must be provided", ErrInvalidConfig) + } + if c.RepoName == "" { + return fmt.Errorf("%w: repo name must be provided", ErrInvalidConfig) + } + if c.RepoRemote == "" { + return fmt.Errorf("%w: repo remote must be provided", ErrInvalidConfig) + } + if c.BranchTrunk == "" { + return fmt.Errorf("%w: trunk branch must be provided", ErrInvalidConfig) + } + + if !gitRemoteRE.MatchString(c.RepoRemote) { + return fmt.Errorf("%w: git remote '%s' contains invalid characters", ErrInvalidConfig, c.RepoRemote) + } + if !gitBranchRE.MatchString(c.BranchTrunk) { + return fmt.Errorf("%w: git branch '%s' contains invalid characters", ErrInvalidConfig, c.BranchTrunk) + } + + return nil +} diff --git a/tools/gomod-local-update/internal/updater/config_test.go b/tools/gomod-local-update/internal/updater/config_test.go new file mode 100644 index 00000000000..21b867aea8d --- /dev/null +++ b/tools/gomod-local-update/internal/updater/config_test.go @@ -0,0 +1,177 @@ +package updater + +import ( + "errors" + "testing" +) + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + config *Config + wantErr bool + }{ + { + name: "valid config", + config: &Config{ + RepoRemote: DefaultRepoRemote, + BranchTrunk: DefaultBranchTrunk, + OrgName: DefaultOrgName, + RepoName: DefaultRepoName, + }, + wantErr: false, + }, + { + name: "version flag bypasses validation", + config: &Config{ + ShowVersion: true, + }, + wantErr: false, + }, + { + name: "missing repo remote", + config: &Config{ + BranchTrunk: DefaultBranchTrunk, + OrgName: DefaultOrgName, + RepoName: DefaultRepoName, + }, + wantErr: true, + }, + { + name: "missing branch trunk", + config: &Config{ + RepoRemote: DefaultRepoRemote, + OrgName: DefaultOrgName, + RepoName: DefaultRepoName, + }, + wantErr: true, + }, + { + name: "missing org name", + config: &Config{ + RepoRemote: DefaultRepoRemote, + BranchTrunk: DefaultBranchTrunk, + RepoName: DefaultRepoName, + }, + wantErr: true, + }, + { + name: "missing repo name", + config: &Config{ + RepoRemote: DefaultRepoRemote, + BranchTrunk: DefaultBranchTrunk, + OrgName: DefaultOrgName, + }, + wantErr: true, + }, + { + name: "invalid remote characters", + config: &Config{ + OrgName: "test", + RepoName: "test", + RepoRemote: "origin!@#", + BranchTrunk: "main", + }, + wantErr: true, + }, + { + name: "invalid branch characters", + config: &Config{ + OrgName: "test", + RepoName: "test", + RepoRemote: "origin", + BranchTrunk: "main!@#", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestConfig_ValidateErrorType(t *testing.T) { + cfg := &Config{ + RepoRemote: "invalid*remote", + BranchTrunk: "develop", + OrgName: "test", + RepoName: "test", + } + + err := cfg.Validate() + if err == nil { + t.Error("expected error due to invalid repo remote, got nil") + return + } + + if !errors.Is(err, ErrInvalidConfig) { + t.Errorf("expected error to be ErrInvalidConfig, got: %v", err) + } +} + +func TestParseFlags(t *testing.T) { + tests := []struct { + name string + args []string + wantCfg *Config + wantErr bool + }{ + { + name: "default flags", + args: []string{}, + wantCfg: &Config{ + RepoRemote: "origin", + BranchTrunk: "develop", + }, + wantErr: false, + }, + { + name: "show version", + args: []string{"-version"}, + wantCfg: &Config{ + ShowVersion: true, + RepoRemote: "origin", + BranchTrunk: "develop", + }, + wantErr: false, + }, + { + name: "custom remote and branch", + args: []string{"-repo-remote", "upstream", "-branch-trunk", "main"}, + wantCfg: &Config{ + RepoRemote: "upstream", + BranchTrunk: "main", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseFlags(tt.args, "test-version") + if (err != nil) != tt.wantErr { + t.Errorf("ParseFlags() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil { + return + } + + if got.RepoRemote != tt.wantCfg.RepoRemote { + t.Errorf("ParseFlags() RepoRemote = %v, want %v", got.RepoRemote, tt.wantCfg.RepoRemote) + } + if got.BranchTrunk != tt.wantCfg.BranchTrunk { + t.Errorf("ParseFlags() BranchTrunk = %v, want %v", got.BranchTrunk, tt.wantCfg.BranchTrunk) + } + if got.ShowVersion != tt.wantCfg.ShowVersion { + t.Errorf("ParseFlags() ShowVersion = %v, want %v", got.ShowVersion, tt.wantCfg.ShowVersion) + } + }) + } +} diff --git a/tools/gomod-local-update/internal/updater/errors.go b/tools/gomod-local-update/internal/updater/errors.go new file mode 100644 index 00000000000..288eb49f66d --- /dev/null +++ b/tools/gomod-local-update/internal/updater/errors.go @@ -0,0 +1,11 @@ +package updater + +import "errors" + +var ( + // ErrModOperation indicates a failure in a module-related operation. + ErrModOperation = errors.New("module operation failed") + + // ErrInvalidConfig indicates invalid configuration parameters. + ErrInvalidConfig = errors.New("invalid configuration") +) diff --git a/tools/gomod-local-update/internal/updater/system_operator.go b/tools/gomod-local-update/internal/updater/system_operator.go new file mode 100644 index 00000000000..59ccfb15ed7 --- /dev/null +++ b/tools/gomod-local-update/internal/updater/system_operator.go @@ -0,0 +1,34 @@ +package updater + +import ( + "os" + "path/filepath" +) + +// SystemOperator provides an interface for file system operations. +type SystemOperator interface { + // ReadFile reads the entire contents of a file + ReadFile(path string) ([]byte, error) + // WriteFile writes data to a file with specific permissions + WriteFile(path string, data []byte, perm os.FileMode) error +} + +type systemOperator struct{} + +func NewSystemOperator() SystemOperator { + return &systemOperator{} +} + +func (s *systemOperator) ReadFile(path string) ([]byte, error) { + path = filepath.Clean(path) + return os.ReadFile(path) +} + +func (s *systemOperator) WriteFile(path string, data []byte, perm os.FileMode) error { + path = filepath.Clean(path) + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return os.WriteFile(path, data, perm) +} diff --git a/tools/gomod-local-update/internal/updater/updater.go b/tools/gomod-local-update/internal/updater/updater.go new file mode 100644 index 00000000000..cfb873ed3ca --- /dev/null +++ b/tools/gomod-local-update/internal/updater/updater.go @@ -0,0 +1,253 @@ +package updater + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "os/exec" + "regexp" + "strings" + "time" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" +) + +// gitExecutor allows mocking git commands in tests +type gitExecutor interface { + Command(ctx context.Context, args ...string) ([]byte, error) +} + +// systemGitExecutor executes git commands on the host system +type systemGitExecutor struct{} + +func (g *systemGitExecutor) Command(ctx context.Context, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, "git", args...).Output() +} + +const ( + // File and mode constants + goModFile = "go.mod" + goModFileMode = 0644 + gitSHALength = 12 + gitTimeout = 30 * time.Second + gitTimeFormat = time.RFC3339 + + // Regex pattern constants + gitRemotePattern = `^[a-zA-Z0-9][-a-zA-Z0-9_.]*$` + gitBranchPattern = `^[a-zA-Z0-9][-a-zA-Z0-9/_]*$` + majorVersionPattern = `/v\d+$` + shaPattern = `^[a-fA-F0-9]{40}$` // SHA-1 hashes are 40 hexadecimal characters +) + +var ( + // Pre-compiled regular expressions + gitRemoteRE = regexp.MustCompile(gitRemotePattern) + gitBranchRE = regexp.MustCompile(gitBranchPattern) + gitShaRE = regexp.MustCompile(shaPattern) + majorVersionRE = regexp.MustCompile(majorVersionPattern) +) + +type Updater struct { + config *Config + system SystemOperator + git gitExecutor +} + +// New creates a new Updater +func New(config *Config, system SystemOperator) *Updater { + return &Updater{ + config: config, + system: system, + git: &systemGitExecutor{}, + } +} + +// validateSHA checks if the SHA consists of exactly 40 hexadecimal digits +func (u *Updater) validateSHA(sha string) error { + if !gitShaRE.MatchString(sha) { + return fmt.Errorf("%w: invalid git SHA '%s'", ErrInvalidConfig, sha) + } + return nil +} + +// getGitInfo retrieves the latest commit SHA and timestamp from a Git repository +func (u *Updater) getGitInfo(remote, branch string) (string, time.Time, error) { + ctx, cancel := context.WithTimeout(context.Background(), gitTimeout) + defer cancel() + + // Use u.git.Command for ls-remote + out, err := u.git.Command(ctx, "ls-remote", remote, "refs/heads/"+branch) + if err != nil { + return "", time.Time{}, fmt.Errorf("%w: failed to fetch commit SHA from %s/%s: %w", + ErrModOperation, remote, branch, err) + } + if len(out) == 0 { + return "", time.Time{}, fmt.Errorf("%w: no output from git ls-remote", ErrModOperation) + } + sha := strings.Split(string(out), "\t")[0] + if len(sha) == 0 { + return "", time.Time{}, fmt.Errorf("%w: empty SHA from git ls-remote", ErrModOperation) + } + + // Validate the SHA + if err := u.validateSHA(sha); err != nil { + return "", time.Time{}, err + } + + // Use u.git.Command for show + showOut, showErr := u.git.Command(ctx, "show", "-s", "--format=%cI", sha) + if showErr != nil { + return "", time.Time{}, fmt.Errorf("failed to get commit time: %w", showErr) + } + + commitTime, parseErr := time.Parse(gitTimeFormat, strings.TrimSpace(string(showOut))) + if parseErr != nil { + return "", time.Time{}, fmt.Errorf("failed to parse commit time: %w", parseErr) + } + + return sha[:gitSHALength], commitTime, nil +} + +// Run starts the module update process +func (u *Updater) Run() error { + logger := log.New(os.Stdout, "", log.LstdFlags) + + logger.Printf("info: auto-detecting modules with local replace directives") + + f, err := u.readModFile() + if err != nil { + return fmt.Errorf("%w: failed to read and parse go.mod file", ErrModOperation) + } + + // Auto-detect modules to update + modulesToUpdate, err := u.findLocalReplaceModules() + if err != nil { + return fmt.Errorf("%w: failed to detect local replace modules", ErrModOperation) + } + if len(modulesToUpdate) == 0 { + logger.Printf("info: no modules found to update in %s", f.Module.Mod.Path) + return nil + } + logger.Printf("info: found %d modules with local replace directives: %v", + len(modulesToUpdate), modulesToUpdate) + + // Get commit info once for all modules + sha, commitTime, err := u.getGitInfo(u.config.RepoRemote, u.config.BranchTrunk) + if err != nil { + if errors.Is(err, ErrInvalidConfig) { + return err + } + return fmt.Errorf("%w: failed to get git commit info from remote", ErrModOperation) + } + + // Update the modules in the same file handle + if err := u.updateGoMod(f, modulesToUpdate, sha, commitTime); err != nil { + return fmt.Errorf("%w: failed to update module versions in go.mod", ErrModOperation) + } + + return u.writeModFile(f) +} + +// updateGoMod updates the go.mod file with new pseudo-versions +func (u *Updater) updateGoMod(modFile *modfile.File, modulesToUpdate []string, sha string, commitTime time.Time) error { + for _, modulePath := range modulesToUpdate { + majorVersion := getMajorVersion(modulePath) + pseudoVersion := module.PseudoVersion(majorVersion, "", commitTime, sha[:gitSHALength]) + + // Find and update version + for _, req := range modFile.Require { + if req.Mod.Path == modulePath { + if u.config.DryRun { + log.Printf("[DRY RUN] Would update %s: %s => %s", modulePath, req.Mod.Version, pseudoVersion) + continue + } + + if err := modFile.AddRequire(modulePath, pseudoVersion); err != nil { + return fmt.Errorf("%w: failed to update version for module %s", + ErrModOperation, modulePath) + } + break + } + } + } + + return nil +} + +// getMajorVersion extracts the major version number from a module path +// Returns "v2" for /v2, "v0" for no version suffix +func getMajorVersion(modulePath string) string { + if match := majorVersionRE.FindString(modulePath); match != "" { + return strings.TrimPrefix(match, "/") + } + return "v0" +} + +// findLocalReplaceModules finds modules with local replace directives +func (u *Updater) findLocalReplaceModules() ([]string, error) { + modFile, err := u.readModFile() + if err != nil { + return nil, err + } + + orgPrefix := fmt.Sprintf("github.com/%s/%s", u.config.OrgName, u.config.RepoName) + localModules := make(map[string]bool) + var modules []string + + // First find all local replaces for our org + for _, rep := range modFile.Replace { + if strings.HasPrefix(rep.Old.Path, orgPrefix) && + rep.New.Version == "" && + isLocalPath(rep.New.Path) { + localModules[rep.Old.Path] = true + } + } + + // Then check requires that match our replaces + for _, req := range modFile.Require { + if localModules[req.Mod.Path] { + modules = append(modules, req.Mod.Path) + } + } + + return modules, nil +} + +// isLocalPath checks if the path is a local path +func isLocalPath(path string) bool { + return path == "." || path == ".." || + strings.HasPrefix(path, "./") || + strings.HasPrefix(path, "../") +} + +// readModFile reads the go.mod file +func (u *Updater) readModFile() (*modfile.File, error) { + content, err := u.system.ReadFile(goModFile) + if err != nil { + return nil, fmt.Errorf("unable to read go.mod: %w", err) + } + + modFile, err := modfile.Parse(goModFile, content, nil) + if err != nil { + return nil, fmt.Errorf("%w: invalid go.mod format: %w", ErrModOperation, err) + } + + return modFile, nil +} + +// writeModFile writes the go.mod file +func (u *Updater) writeModFile(modFile *modfile.File) error { + content, err := modFile.Format() + if err != nil { + return fmt.Errorf("%w: failed to format go.mod content", ErrModOperation) + } + + if err := u.system.WriteFile(goModFile, content, goModFileMode); err != nil { + return fmt.Errorf("%w: failed to write updated go.mod file", ErrModOperation) + } + + return nil +} diff --git a/tools/gomod-local-update/internal/updater/updater_test.go b/tools/gomod-local-update/internal/updater/updater_test.go new file mode 100644 index 00000000000..ba17024dd37 --- /dev/null +++ b/tools/gomod-local-update/internal/updater/updater_test.go @@ -0,0 +1,320 @@ +package updater + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" +) + +// mockGitExecutor simulates git commands for testing as an alternative to systemGitExecutor +type mockGitExecutor struct { + sha string + time time.Time +} + +func (m *mockGitExecutor) Command(ctx context.Context, args ...string) ([]byte, error) { + switch args[0] { + case "ls-remote": + // Validate inputs + if len(args) != 3 { + return nil, fmt.Errorf("expected 3 args for ls-remote, got %d", len(args)) + } + remote := args[1] + if remote == "invalid*remote" { + return nil, fmt.Errorf("%w: git remote '%s' contains invalid characters", ErrInvalidConfig, remote) + } + // Return a full 40-character SHA + fullSHA := fmt.Sprintf("%s%s", m.sha, strings.Repeat("0", 40-len(m.sha))) + return []byte(fullSHA + "\trefs/heads/" + args[2] + "\n"), nil + case "show": + if len(args) != 4 { + return nil, fmt.Errorf("unexpected show args: %v", args) + } + // Use the full SHA for validation + fullSHA := fmt.Sprintf("%s%s", m.sha, strings.Repeat("0", 40-len(m.sha))) + if args[3] != fullSHA { + return nil, fmt.Errorf("unexpected SHA: got %s, want %s", args[3], fullSHA) + } + return []byte(m.time.Format(gitTimeFormat) + "\n"), nil + default: + return nil, fmt.Errorf("unexpected git command: %v", args) + } +} + +type mockSystemOperator struct { + files map[string][]byte + err error +} + +func newMockSystemOperator() *mockSystemOperator { + return &mockSystemOperator{ + files: make(map[string][]byte), + } +} + +func (m *mockSystemOperator) ReadFile(path string) ([]byte, error) { + if m.err != nil { + return nil, m.err + } + content, ok := m.files[path] + if !ok { + return nil, fmt.Errorf("file not found: %s", path) + } + return content, nil +} + +func (m *mockSystemOperator) WriteFile(path string, data []byte, perm os.FileMode) error { + if m.err != nil { + return m.err + } + m.files[path] = data + return nil +} + +func TestUpdater_Run(t *testing.T) { + testTime := time.Date(2024, 11, 22, 18, 21, 10, 0, time.UTC) + // Use a full 40-character SHA + testSHA := "ac7a7395feed" + strings.Repeat("0", 28) + + tests := []struct { + name string + config *Config + sysOp *mockSystemOperator + wantErr bool + wantFile string + }{ + { + name: "successful update", + config: &Config{ + RepoRemote: "origin", + BranchTrunk: "develop", + OrgName: "smartcontractkit", + RepoName: "chainlink", + }, + sysOp: func() *mockSystemOperator { + m := newMockSystemOperator() + m.files["go.mod"] = []byte(`module test +require github.com/smartcontractkit/chainlink/v2 v2.0.0 +replace github.com/smartcontractkit/chainlink/v2 => ../ +`) + return m + }(), + wantErr: false, + wantFile: fmt.Sprintf(`module test + +require github.com/smartcontractkit/chainlink/v2 v2.0.0-20241122182110-%s + +replace github.com/smartcontractkit/chainlink/v2 => ../ +`, testSHA[:12]), + }, + { + name: "handles module with local replace", + config: &Config{ + RepoRemote: "origin", + BranchTrunk: "develop", + OrgName: "smartcontractkit", + RepoName: "chainlink", + }, + sysOp: func() *mockSystemOperator { + m := newMockSystemOperator() + m.files["go.mod"] = []byte(`module test +require github.com/smartcontractkit/chainlink/v2 v2.0.0 +replace github.com/smartcontractkit/chainlink/v2 => ../ +`) + return m + }(), + wantErr: false, + }, + { + name: "v1 module update", + config: &Config{ + RepoRemote: "origin", + BranchTrunk: "develop", + OrgName: "example", + RepoName: "mod", + }, + sysOp: func() *mockSystemOperator { + m := newMockSystemOperator() + m.files["go.mod"] = []byte(`module test +require github.com/example/mod v1.0.0 +`) + return m + }(), + wantErr: false, + }, + { + name: "updates v2 module with timestamp", + config: &Config{ + RepoRemote: "origin", + BranchTrunk: "develop", + OrgName: "smartcontractkit", + RepoName: "chainlink", + }, + sysOp: func() *mockSystemOperator { + m := newMockSystemOperator() + m.files["go.mod"] = []byte(`module test +require github.com/smartcontractkit/chainlink/v2 v2.0.0-20241119120536-03115e80382d +`) + return m + }(), + wantErr: false, + wantFile: fmt.Sprintf(`module test + +require github.com/smartcontractkit/chainlink/v2 v2.0.0-20241122182110-%s + +replace github.com/smartcontractkit/chainlink/v2 => ../ +`, testSHA[:12]), + }, + { + name: "updates v0 module with timestamp", + config: &Config{ + RepoRemote: "origin", + BranchTrunk: "develop", + OrgName: "smartcontractkit", + RepoName: "chainlink", + }, + sysOp: func() *mockSystemOperator { + m := newMockSystemOperator() + m.files["go.mod"] = []byte(`module test +require github.com/smartcontractkit/chainlink/deployment v0.0.0-20241119120536-03115e80382d +`) + return m + }(), + wantErr: false, + wantFile: fmt.Sprintf(`module test + +require github.com/smartcontractkit/chainlink/deployment v0.0.0-20241122182110-%s + +replace github.com/smartcontractkit/chainlink/deployment => ../ +`, testSHA[:12]), + }, + { + name: "handles multiple modules with different versions", + config: &Config{ + RepoRemote: "origin", + BranchTrunk: "develop", + OrgName: "smartcontractkit", + RepoName: "chainlink", + }, + sysOp: func() *mockSystemOperator { + m := newMockSystemOperator() + m.files["go.mod"] = []byte(`module test +require ( + github.com/smartcontractkit/chainlink/v2 v2.0.0-20241119120536-03115e80382d + github.com/smartcontractkit/chainlink/deployment v0.0.0-20241119120536-03115e80382d +) +`) + return m + }(), + wantErr: false, + wantFile: fmt.Sprintf(`module test + +require ( + github.com/smartcontractkit/chainlink/v2 v2.0.0-20241122182110-%s + github.com/smartcontractkit/chainlink/deployment v0.0.0-20241122182110-%s +) + +replace github.com/smartcontractkit/chainlink/v2 => ../ + +replace github.com/smartcontractkit/chainlink/deployment => ../ +`, testSHA[:12], testSHA[:12]), + }, + { + name: "updates v3 module with timestamp", + config: &Config{ + RepoRemote: "origin", + BranchTrunk: "develop", + OrgName: "smartcontractkit", + RepoName: "chainlink", + }, + sysOp: func() *mockSystemOperator { + m := newMockSystemOperator() + m.files["go.mod"] = []byte(`module test +require github.com/smartcontractkit/chainlink/v3 v3.0.0-20241119120536-03115e80382d +`) + return m + }(), + wantErr: false, + wantFile: fmt.Sprintf(`module test + +require github.com/smartcontractkit/chainlink/v3 v3.0.0-20241122182110-%s + +replace github.com/smartcontractkit/chainlink/v3 => ../ +`, testSHA[:12]), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u := New(tt.config, tt.sysOp) + + // Add local replace directive for modules that should be updated + if !strings.Contains(tt.name, "v1 module update") { + modContent := string(tt.sysOp.files["go.mod"]) + for _, module := range []string{"v2", "v3", "deployment"} { + modulePath := fmt.Sprintf("github.com/%s/%s/%s", + tt.config.OrgName, tt.config.RepoName, module) + if strings.Contains(modContent, modulePath) && + !strings.Contains(modContent, "replace "+modulePath) { + modContent += fmt.Sprintf("\nreplace %s => ../\n", modulePath) + } + } + tt.sysOp.files["go.mod"] = []byte(modContent) + } + + // Override the git executor with our mock + u.git = &mockGitExecutor{ + sha: testSHA, + time: testTime, + } + err := u.Run() + if (err != nil) != tt.wantErr { + t.Errorf("Updater.Run() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.wantFile != "" { + got := string(tt.sysOp.files["go.mod"]) + if got != tt.wantFile { + t.Errorf("File content mismatch\nGot:\n%s\nWant:\n%s", got, tt.wantFile) + } + } + }) + } +} + +func TestUpdater_FindLocalReplaceModules(t *testing.T) { + sysOp := newMockSystemOperator() + sysOp.files["go.mod"] = []byte(` +module test +require ( + github.com/smartcontractkit/chainlink/v2 v2.0.0 + github.com/other/repo v1.0.0 +) +replace ( + github.com/smartcontractkit/chainlink/v2 => ../ + github.com/other/repo => ../other +)`) + + cfg := &Config{ + OrgName: "smartcontractkit", + RepoName: "chainlink", + } + + u := New(cfg, sysOp) + modules, err := u.findLocalReplaceModules() + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if len(modules) != 1 { + t.Errorf("expected 1 module, got %d", len(modules)) + return + } + if modules[0] != "github.com/smartcontractkit/chainlink/v2" { + t.Errorf("expected chainlink module, got %s", modules[0]) + } +}