diff --git a/cmd/internal/migrations/dependencies.go b/cmd/internal/migrations/dependencies.go new file mode 100644 index 0000000..539b7bb --- /dev/null +++ b/cmd/internal/migrations/dependencies.go @@ -0,0 +1,111 @@ +package migrations + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + semver "github.com/Masterminds/semver/v3" + "github.com/spf13/cobra" + "golang.org/x/mod/modfile" +) + +// ExecCommand is used to run external commands. It can be replaced in tests. +var ExecCommand = exec.Command + +// MigrateDependencies ensures that dependencies shared with Fiber are at least +// the versions required by the target Fiber release. +// +// It updates go.mod files that already require a dependency also required by +// Fiber, bumping the version when it is lower than Fiber's requirement. No +// changes are made if the existing version is equal or higher. +func MigrateDependencies(cmd *cobra.Command, cwd string, _, target *semver.Version) error { + fiberModule := fmt.Sprintf("github.com/gofiber/fiber/v%d@v%s", target.Major(), target.String()) + + c := ExecCommand("go", "mod", "download", "-json", fiberModule) + var out bytes.Buffer + c.Stdout = &out + c.Stderr = &out + if err := c.Run(); err != nil { + return fmt.Errorf("download fiber module: %w", err) + } + var info struct { + GoMod string `json:"GoMod"` //nolint:tagliatelle // field name defined by go tool output + } + if err := json.Unmarshal(out.Bytes(), &info); err != nil { + return fmt.Errorf("parse download info: %w", err) + } + + b, err := os.ReadFile(info.GoMod) // #nosec G304 + if err != nil { + return fmt.Errorf("read fiber go.mod: %w", err) + } + mf, err := modfile.Parse(info.GoMod, b, nil) + if err != nil { + return fmt.Errorf("parse fiber go.mod: %w", err) + } + + deps := make(map[string]*semver.Version, len(mf.Require)) + for _, r := range mf.Require { + v, err := semver.NewVersion(strings.TrimPrefix(r.Mod.Version, "v")) + if err != nil { + return fmt.Errorf("parse fiber dependency %s version %s: %w", r.Mod.Path, r.Mod.Version, err) + } + deps[r.Mod.Path] = v + } + + dirs, err := fiberModuleDirs(cwd) + if err != nil { + return fmt.Errorf("find modules: %w", err) + } + + anyChanged := false + for _, dir := range dirs { + modFile := filepath.Join(dir, "go.mod") + b, err := os.ReadFile(modFile) // #nosec G304 + if err != nil { + return fmt.Errorf("read %s: %w", modFile, err) + } + mf, err := modfile.Parse(modFile, b, nil) + if err != nil { + return fmt.Errorf("parse %s: %w", modFile, err) + } + + changed := false + for _, r := range mf.Require { + targetVer, ok := deps[r.Mod.Path] + if !ok { + continue + } + currVer, err := semver.NewVersion(strings.TrimPrefix(r.Mod.Version, "v")) + if err != nil { + return fmt.Errorf("parse %s version in %s: %w", r.Mod.Path, modFile, err) + } + if currVer.LessThan(targetVer) { + r.Mod.Version = "v" + targetVer.String() + changed = true + } + } + + if changed { + mf.SetRequire(mf.Require) + formatted, err := mf.Format() + if err != nil { + return fmt.Errorf("format %s: %w", modFile, err) + } + if err := os.WriteFile(modFile, formatted, 0o600); err != nil { + return fmt.Errorf("write %s: %w", modFile, err) + } + anyChanged = true + } + } + + if anyChanged { + cmd.Println("Updating dependency versions") + } + return nil +} diff --git a/cmd/internal/migrations/dependencies_test.go b/cmd/internal/migrations/dependencies_test.go new file mode 100644 index 0000000..30fa605 --- /dev/null +++ b/cmd/internal/migrations/dependencies_test.go @@ -0,0 +1,96 @@ +package migrations_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + semver "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gofiber/cli/cmd/internal/migrations" +) + +func Test_MigrateDependencies(t *testing.T) { + dir, err := os.MkdirTemp("", "mdeps") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + mod := `module example + +go 1.22 + +require ( + github.com/gofiber/fiber/v3 v3.0.0 + github.com/valyala/fasthttp v1.0.0 + github.com/andybalholm/brotli v1.2.0 +)` + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(mod), 0o600)) + + fiberMod := `module github.com/gofiber/fiber/v3 + +go 1.22 + +require ( + github.com/valyala/fasthttp v1.10.0 + github.com/andybalholm/brotli v1.0.0 +)` + fiberGoMod := filepath.Join(dir, "fiber.mod") + require.NoError(t, os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600)) + + restore := stubFiberDownload(t, fiberGoMod) + defer restore() + + var buf bytes.Buffer + cmd := newCmd(&buf) + target := semver.MustParse("3.0.0") + require.NoError(t, migrations.MigrateDependencies(cmd, dir, nil, target)) + + content := readFile(t, filepath.Join(dir, "go.mod")) + assert.Contains(t, content, "github.com/valyala/fasthttp v1.10.0") + assert.Contains(t, content, "github.com/andybalholm/brotli v1.2.0") + assert.Contains(t, buf.String(), "Updating dependency versions") +} + +func Test_MigrateDependencies_NoChange(t *testing.T) { + dir, err := os.MkdirTemp("", "mdeps_nc") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + mod := `module example + +go 1.22 + +require ( + github.com/gofiber/fiber/v3 v3.0.0 + github.com/valyala/fasthttp v1.10.0 + github.com/andybalholm/brotli v1.2.0 +)` + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(mod), 0o600)) + + fiberMod := `module github.com/gofiber/fiber/v3 + +go 1.22 + +require ( + github.com/valyala/fasthttp v1.10.0 + github.com/andybalholm/brotli v1.0.0 +)` + fiberGoMod := filepath.Join(dir, "fiber.mod") + require.NoError(t, os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600)) + + restore := stubFiberDownload(t, fiberGoMod) + defer restore() + + var buf bytes.Buffer + cmd := newCmd(&buf) + target := semver.MustParse("3.0.0") + require.NoError(t, migrations.MigrateDependencies(cmd, dir, nil, target)) + + content := readFile(t, filepath.Join(dir, "go.mod")) + assert.Contains(t, content, "github.com/valyala/fasthttp v1.10.0") + assert.Contains(t, content, "github.com/andybalholm/brotli v1.2.0") + assert.Empty(t, buf.String()) +} diff --git a/cmd/internal/migrations/exec_stub_test.go b/cmd/internal/migrations/exec_stub_test.go new file mode 100644 index 0000000..58a9665 --- /dev/null +++ b/cmd/internal/migrations/exec_stub_test.go @@ -0,0 +1,40 @@ +package migrations_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/gofiber/cli/cmd/internal/migrations" +) + +// stubFiberDownload replaces migrations.ExecCommand with a helper that +// returns the provided Fiber go.mod path in JSON format. It returns a +// function to restore the original ExecCommand. +func stubFiberDownload(t *testing.T, fiberGoMod string) func() { + t.Helper() + orig := migrations.ExecCommand + out := fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod)) + migrations.ExecCommand = func(string, ...string) *exec.Cmd { + cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess", "--") // #nosec G204 -- test helper + cmd.Env = []string{ + "GO_WANT_HELPER_PROCESS=1", + "GO_HELPER_STDOUT=" + out, + } + return cmd + } + return func() { migrations.ExecCommand = orig } +} + +func TestHelperProcess(t *testing.T) { + t.Helper() + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + if out := os.Getenv("GO_HELPER_STDOUT"); out != "" { + _, _ = fmt.Fprint(os.Stdout, out) + } + os.Exit(0) //nolint:revive // helper process exits intentionally +} diff --git a/cmd/internal/migrations/lists.go b/cmd/internal/migrations/lists.go index a07e944..8ea4fb1 100644 --- a/cmd/internal/migrations/lists.go +++ b/cmd/internal/migrations/lists.go @@ -30,7 +30,7 @@ type Migration struct { // Example structure: // {"from": ">=2.0.0", "to": "<=3.*.*", "fn": [MigrateFN, MigrateFN]} var Migrations = []Migration{ - {From: ">=1.0.0-0", To: ">=0.0.0-0", Functions: []MigrationFn{MigrateGoPkgs}}, + {From: ">=1.0.0-0", To: ">=0.0.0-0", Functions: []MigrationFn{MigrateGoPkgs, MigrateDependencies}}, { From: ">=2.0.0-0", To: "<4.0.0-0", diff --git a/cmd/internal/migrations/lists_test.go b/cmd/internal/migrations/lists_test.go index 136310b..07a8f58 100644 --- a/cmd/internal/migrations/lists_test.go +++ b/cmd/internal/migrations/lists_test.go @@ -44,14 +44,22 @@ func Test_DoMigration_Verbose(t *testing.T) { } func Test_DoMigration_Verbose_Run(t *testing.T) { - t.Parallel() curr := semver.MustParse("1.0.0") target := semver.MustParse("2.0.0") t.Run("no changes", func(t *testing.T) { - t.Parallel() + fiberMod := `module github.com/gofiber/fiber/v2 + +go 1.22 +require github.com/valyala/fasthttp v1.0.0` dir := t.TempDir() + fiberGoMod := filepath.Join(dir, "fiber.mod") + require.NoError(t, os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600)) + + restore := stubFiberDownload(t, fiberGoMod) + t.Cleanup(restore) + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test\n\nrequire github.com/gofiber/fiber/v2 v2.0.0\n"), 0o600)) var buf bytes.Buffer cmd := &cobra.Command{} @@ -63,9 +71,18 @@ func Test_DoMigration_Verbose_Run(t *testing.T) { }) t.Run("changes", func(t *testing.T) { - t.Parallel() + fiberMod := `module github.com/gofiber/fiber/v1 + +go 1.22 +require github.com/valyala/fasthttp v1.0.0` dir := t.TempDir() + fiberGoMod := filepath.Join(dir, "fiber.mod") + require.NoError(t, os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600)) + + restore := stubFiberDownload(t, fiberGoMod) + t.Cleanup(restore) + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test\n\nrequire github.com/gofiber/fiber/v1 v1.0.0\n"), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nimport \"github.com/gofiber/fiber/v1\"\n"), 0o600)) var buf bytes.Buffer diff --git a/cmd/migrate_test.go b/cmd/migrate_test.go index e05b6eb..9e1cd29 100644 --- a/cmd/migrate_test.go +++ b/cmd/migrate_test.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "net/http" "os" "os/exec" @@ -12,6 +13,7 @@ import ( "github.com/stretchr/testify/require" cmdinternal "github.com/gofiber/cli/cmd/internal" + "github.com/gofiber/cli/cmd/internal/migrations" ) func readFileTB(tb testing.TB, path string) string { @@ -21,6 +23,41 @@ func readFileTB(tb testing.TB, path string) string { return string(b) } +func TestMain(m *testing.M) { + orig := migrations.ExecCommand + + tmpDir, err := os.MkdirTemp("", "fiber_mod") + if err != nil { + panic(err) + } + fiberMod := `module github.com/gofiber/fiber/v2 + +go 1.22 + +require github.com/valyala/fasthttp v1.0.0` + fiberGoMod := filepath.Join(tmpDir, "go.mod") + if err := os.WriteFile(fiberGoMod, []byte(fiberMod), 0o600); err != nil { + panic(err) + } + + migrations.ExecCommand = func(string, ...string) *exec.Cmd { + cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess", "--", "go", "mod", "download") // #nosec G204 -- test helper + cmd.Env = []string{ + "GO_WANT_HELPER_PROCESS=1", + "GO_HELPER_STDOUT=" + fmt.Sprintf(`{"GoMod":%q}`, filepath.ToSlash(fiberGoMod)), + } + return cmd + } + + code := m.Run() + + migrations.ExecCommand = orig + if err := os.RemoveAll(tmpDir); err != nil { + panic(err) + } + os.Exit(code) +} + const goModV2 = `module example.com/demo go 1.20 diff --git a/cmd/tester_test.go b/cmd/tester_test.go index 140c1e0..c962588 100644 --- a/cmd/tester_test.go +++ b/cmd/tester_test.go @@ -57,6 +57,9 @@ func TestHelperProcess(t *testing.T) { testExit(1) return } + if out := os.Getenv("GO_HELPER_STDOUT"); out != "" { + _, _ = fmt.Fprint(os.Stdout, out) + } testExit(0) }