diff --git a/tool/internal/setup/sync.go b/tool/internal/setup/sync.go index 2fb955e3..9ed076e4 100644 --- a/tool/internal/setup/sync.go +++ b/tool/internal/setup/sync.go @@ -5,11 +5,14 @@ package setup import ( "context" + "fmt" + goversion "go/version" "os" "path/filepath" "strings" "golang.org/x/mod/modfile" + "golang.org/x/mod/semver" "github.com/open-telemetry/opentelemetry-go-compile-instrumentation/tool/ex" "github.com/open-telemetry/opentelemetry-go-compile-instrumentation/tool/internal/rule" @@ -70,6 +73,50 @@ func addReplace(modfile *modfile.File, replace *replaceDirective) (bool, error) return false, nil } +// versionSnapshot records go directive and direct dep versions before tidy. +type versionSnapshot struct { + goVersion string + deps map[string]string +} + +func snapshotVersion(mf *modfile.File) versionSnapshot { + snap := versionSnapshot{ + deps: make(map[string]string), + } + if mf.Go != nil { + snap.goVersion = mf.Go.Version + } + for _, req := range mf.Require { + if !req.Indirect { + snap.deps[req.Mod.Path] = req.Mod.Version + } + } + return snap +} + +func (sp *SetupPhase) warnVersion(goModPath string, before versionSnapshot) error { + after, err := parseGoMod(goModPath) + if err != nil { + return ex.Wrapf(err, "unable to check for version bumps after go mod tidy") + } + + // Go directives use Go toolchain syntax ("1.21"), not module semver. + if after.Go != nil && before.goVersion != "" { + if goversion.Compare("go"+after.Go.Version, "go"+before.goVersion) > 0 { + sp.Warn(fmt.Sprintf("Bumped go version (%s -> %s)", before.goVersion, after.Go.Version)) + } + } + + for _, req := range after.Require { + if oldVer, tracked := before.deps[req.Mod.Path]; tracked { + if semver.Compare(req.Mod.Version, oldVer) > 0 { + sp.Warn(fmt.Sprintf("Bumped dependency %s (%s -> %s)", req.Mod.Path, oldVer, req.Mod.Version)) + } + } + } + return nil +} + func (sp *SetupPhase) syncDeps(ctx context.Context, matched []*rule.InstRuleSet, moduleDir string) error { rules := make([]*rule.InstFuncRule, 0, len(matched)) for _, m := range matched { @@ -89,6 +136,8 @@ func (sp *SetupPhase) syncDeps(ctx context.Context, matched []*rule.InstRuleSet, if err != nil { return err } + + before := snapshotVersion(modfile) replaces := make([]*replaceDirective, 0) for _, m := range rules { util.Assert(strings.HasPrefix(m.Path, util.OtelcRoot), "sanity check") @@ -148,6 +197,11 @@ func (sp *SetupPhase) syncDeps(ctx context.Context, matched []*rule.InstRuleSet, if err != nil { return ex.Wrapf(err, "running go mod tidy in %s", moduleDir) } + // Compare after tidy because MVS may raise existing consumer versions. + err = sp.warnVersion(goModFile, before) + if err != nil { + return err + } sp.keepForDebug(goModFile) } return nil diff --git a/tool/internal/setup/sync_test.go b/tool/internal/setup/sync_test.go index 39032f94..be56d711 100644 --- a/tool/internal/setup/sync_test.go +++ b/tool/internal/setup/sync_test.go @@ -4,6 +4,7 @@ package setup import ( + "bytes" "log/slog" "os" "path/filepath" @@ -225,3 +226,180 @@ go 1.21 // At minimum, the pkg replace should be added assert.Contains(t, string(content), "replace") } + +func warnCapture() (*SetupPhase, *bytes.Buffer) { + var buf bytes.Buffer + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}) + return &SetupPhase{logger: slog.New(handler)}, &buf +} + +func TestSnapshotVersion(t *testing.T) { + content := `module example.com/app + +go 1.22.0 + +require ( + go.opentelemetry.io/otel v1.38.0 + github.com/example/lib v0.9.0 +) + +require ( + github.com/indirect/dep v0.5.0 // indirect +) +` + mf, err := modfile.Parse("go.mod", []byte(content), nil) + require.NoError(t, err) + + snap := snapshotVersion(mf) + + assert.Equal(t, "1.22.0", snap.goVersion) + assert.Equal(t, "v1.38.0", snap.deps["go.opentelemetry.io/otel"]) + assert.Equal(t, "v0.9.0", snap.deps["github.com/example/lib"]) + + // indirect deps must not leak into the snapshot + _, tracked := snap.deps["github.com/indirect/dep"] + assert.False(t, tracked) +} + +func TestSnapshotVersion_MinimalGoMod(t *testing.T) { + content := `module example.com/tiny + +go 1.21 +` + mf, err := modfile.Parse("go.mod", []byte(content), nil) + require.NoError(t, err) + + snap := snapshotVersion(mf) + assert.Equal(t, "1.21", snap.goVersion) + assert.Empty(t, snap.deps) +} + +func TestWarnVersion_GoVersionRaised(t *testing.T) { + tests := []struct { + name string + goVersion string + }{ + { + name: "patch version", + goVersion: "1.22.0", + }, + { + name: "language version", + goVersion: "1.21", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tempDir := t.TempDir() + gomodPath := filepath.Join(tempDir, "go.mod") + afterContent := `module example.com/app + +go 1.25.0 + +require ( + go.opentelemetry.io/otel v1.38.0 +) +` + require.NoError(t, os.WriteFile(gomodPath, []byte(afterContent), 0o644)) + + sp, buf := warnCapture() + before := versionSnapshot{ + goVersion: test.goVersion, + deps: map[string]string{ + "go.opentelemetry.io/otel": "v1.38.0", + }, + } + + require.NoError(t, sp.warnVersion(gomodPath, before)) + + logged := buf.String() + assert.Contains(t, logged, "Bumped go version") + assert.Contains(t, logged, test.goVersion+" -> 1.25.0") + }) + } +} + +func TestWarnVersion_DepVersionRaised(t *testing.T) { + tempDir := t.TempDir() + gomodPath := filepath.Join(tempDir, "go.mod") + afterContent := `module example.com/app + +go 1.22.0 + +require ( + go.opentelemetry.io/otel v1.43.0 +) +` + require.NoError(t, os.WriteFile(gomodPath, []byte(afterContent), 0o644)) + + sp, buf := warnCapture() + before := versionSnapshot{ + goVersion: "1.22.0", + deps: map[string]string{ + "go.opentelemetry.io/otel": "v1.38.0", + }, + } + + require.NoError(t, sp.warnVersion(gomodPath, before)) + + logged := buf.String() + assert.Contains(t, logged, "Bumped dependency go.opentelemetry.io/otel") + assert.Contains(t, logged, "v1.38.0 -> v1.43.0") +} + +func TestWarnVersion_NoChange(t *testing.T) { + tempDir := t.TempDir() + gomodPath := filepath.Join(tempDir, "go.mod") + content := `module example.com/app + +go 1.22.0 + +require ( + go.opentelemetry.io/otel v1.38.0 +) +` + require.NoError(t, os.WriteFile(gomodPath, []byte(content), 0o644)) + + sp, buf := warnCapture() + before := versionSnapshot{ + goVersion: "1.22.0", + deps: map[string]string{ + "go.opentelemetry.io/otel": "v1.38.0", + }, + } + + require.NoError(t, sp.warnVersion(gomodPath, before)) + + assert.Empty(t, buf.String()) +} + +func TestWarnVersion_MissingFile(t *testing.T) { + sp, _ := warnCapture() + before := versionSnapshot{goVersion: "1.22.0", deps: map[string]string{}} + + err := sp.warnVersion("/nonexistent/go.mod", before) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to check for version bumps") +} + +func TestWarnVersion_EmptyGoVersion(t *testing.T) { + tempDir := t.TempDir() + gomodPath := filepath.Join(tempDir, "go.mod") + afterContent := `module example.com/app + +go 1.25.0 +` + require.NoError(t, os.WriteFile(gomodPath, []byte(afterContent), 0o644)) + + sp, buf := warnCapture() + before := versionSnapshot{ + goVersion: "", + deps: map[string]string{}, + } + + require.NoError(t, sp.warnVersion(gomodPath, before)) + + assert.Empty(t, buf.String()) +}