Skip to content

Commit c231465

Browse files
Handle updates to plugin deps in same update (#2243)
In #2242, I mentioned: > I don't think this handles the case where the update to a dep is in the same PR as the update to the consuming plugin (I think the syncer can handle that case?), so we could maybe extend this further to detect that situation. This attempts to handle that situation, by refactoring the fetcher a little bit: we first take a pass to grab all the pending updates, and then subsequently run the updates (in dependency order), updating the `latestPluginVersions` as we go so that consumers can be updated with the latest dependency. This just needed a tiny bit of tweaking to have an "integration" test over `run` (making the fetcher an interface), so added that as well.
1 parent be39419 commit c231465

File tree

2 files changed

+212
-14
lines changed

2 files changed

+212
-14
lines changed

internal/cmd/fetcher/main.go

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,20 @@ var (
3232
errNoVersions = errors.New("no versions found")
3333
)
3434

35+
// Fetcher is an interface for fetching plugin versions from external sources.
36+
type Fetcher interface {
37+
Fetch(ctx context.Context, config *source.Config) (string, error)
38+
}
39+
3540
func main() {
3641
if len(os.Args) != 2 {
3742
_, _ = fmt.Fprintf(os.Stderr, "usage: %s <directory>\n", os.Args)
3843
os.Exit(2)
3944
}
4045
root := os.Args[1]
4146
ctx := interrupt.Handle(context.Background())
42-
created, err := run(ctx, root)
47+
client := fetchclient.New(ctx)
48+
created, err := run(ctx, root, client)
4349
if err != nil {
4450
_, _ = fmt.Fprintf(os.Stderr, "failed to fetch versions: %v\n", err)
4551
os.Exit(1)
@@ -288,7 +294,14 @@ func updatePluginDeps(content []byte, latestVersions map[string]string) ([]byte,
288294
return updatedContent, nil
289295
}
290296

291-
func run(ctx context.Context, root string) ([]createdPlugin, error) {
297+
// pluginToCreate represents a plugin that needs a new version created.
298+
type pluginToCreate struct {
299+
pluginDir string
300+
previousVersion string
301+
newVersion string
302+
}
303+
304+
func run(ctx context.Context, root string, fetcher Fetcher) ([]createdPlugin, error) {
292305
now := time.Now()
293306
defer func() {
294307
log.Printf("finished running in: %.2fs\n", time.Since(now).Seconds())
@@ -302,12 +315,14 @@ func run(ctx context.Context, root string) ([]createdPlugin, error) {
302315
return nil, err
303316
}
304317

305-
// Load all existing plugins to determine latest versions for dependency bumping
318+
// Load all existing plugins (already sorted in dependency order by plugin.FindAll)
306319
pluginsDir := filepath.Join(root, "plugins")
307320
allPlugins, err := plugin.FindAll(pluginsDir)
308321
if err != nil {
309322
return nil, fmt.Errorf("failed to load existing plugins: %w", err)
310323
}
324+
325+
// Build initial map of latest plugin versions
311326
latestPluginVersions := make(map[string]string)
312327
for _, p := range allPlugins {
313328
current := latestPluginVersions[p.Name]
@@ -320,17 +335,19 @@ func run(ctx context.Context, root string) ([]createdPlugin, error) {
320335
if err != nil {
321336
return nil, err
322337
}
323-
client := fetchclient.New(ctx)
338+
339+
// First pass: fetch all new versions and determine which plugins need updates
324340
latestVersions := make(map[string]string, len(configs))
325-
created := make([]createdPlugin, 0, len(configs))
341+
pendingCreations := make(map[string]*pluginToCreate) // keyed by plugin directory
342+
326343
for _, config := range configs {
327344
if config.Source.Disabled {
328345
log.Printf("skipping source: %s", config.Filename)
329346
continue
330347
}
331348
newVersion := latestVersions[config.CacheKey()]
332349
if newVersion == "" {
333-
newVersion, err = client.Fetch(ctx, config)
350+
newVersion, err = fetcher.Fetch(ctx, config)
334351
if err != nil {
335352
if errors.Is(err, fetchclient.ErrSemverPrerelease) {
336353
log.Printf("skipping source: %s: %v", config.Filename, err)
@@ -346,7 +363,6 @@ func run(ctx context.Context, root string) ([]createdPlugin, error) {
346363
log.Printf("skipping source: %s: %v", config.Filename, newVersion)
347364
continue
348365
}
349-
// example: plugins/grpc
350366
pluginDir := filepath.Dir(config.Filename)
351367
ok, err := checkDirExists(filepath.Join(pluginDir, newVersion))
352368
if err != nil {
@@ -359,16 +375,40 @@ func run(ctx context.Context, root string) ([]createdPlugin, error) {
359375
if err != nil {
360376
return nil, fmt.Errorf("failed to get latest known version from dir %s with error: %w", pluginDir, err)
361377
}
362-
if err := createPluginDir(pluginDir, previousVersion, newVersion, latestBaseImageVersions, latestPluginVersions); err != nil {
363-
return nil, err
364-
}
365-
log.Printf("created %v/%v\n", pluginDir, newVersion)
366-
created = append(created, createdPlugin{
367-
org: filepath.Base(filepath.Dir(pluginDir)),
368-
name: filepath.Base(pluginDir),
378+
379+
pendingCreations[pluginDir] = &pluginToCreate{
369380
pluginDir: pluginDir,
370381
previousVersion: previousVersion,
371382
newVersion: newVersion,
383+
}
384+
}
385+
386+
// Second pass: create plugins in dependency order (using the order from allPlugins)
387+
// Update latestPluginVersions as we go so subsequent plugins reference new versions
388+
created := make([]createdPlugin, 0, len(pendingCreations))
389+
for _, p := range allPlugins {
390+
// Extract the plugin directory from the plugin's path
391+
// p.Path is the full path to buf.plugin.yaml, directory is two levels up (dir/version/buf.plugin.yaml)
392+
pluginDir := filepath.Dir(filepath.Dir(p.Path))
393+
pending, needsCreation := pendingCreations[pluginDir]
394+
if !needsCreation {
395+
continue
396+
}
397+
398+
if err := createPluginDir(pending.pluginDir, pending.previousVersion, pending.newVersion, latestBaseImageVersions, latestPluginVersions); err != nil {
399+
return nil, err
400+
}
401+
log.Printf("created %v/%v\n", pending.pluginDir, pending.newVersion)
402+
403+
// Update latestPluginVersions so subsequent plugins in this run can reference this new version
404+
latestPluginVersions[p.Name] = pending.newVersion
405+
406+
created = append(created, createdPlugin{
407+
org: filepath.Base(filepath.Dir(pending.pluginDir)),
408+
name: filepath.Base(pending.pluginDir),
409+
pluginDir: pending.pluginDir,
410+
previousVersion: pending.previousVersion,
411+
newVersion: pending.newVersion,
372412
})
373413
}
374414
return created, nil

internal/cmd/fetcher/main_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
package main
22

33
import (
4+
"context"
5+
"os"
6+
"path/filepath"
47
"strings"
58
"testing"
69

710
"github.com/bufbuild/buf/private/bufpkg/bufremoteplugin/bufremotepluginconfig"
811
"github.com/bufbuild/buf/private/pkg/encoding"
912
"github.com/stretchr/testify/assert"
1013
"github.com/stretchr/testify/require"
14+
15+
"github.com/bufbuild/plugins/internal/source"
1116
)
1217

1318
func TestUpdatePluginDeps(t *testing.T) {
@@ -138,3 +143,156 @@ plugin_version: v1.0.0
138143
})
139144
}
140145
}
146+
147+
// TestRunDependencyOrdering tests the end-to-end behavior of run() with dependency ordering.
148+
// It verifies that when creating multiple plugin versions in one run, they are processed
149+
// in dependency order and consumers reference the newly created dependency versions.
150+
func TestRunDependencyOrdering(t *testing.T) {
151+
t.Parallel()
152+
ctx := context.Background()
153+
tmpDir := t.TempDir()
154+
155+
// Setup: Create complete repository structure
156+
setupTestRepository(t, tmpDir)
157+
158+
// Mock fetcher that returns new versions for our test plugins
159+
// Cache keys are formatted as "github-owner-repository"
160+
fetcher := &mockFetcher{
161+
versions: map[string]string{
162+
"github-test-base-plugin": "v2.0.0",
163+
"github-test-consumer-plugin": "v2.0.0",
164+
},
165+
}
166+
167+
// Run the fetcher
168+
created, err := run(ctx, tmpDir, fetcher)
169+
require.NoError(t, err)
170+
171+
// Verify plugins were created in dependency order
172+
require.Len(t, created, 2, "should create 2 new plugin versions")
173+
assert.Equal(t, "base-plugin", created[0].name, "base-plugin should be created first (no dependencies)")
174+
assert.Equal(t, "v2.0.0", created[0].newVersion)
175+
assert.Equal(t, "consumer-plugin", created[1].name, "consumer-plugin should be created second (depends on base-plugin)")
176+
assert.Equal(t, "v2.0.0", created[1].newVersion)
177+
178+
// Verify consumer references the newly created base-plugin v2.0.0
179+
consumerYAMLPath := filepath.Join(tmpDir, "plugins", "test", "consumer-plugin", "v2.0.0", "buf.plugin.yaml")
180+
content, err := os.ReadFile(consumerYAMLPath)
181+
require.NoError(t, err, "should be able to read created consumer plugin config")
182+
183+
var config bufremotepluginconfig.ExternalConfig
184+
err = encoding.UnmarshalJSONOrYAMLStrict(content, &config)
185+
require.NoError(t, err, "should be able to parse consumer plugin config")
186+
187+
require.Len(t, config.Deps, 1, "consumer should have one dependency")
188+
assert.Equal(t, "buf.build/test/base-plugin:v2.0.0", config.Deps[0].Plugin,
189+
"consumer should reference newly created base-plugin v2.0.0, not the old v1.0.0")
190+
}
191+
192+
// mockFetcher returns predetermined versions for testing.
193+
type mockFetcher struct {
194+
versions map[string]string // maps cache key (e.g., "github-owner-repo") -> version to return
195+
}
196+
197+
func (m *mockFetcher) Fetch(_ context.Context, config *source.Config) (string, error) {
198+
key := config.CacheKey()
199+
if version, ok := m.versions[key]; ok {
200+
return version, nil
201+
}
202+
// Return a default version if not in map
203+
return "v1.0.0", nil
204+
}
205+
206+
// setupTestRepository creates a complete test repository structure with:
207+
// - plugins/ directory with base-plugin and consumer-plugin
208+
// - source.yaml files for version detection
209+
// - .github/docker/ directory with base images.
210+
func setupTestRepository(t *testing.T, tmpDir string) {
211+
t.Helper()
212+
213+
// Create base Docker images directory (required by run())
214+
baseImageDir := filepath.Join(tmpDir, ".github", "docker")
215+
require.NoError(t, os.MkdirAll(baseImageDir, 0755))
216+
217+
// Create required docker/dockerfile base image
218+
dockerfileImage := `FROM docker/dockerfile:1.19
219+
`
220+
require.NoError(t, os.WriteFile(filepath.Join(baseImageDir, "Dockerfile.dockerfile"), []byte(dockerfileImage), 0644))
221+
222+
// Create golang base image
223+
golangImage := `FROM golang:1.22.0-bookworm
224+
`
225+
require.NoError(t, os.WriteFile(filepath.Join(baseImageDir, "Dockerfile.golang"), []byte(golangImage), 0644))
226+
227+
// Setup base-plugin v1.0.0
228+
basePluginDir := filepath.Join(tmpDir, "plugins", "test", "base-plugin")
229+
require.NoError(t, os.MkdirAll(filepath.Join(basePluginDir, "v1.0.0"), 0755))
230+
231+
// Create source.yaml for base-plugin
232+
baseSourceYAML := `source:
233+
github:
234+
owner: test
235+
repository: base-plugin
236+
`
237+
require.NoError(t, os.WriteFile(filepath.Join(basePluginDir, "source.yaml"), []byte(baseSourceYAML), 0644))
238+
239+
// Create buf.plugin.yaml for base-plugin v1.0.0
240+
basePluginYAML := `version: v1
241+
name: buf.build/test/base-plugin
242+
plugin_version: v1.0.0
243+
output_languages:
244+
- go
245+
`
246+
require.NoError(t, os.WriteFile(
247+
filepath.Join(basePluginDir, "v1.0.0", "buf.plugin.yaml"),
248+
[]byte(basePluginYAML),
249+
0644,
250+
))
251+
252+
// Create Dockerfile for base-plugin v1.0.0
253+
baseDockerfile := `FROM golang:1.22.0-bookworm
254+
COPY --from=base /binary /usr/local/bin/protoc-gen-base
255+
`
256+
require.NoError(t, os.WriteFile(
257+
filepath.Join(basePluginDir, "v1.0.0", "Dockerfile"),
258+
[]byte(baseDockerfile),
259+
0644,
260+
))
261+
262+
// Setup consumer-plugin v1.0.0
263+
consumerPluginDir := filepath.Join(tmpDir, "plugins", "test", "consumer-plugin")
264+
require.NoError(t, os.MkdirAll(filepath.Join(consumerPluginDir, "v1.0.0"), 0755))
265+
266+
// Create source.yaml for consumer-plugin
267+
consumerSourceYAML := `source:
268+
github:
269+
owner: test
270+
repository: consumer-plugin
271+
`
272+
require.NoError(t, os.WriteFile(filepath.Join(consumerPluginDir, "source.yaml"), []byte(consumerSourceYAML), 0644))
273+
274+
// Create buf.plugin.yaml for consumer-plugin v1.0.0 that depends on base-plugin v1.0.0
275+
consumerPluginYAML := `version: v1
276+
name: buf.build/test/consumer-plugin
277+
plugin_version: v1.0.0
278+
deps:
279+
- plugin: buf.build/test/base-plugin:v1.0.0
280+
output_languages:
281+
- go
282+
`
283+
require.NoError(t, os.WriteFile(
284+
filepath.Join(consumerPluginDir, "v1.0.0", "buf.plugin.yaml"),
285+
[]byte(consumerPluginYAML),
286+
0644,
287+
))
288+
289+
// Create Dockerfile for consumer-plugin v1.0.0
290+
consumerDockerfile := `FROM golang:1.22.0-bookworm
291+
COPY --from=consumer /binary /usr/local/bin/protoc-gen-consumer
292+
`
293+
require.NoError(t, os.WriteFile(
294+
filepath.Join(consumerPluginDir, "v1.0.0", "Dockerfile"),
295+
[]byte(consumerDockerfile),
296+
0644,
297+
))
298+
}

0 commit comments

Comments
 (0)