diff --git a/go.mod b/go.mod index 4b03641..685748f 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 github.com/spf13/cobra v1.1.3 github.com/stretchr/testify v1.9.0 - golang.org/x/mod v0.12.0 + golang.org/x/mod v0.19.0 ) require ( diff --git a/go.sum b/go.sum index f978912..08aac65 100644 --- a/go.sum +++ b/go.sum @@ -605,8 +605,8 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/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.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/plugin.go b/plugin.go index c7e201a..d249751 100644 --- a/plugin.go +++ b/plugin.go @@ -8,20 +8,33 @@ import ( "github.com/krakendio/krakend-cobra/v2/plugin" "github.com/spf13/cobra" "golang.org/x/mod/modfile" + "golang.org/x/mod/module" ) -// indirectRequires returns the indirect dependencies of the go.sum file. -func indirectRequires(goSum string) (map[string]struct{}, error) { - dir := filepath.Dir(goSum) - filename := filepath.Join(dir, "go.mod") +const ( + // goName is the name of the diff which represents the go version. + goName = "go" + + // libcName is the name of the diff which represents the libc version. + libcName = "libc" +) + +// goMod returns the go.mod file path from the go.sum file path. +func goMod(goSum string) string { + return filepath.Join(filepath.Dir(goSum), "go.mod") +} + +// indirectRequires returns the details and indirect dependencies of the go.sum file. +func indirectRequires(goSum string) (*modfile.File, map[string]struct{}, error) { + filename := goMod(goSum) data, err := os.ReadFile(filename) if err != nil { - return nil, fmt.Errorf("reading go.mod: %w", err) + return nil, nil, fmt.Errorf("read go.mod: %w", err) } f, err := modfile.Parse(filename, data, nil) if err != nil { - return nil, fmt.Errorf("parsing go.mod: %w", err) + return nil, nil, fmt.Errorf("parse go.mod: %w", err) } indirects := map[string]struct{}{} @@ -31,7 +44,23 @@ func indirectRequires(goSum string) (map[string]struct{}, error) { } } - return indirects, nil + return f, indirects, nil +} + +// writeModFile writes the modfile.File to the go.mod file determined from goSum. +func writeModFile(goSum string, f *modfile.File) error { + f.Cleanup() + data, err := f.Format() + if err != nil { + return fmt.Errorf("format go.mod: %w", err) + } + + filename := goMod(goSum) + if err = os.WriteFile(filename, data, 0644); err != nil { + return fmt.Errorf("write go.sum: %w", err) + } + + return nil } // getBuildInfo returns the dependencies of the binary calling it. @@ -66,32 +95,99 @@ func pluginFuncErr(cmd *cobra.Command, _ []string) error { return nil } - if gogetEnabled { - indirects, err := indirectRequires(goSum) - if err != nil { + var indirects map[string]struct{} + var modFile *modfile.File + if gogetEnabled || fixEnabled { + if modFile, indirects, err = indirectRequires(goSum); err != nil { return err } - for _, diff := range diffs { - if diff.Name != "go" && diff.Name != "libc" { - if _, ok := indirects[diff.Name]; ok { - cmd.Printf("go mod edit --replace %s=%s@%s\n", diff.Name, diff.Name, diff.Expected) - } else { - cmd.Printf("go get %s@%s\n", diff.Name, diff.Expected) - } + } + + var fixed int + if !fixEnabled { + outputFixes(cmd, diffs, indirects) + } else if fixed, err = applyFixes(cmd, diffs, modFile, indirects); err != nil { + return err + } + + // Report any remaining incompatibilities. + if len(diffs) != fixed { + if fixed > 0 { + return fmt.Errorf("%d incompatibilities fixed, %d left", fixed, len(diffs)-fixed) + } + + return fmt.Errorf("%d incompatibilities found", len(diffs)) + } + + return nil +} + +// outputFixes prints the incompatibilities. +func outputFixes(cmd *cobra.Command, diffs []plugin.Diff, indirects map[string]struct{}) { + for _, diff := range diffs { + if diff.Name != goName && diff.Name != libcName && gogetEnabled { + if _, ok := indirects[diff.Name]; ok { + cmd.Printf("go mod edit --replace %s=%s@%s\n", diff.Name, diff.Name, diff.Expected) + } else { + cmd.Printf("go get %s@%s\n", diff.Name, diff.Expected) + } + continue + } + + cmd.Println(diff.Name) + cmd.Println("\thave:", diff.Have) + cmd.Println("\twant:", diff.Expected) + } +} + +// applyFixes applies the fixes and returns the number of incompatibilities fixed. +func applyFixes(cmd *cobra.Command, diffs []plugin.Diff, modFile *modfile.File, indirects map[string]struct{}) (int, error) { + var replaces []plugin.Diff + var requires []*modfile.Require + for _, diff := range diffs { + if diff.Name != goName && diff.Name != libcName { + if _, ok := indirects[diff.Name]; ok { + replaces = append(replaces, diff) continue } - cmd.Println(diff.Name) - cmd.Println("\thave:", diff.Have) - cmd.Println("\twant:", diff.Expected) + requires = append(requires, &modfile.Require{ + Mod: module.Version{ + Path: diff.Name, + Version: diff.Expected, + }, + }) + continue } - } else { - for _, diff := range diffs { - cmd.Println(diff.Name) - cmd.Println("\thave:", diff.Have) - cmd.Println("\twant:", diff.Expected) + + cmd.Println(diff.Name) + cmd.Println("\thave:", diff.Have) + cmd.Println("\twant:", diff.Expected) + } + + if len(requires) > 0 { + // We use the modfile.SetRequireSeparateIndirect to avoid adding direct + // dependencies to the direct block as per: + // https://github.com/golang/go/issues/69050 + modFile.SetRequireSeparateIndirect(append(modFile.Require, requires...)) + } + + // Add replaces after requires. + for _, diff := range replaces { + if err := modFile.AddReplace(diff.Name, "", diff.Name, diff.Expected); err != nil { + return 0, fmt.Errorf("add replace: %w", err) } } - return fmt.Errorf("%d incompatibilities found", len(diffs)) + fixed := len(replaces) + len(requires) + if fixed > 0 { + modFile.Cleanup() + if err := writeModFile(goSum, modFile); err != nil { + return 0, err + } + + cmd.Printf("%d incompatibilities fixed\n", fixed) + } + + return fixed, nil } diff --git a/plugin_test.go b/plugin_test.go index fea136d..4910a8e 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -2,13 +2,51 @@ package cmd import ( "bytes" + "errors" + "os" + "path/filepath" + "strings" "testing" "github.com/krakendio/krakend-cobra/v2/plugin" + "github.com/luraproject/lura/v2/core" "github.com/spf13/cobra" "github.com/stretchr/testify/require" ) +const testDir = "testdata" + +// copyDir is a helper function to copy directory entries from src to dst. +func copyDir(t *testing.T, srcSubDir, dstDir string) { + t.Helper() + + srcDir := filepath.Join(testDir, srcSubDir) + entries, err := os.ReadDir(srcDir) + if errors.Is(err, os.ErrNotExist) { + // Nothing to do. + return + } + require.NoError(t, err) + + for _, entry := range entries { + file := entry.Name() + data, err := os.ReadFile(filepath.Join(srcDir, file)) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(dstDir, file), data, 0644) + require.NoError(t, err) + } +} + +// loadFile is a helper function to load a file from the testdata directory. +func loadFile(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(testDir, name)) + require.NoError(t, err) + + return string(data) +} + func Test_pluginFuncErr(t *testing.T) { var buf bytes.Buffer cmd := &cobra.Command{} @@ -16,8 +54,8 @@ func Test_pluginFuncErr(t *testing.T) { localDescriber = func() plugin.Descriptor { return plugin.Descriptor{ - Go: goVersion, - Libc: libcVersion, + Go: core.GoVersion, + Libc: core.GlibcVersion, Deps: map[string]string{ "golang.org/x/mod": "v0.6.0-dev.0.20220419223038-86c51ed26bb4", "github.com/Azure/azure-sdk-for-go": "v59.3.0+incompatible", @@ -28,63 +66,105 @@ func Test_pluginFuncErr(t *testing.T) { defer func() { localDescriber = plugin.Local }() + goModData := loadFile(t, "go.mod") + tests := map[string]struct { - goSum string - expected string - fix bool - err string + dir string + expected string + expectedGoMod string + goVersion string + format bool + fix bool + err string }{ + "missing": { - goSum: "./testdata/missing-go.sum", - err: "open ./testdata/missing-go.sum: no such file or directory", + dir: "missing", + goVersion: goVersion, + expectedGoMod: goModData, + err: "open DIR/go.sum: no such file or directory", }, - "matching": { - goSum: "./testdata/match-go.sum", - expected: "No incompatibilities found!\n", + + "match": { + dir: "match", + goVersion: goVersion, + expectedGoMod: goModData, + expected: "No incompatibilities found!\n", }, "changes": { - goSum: "./testdata/changes-go.sum", - expected: `cloud.google.com/go - have: v0.100.3 - want: v0.100.2 -github.com/Azure/azure-sdk-for-go - have: v59.3.1+incompatible - want: v59.3.0+incompatible -golang.org/x/mod - have: v0.6.10-dev.0.20220419223038-86c51ed26bb4 - want: v0.6.0-dev.0.20220419223038-86c51ed26bb4 -`, - err: "3 incompatibilities found", + dir: "changes", + goVersion: goVersion, + expectedGoMod: goModData, + expected: loadFile(t, "changes/expected.txt"), + err: "3 incompatibilities found", }, - "fix": { - goSum: "./testdata/changes-go.sum", - fix: true, - expected: `go mod edit --replace cloud.google.com/go=cloud.google.com/go@v0.100.2 -go mod edit --replace github.com/Azure/azure-sdk-for-go=github.com/Azure/azure-sdk-for-go@v59.3.0+incompatible -go get golang.org/x/mod@v0.6.0-dev.0.20220419223038-86c51ed26bb4 + "format": { + dir: "changes", + goVersion: goVersion, + expectedGoMod: goModData, + format: true, + expected: loadFile(t, "format/expected.txt"), + err: "3 incompatibilities found", + }, + "fixed-all": { + dir: "changes", + goVersion: goVersion, + expectedGoMod: loadFile(t, "fixed-all/go.mod"), + fix: true, + expected: "3 incompatibilities fixed\n", + }, + "fixed-some": { + dir: "changes", + goVersion: "1.1.0", + expectedGoMod: loadFile(t, "fixed-some/go.mod"), + fix: true, + expected: `go + have: 1.1.0 + want: undefined +3 incompatibilities fixed `, - err: "3 incompatibilities found", + err: "3 incompatibilities fixed, 1 left", }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { buf.Reset() + // Make copies in a temporary directory so + // the original files are not modified. + tempDir := t.TempDir() orig := goSum - goSum = tc.goSum + goSum = filepath.Join(tempDir, "go.sum") + copyDir(t, tc.dir, tempDir) defer func() { goSum = orig }() - fix := gogetEnabled - gogetEnabled = tc.fix - defer func() { gogetEnabled = fix }() + // Override the global variables for the test. + format := gogetEnabled + fix := fixEnabled + gogetEnabled = tc.format + fixEnabled = tc.fix + oldGoVersion := goVersion + goVersion = tc.goVersion + defer func() { + gogetEnabled = format + fixEnabled = fix + goVersion = oldGoVersion + }() err := pluginFuncErr(cmd, nil) if tc.err != "" { - require.EqualError(t, err, tc.err) + require.EqualError(t, err, strings.ReplaceAll(tc.err, "DIR", tempDir)) } else { require.NoError(t, err) } require.Equal(t, tc.expected, buf.String()) + + data, err := os.ReadFile(filepath.Join(tempDir, "go.mod")) + if errors.Is(err, os.ErrNotExist) { + return + } + require.NoError(t, err) + require.Equal(t, string(tc.expectedGoMod), string(data)) }) } } diff --git a/root.go b/root.go index 914efba..0967dfd 100644 --- a/root.go +++ b/root.go @@ -31,6 +31,7 @@ var ( libcVersion = core.GlibcVersion checkDumpPrefix = "\t" gogetEnabled = false + fixEnabled = false DefaultRoot Root RootCommand Command @@ -110,13 +111,14 @@ func init() { goVersionFlag := StringFlagBuilder(&goVersion, "go", "g", goVersion, "The version of the go compiler used for your plugin") libcVersionFlag := StringFlagBuilder(&libcVersion, "libc", "l", "", "Version of the libc library used") gogetFlag := BoolFlagBuilder(&gogetEnabled, "format", "f", false, "Shows fix commands to update your dependencies") + fixFlag := BoolFlagBuilder(&fixEnabled, "fix", "x", false, "Applies fixes to update your dependencies") PluginCommand = NewCommand(pluginCmd, goSumFlag, goVersionFlag, libcVersionFlag, gogetFlag) rulesToExcludeFlag := StringFlagBuilder(&rulesToExclude, "ignore", "i", rulesToExclude, "List of rules to ignore (comma-separated, no spaces)") severitiesToIncludeFlag := StringFlagBuilder(&severitiesToInclude, "severity", "s", severitiesToInclude, "List of severities to include (comma-separated, no spaces)") pathToRulesToExcludeFlag := StringFlagBuilder(&rulesToExcludePath, "ignore-file", "I", rulesToExcludePath, "Path to a text-plain file containing the list of rules to exclude") formatFlag := StringFlagBuilder(&formatTmpl, "format", "f", formatTmpl, "Inline go template to render the results") - AuditCommand = NewCommand(auditCmd, cfgFlag, rulesToExcludeFlag, severitiesToIncludeFlag, pathToRulesToExcludeFlag, formatFlag) + AuditCommand = NewCommand(auditCmd, cfgFlag, rulesToExcludeFlag, severitiesToIncludeFlag, pathToRulesToExcludeFlag, formatFlag, fixFlag) VersionCommand = NewCommand(versionCmd) diff --git a/testdata/changes/expected.txt b/testdata/changes/expected.txt new file mode 100644 index 0000000..4ed20d7 --- /dev/null +++ b/testdata/changes/expected.txt @@ -0,0 +1,9 @@ +cloud.google.com/go + have: v0.100.3 + want: v0.100.2 +github.com/Azure/azure-sdk-for-go + have: v59.3.1+incompatible + want: v59.3.0+incompatible +golang.org/x/mod + have: v0.6.10-dev.0.20220419223038-86c51ed26bb4 + want: v0.6.0-dev.0.20220419223038-86c51ed26bb4 diff --git a/testdata/changes/go.mod b/testdata/changes/go.mod new file mode 100644 index 0000000..c429cbf --- /dev/null +++ b/testdata/changes/go.mod @@ -0,0 +1,13 @@ +module github.com/krakendio/krakend-cobra/v2 + +go 1.17 + +require ( + github.com/gin-gonic/gin v1.8.2 + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 +) + +require ( + cloud.google.com/go v0.100.2 // indirect + github.com/Azure/azure-sdk-for-go v59.3.0+incompatible // indirect +) diff --git a/testdata/changes-go.sum b/testdata/changes/go.sum similarity index 100% rename from testdata/changes-go.sum rename to testdata/changes/go.sum diff --git a/testdata/fixed-all/go.mod b/testdata/fixed-all/go.mod new file mode 100644 index 0000000..73a409d --- /dev/null +++ b/testdata/fixed-all/go.mod @@ -0,0 +1,17 @@ +module github.com/krakendio/krakend-cobra/v2 + +go 1.17 + +require ( + github.com/gin-gonic/gin v1.8.2 + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 +) + +require ( + cloud.google.com/go v0.100.2 // indirect + github.com/Azure/azure-sdk-for-go v59.3.0+incompatible // indirect +) + +replace cloud.google.com/go => cloud.google.com/go v0.100.2 + +replace github.com/Azure/azure-sdk-for-go => github.com/Azure/azure-sdk-for-go v59.3.0+incompatible diff --git a/testdata/fixed-some/go.mod b/testdata/fixed-some/go.mod new file mode 100644 index 0000000..73a409d --- /dev/null +++ b/testdata/fixed-some/go.mod @@ -0,0 +1,17 @@ +module github.com/krakendio/krakend-cobra/v2 + +go 1.17 + +require ( + github.com/gin-gonic/gin v1.8.2 + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 +) + +require ( + cloud.google.com/go v0.100.2 // indirect + github.com/Azure/azure-sdk-for-go v59.3.0+incompatible // indirect +) + +replace cloud.google.com/go => cloud.google.com/go v0.100.2 + +replace github.com/Azure/azure-sdk-for-go => github.com/Azure/azure-sdk-for-go v59.3.0+incompatible diff --git a/testdata/format/expected.txt b/testdata/format/expected.txt new file mode 100644 index 0000000..79efa61 --- /dev/null +++ b/testdata/format/expected.txt @@ -0,0 +1,3 @@ +go mod edit --replace cloud.google.com/go=cloud.google.com/go@v0.100.2 +go mod edit --replace github.com/Azure/azure-sdk-for-go=github.com/Azure/azure-sdk-for-go@v59.3.0+incompatible +go get golang.org/x/mod@v0.6.0-dev.0.20220419223038-86c51ed26bb4 diff --git a/testdata/match/go.mod b/testdata/match/go.mod new file mode 100644 index 0000000..c429cbf --- /dev/null +++ b/testdata/match/go.mod @@ -0,0 +1,13 @@ +module github.com/krakendio/krakend-cobra/v2 + +go 1.17 + +require ( + github.com/gin-gonic/gin v1.8.2 + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 +) + +require ( + cloud.google.com/go v0.100.2 // indirect + github.com/Azure/azure-sdk-for-go v59.3.0+incompatible // indirect +) diff --git a/testdata/match-go.sum b/testdata/match/go.sum similarity index 100% rename from testdata/match-go.sum rename to testdata/match/go.sum