diff --git a/internal/cmd/fetcher/main.go b/internal/cmd/fetcher/main.go index 05739943a..7278b1860 100644 --- a/internal/cmd/fetcher/main.go +++ b/internal/cmd/fetcher/main.go @@ -16,10 +16,13 @@ import ( "time" "buf.build/go/interrupt" + "github.com/bufbuild/buf/private/bufpkg/bufremoteplugin/bufremotepluginconfig" + "github.com/bufbuild/buf/private/pkg/encoding" "golang.org/x/mod/semver" "github.com/bufbuild/plugins/internal/docker" "github.com/bufbuild/plugins/internal/fetchclient" + "github.com/bufbuild/plugins/internal/plugin" "github.com/bufbuild/plugins/internal/source" ) @@ -221,6 +224,70 @@ func runPluginTests(ctx context.Context, plugins []createdPlugin) error { return cmd.Run() } +// updatePluginDeps updates plugin dependencies in a buf.plugin.yaml file to their latest versions. +// It parses the YAML content, finds any entries in the "deps:" section with "plugin:" fields, +// and updates them to use the latest available version from latestVersions map. +// For example, if the YAML contains: +// +// deps: +// - plugin: buf.build/protocolbuffers/go:v1.30.0 +// +// and latestVersions maps "buf.build/protocolbuffers/go" to "v1.36.11", +// the function will update it to: +// +// deps: +// - plugin: buf.build/protocolbuffers/go:v1.36.11 +// +// It returns the modified content with updated dependency versions. +func updatePluginDeps(content []byte, latestVersions map[string]string) ([]byte, error) { + var config bufremotepluginconfig.ExternalConfig + if err := encoding.UnmarshalJSONOrYAMLStrict(content, &config); err != nil { + return nil, fmt.Errorf("failed to parse buf.plugin.yaml: %w", err) + } + + // Check if there are any plugin dependencies + if len(config.Deps) == 0 { + // No deps, return original content + return content, nil + } + + modified := false + for i := range config.Deps { + dep := &config.Deps[i] + if dep.Plugin == "" { + continue + } + + // Parse the plugin reference: buf.build/owner/name:version + pluginName, currentVersion, ok := strings.Cut(dep.Plugin, ":") + if !ok { + continue + } + + // Look up the latest version for this plugin + if latestVersion, exists := latestVersions[pluginName]; exists && latestVersion != currentVersion { + oldPluginRef := dep.Plugin + newPluginRef := pluginName + ":" + latestVersion + dep.Plugin = newPluginRef + log.Printf("updating plugin dependency %s -> %s", oldPluginRef, newPluginRef) + modified = true + } + } + + if !modified { + // No changes made, return original content + return content, nil + } + + // Marshal back to YAML + updatedContent, err := encoding.MarshalYAML(&config) + if err != nil { + return nil, fmt.Errorf("failed to marshal updated YAML: %w", err) + } + + return updatedContent, nil +} + func run(ctx context.Context, root string) ([]createdPlugin, error) { now := time.Now() defer func() { @@ -234,6 +301,21 @@ func run(ctx context.Context, root string) ([]createdPlugin, error) { if err != nil { return nil, err } + + // Load all existing plugins to determine latest versions for dependency bumping + pluginsDir := filepath.Join(root, "plugins") + allPlugins, err := plugin.FindAll(pluginsDir) + if err != nil { + return nil, fmt.Errorf("failed to load existing plugins: %w", err) + } + latestPluginVersions := make(map[string]string) + for _, p := range allPlugins { + current := latestPluginVersions[p.Name] + if current == "" || semver.Compare(current, p.PluginVersion) < 0 { + latestPluginVersions[p.Name] = p.PluginVersion + } + } + configs, err := source.GatherConfigs(root) if err != nil { return nil, err @@ -277,7 +359,7 @@ func run(ctx context.Context, root string) ([]createdPlugin, error) { if err != nil { return nil, fmt.Errorf("failed to get latest known version from dir %s with error: %w", pluginDir, err) } - if err := createPluginDir(pluginDir, previousVersion, newVersion, latestBaseImageVersions); err != nil { + if err := createPluginDir(pluginDir, previousVersion, newVersion, latestBaseImageVersions, latestPluginVersions); err != nil { return nil, err } log.Printf("created %v/%v\n", pluginDir, newVersion) @@ -301,6 +383,7 @@ func copyDirectory( prevVersion string, newVersion string, latestBaseImages *docker.BaseImages, + latestPluginVersions map[string]string, ) (retErr error) { entries, err := os.ReadDir(source) if err != nil { @@ -324,6 +407,7 @@ func copyDirectory( prevVersion, newVersion, latestBaseImages, + latestPluginVersions, ); err != nil { return err } @@ -336,6 +420,7 @@ func createPluginDir( previousVersion string, newVersion string, latestBaseImages *docker.BaseImages, + latestPluginVersions map[string]string, ) (retErr error) { if err := os.Mkdir(filepath.Join(dir, newVersion), 0755); err != nil { return err @@ -351,6 +436,7 @@ func createPluginDir( previousVersion, newVersion, latestBaseImages, + latestPluginVersions, ) } @@ -360,6 +446,7 @@ func copyFile( prevVersion string, newVersion string, latestBaseImages *docker.BaseImages, + latestPluginVersions map[string]string, ) (retErr error) { srcFile, err := os.Open(src) if err != nil { @@ -386,6 +473,28 @@ func copyFile( } return nil } + + // Special handling for buf.plugin.yaml to update plugin dependencies + if filename == "buf.plugin.yaml" { + content, err := io.ReadAll(srcFile) + if err != nil { + return fmt.Errorf("failed to read buf.plugin.yaml: %w", err) + } + // Update plugin dependencies to latest versions + content, err = updatePluginDeps(content, latestPluginVersions) + if err != nil { + return fmt.Errorf("failed to update plugin deps: %w", err) + } + // Now do the version string replacement + prevVersionStripped := strings.TrimPrefix(prevVersion, "v") + newVersionStripped := strings.TrimPrefix(newVersion, "v") + content = []byte(strings.ReplaceAll(string(content), prevVersionStripped, newVersionStripped)) + if _, err := destFile.Write(content); err != nil { + return err + } + return nil + } + isDockerfile := strings.HasPrefix(filename, "Dockerfile") prevVersion = strings.TrimPrefix(prevVersion, "v") newVersion = strings.TrimPrefix(newVersion, "v") diff --git a/internal/cmd/fetcher/main_test.go b/internal/cmd/fetcher/main_test.go new file mode 100644 index 000000000..1463426f2 --- /dev/null +++ b/internal/cmd/fetcher/main_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "strings" + "testing" + + "github.com/bufbuild/buf/private/bufpkg/bufremoteplugin/bufremotepluginconfig" + "github.com/bufbuild/buf/private/pkg/encoding" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdatePluginDeps(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + latestVersions map[string]string + expectedUpdates map[string]string // plugin name -> expected version + wantErr bool + }{ + { + name: "updates single plugin dependency", + input: `version: v1 +name: buf.build/test/plugin +plugin_version: v1.0.0 +deps: + - plugin: buf.build/protocolbuffers/go:v1.30.0 +`, + latestVersions: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + }, + expectedUpdates: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + }, + }, + { + name: "updates multiple plugin dependencies", + input: `version: v1 +name: buf.build/test/plugin +plugin_version: v1.0.0 +deps: + - plugin: buf.build/protocolbuffers/go:v1.30.0 + - plugin: buf.build/protocolbuffers/python:v30.0 +`, + latestVersions: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + "buf.build/protocolbuffers/python": "v33.5", + }, + expectedUpdates: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + "buf.build/protocolbuffers/python": "v33.5", + }, + }, + { + name: "no updates when already at latest version", + input: `version: v1 +name: buf.build/test/plugin +plugin_version: v1.0.0 +deps: + - plugin: buf.build/protocolbuffers/go:v1.36.11 +`, + latestVersions: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + }, + expectedUpdates: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + }, + }, + { + name: "handles missing plugin in latestVersions map", + input: `version: v1 +name: buf.build/test/plugin +plugin_version: v1.0.0 +deps: + - plugin: buf.build/unknown/plugin:v1.0.0 +`, + latestVersions: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + }, + expectedUpdates: map[string]string{ + "buf.build/unknown/plugin": "v1.0.0", // unchanged + }, + }, + { + name: "handles yaml without deps", + input: `version: v1 +name: buf.build/test/plugin +plugin_version: v1.0.0 +`, + latestVersions: map[string]string{ + "buf.build/protocolbuffers/go": "v1.36.11", + }, + expectedUpdates: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := updatePluginDeps([]byte(tt.input), tt.latestVersions) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + + // Parse the result to check if dependencies were updated correctly + var config bufremotepluginconfig.ExternalConfig + err = encoding.UnmarshalJSONOrYAMLStrict(result, &config) + require.NoError(t, err, "failed to parse result YAML") + + if tt.expectedUpdates == nil { + // No deps expected in result + assert.Empty(t, config.Deps, "expected no deps field, but got one") + return + } + + require.NotEmpty(t, config.Deps, "expected deps field in result") + + // Check each dependency + foundDeps := make(map[string]string) + for _, dep := range config.Deps { + if dep.Plugin == "" { + continue + } + pluginName, version, ok := strings.Cut(dep.Plugin, ":") + require.True(t, ok, "invalid plugin reference format: %s", dep.Plugin) + foundDeps[pluginName] = version + } + + // Verify all expected updates + for pluginName, expectedVersion := range tt.expectedUpdates { + foundVersion, ok := foundDeps[pluginName] + assert.True(t, ok, "missing plugin dependency: %s", pluginName) + assert.Equal(t, expectedVersion, foundVersion, "plugin %s version mismatch", pluginName) + } + }) + } +}