Skip to content

Commit f6842b8

Browse files
authored
make bundle a general solution rather than only composer (#484)
code support for bundle extension. Based on previous change, this PR is pretty simple. Now you could run extension in this way: ``` boe run --extension composer/example-go --extension dns/listener ``` Because the `/` is not allowed in the extension name (`"^[a-z][.a-z0-9-_]*$"`), it's pretty great to be used as the separator between bundle and extension name. And easy to understand and use. If there is a `/`, the first part is bundle (artifact), the second part the specific extension name. If there is no `/`, we will fallback to use the extension name as bundle name. --------- Signed-off-by: wbpcode <wbphub@gmail.com>
1 parent 9f79523 commit f6842b8

17 files changed

Lines changed: 545 additions & 63 deletions

cli/cmd/download.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func (d *Download) AfterApply(dirs *xdg.Directories, logger *slog.Logger) error
6565
func (d *Download) Run(ctx context.Context, logger *slog.Logger) error {
6666
logger.Debug("handling download command", "cmd", internal.RedactSensitive(d))
6767

68-
name, tag := splitRef(d.Extension)
68+
bundle, name, tag := splitRef(d.Extension)
6969
_, _ = fmt.Fprintf(os.Stderr, "→ %sDownloading %s for %s/%s...%s\n",
7070
internal.ANSIBold, d.Extension, d.downloader.OS, d.downloader.Arch, internal.ANSIReset)
7171

@@ -77,7 +77,7 @@ func (d *Download) Run(ctx context.Context, logger *slog.Logger) error {
7777
case extensions.ComposerArtifact, extensions.ComposerArtifactLite, extensions.ComposerArtifactSource:
7878
downloaded, err = d.downloader.DownloadComposer(ctx, tag, name)
7979
default:
80-
downloaded, err = d.downloader.DownloadExtension(ctx, name, tag)
80+
downloaded, err = d.downloader.DownloadExtension(ctx, bundle, name, tag)
8181
}
8282
if err != nil {
8383
return fmt.Errorf("failed to download extension: %w", err)

cli/cmd/flags.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func (e extensionPositions) sort(manifests []*extensions.Manifest) ([]*extension
4242

4343
for r, positions := range e.remote {
4444
pos := slices.IndexFunc(manifests, func(m *extensions.Manifest) bool {
45-
return m.Remote && (m.Name+":"+m.Version == r || m.Name == r)
45+
return m.Remote && (m.RemoteRef == r)
4646
})
4747
if pos == -1 {
4848
return nil, fmt.Errorf("failed to find manifest for remote extension with reference %s", r)

cli/cmd/flags_test.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,20 @@ func TestExtensionPositionsSort(t *testing.T) {
3131
name: "single remote extension",
3232
args: []string{"boe", "gen-config", "--extension", "ext1"},
3333
manifests: []*extensions.Manifest{
34-
{Name: "ext1", Remote: true},
34+
{Name: "ext1", Remote: true, RemoteRef: "ext1"},
3535
},
3636
want: []*extensions.Manifest{
37-
{Name: "ext1", Remote: true},
37+
{Name: "ext1", Remote: true, RemoteRef: "ext1"},
3838
},
3939
},
4040
{
4141
name: "single remote extension with version",
4242
args: []string{"boe", "gen-config", "--extension", "ext1:0.1.0"},
4343
manifests: []*extensions.Manifest{
44-
{Name: "ext1", Version: "0.1.0", Remote: true},
44+
{Name: "ext1", Version: "0.1.0", Remote: true, RemoteRef: "ext1:0.1.0"},
4545
},
4646
want: []*extensions.Manifest{
47-
{Name: "ext1", Version: "0.1.0", Remote: true},
47+
{Name: "ext1", Version: "0.1.0", Remote: true, RemoteRef: "ext1:0.1.0"},
4848
},
4949
},
5050
{
@@ -61,23 +61,23 @@ func TestExtensionPositionsSort(t *testing.T) {
6161
name: "local before remote",
6262
args: []string{"boe", "gen-config", "--local", localDir1, "--extension", "ext1"},
6363
manifests: []*extensions.Manifest{
64-
{Name: "ext1", Remote: true},
64+
{Name: "ext1", Remote: true, RemoteRef: "ext1"},
6565
{Name: "local1", Path: localDir1 + "/manifest.yaml"},
6666
},
6767
want: []*extensions.Manifest{
6868
{Name: "local1", Path: localDir1 + "/manifest.yaml"},
69-
{Name: "ext1", Remote: true},
69+
{Name: "ext1", Remote: true, RemoteRef: "ext1"},
7070
},
7171
},
7272
{
7373
name: "remote before local",
7474
args: []string{"boe", "gen-config", "--extension", "ext1", "--local", localDir1},
7575
manifests: []*extensions.Manifest{
76-
{Name: "ext1", Remote: true},
76+
{Name: "ext1", Remote: true, RemoteRef: "ext1"},
7777
{Name: "local1", Path: localDir1 + "/manifest.yaml"},
7878
},
7979
want: []*extensions.Manifest{
80-
{Name: "ext1", Remote: true},
80+
{Name: "ext1", Remote: true, RemoteRef: "ext1"},
8181
{Name: "local1", Path: localDir1 + "/manifest.yaml"},
8282
},
8383
},
@@ -93,19 +93,19 @@ func TestExtensionPositionsSort(t *testing.T) {
9393
"--local", localDir1,
9494
},
9595
manifests: []*extensions.Manifest{
96-
{Name: "ext1", Version: "1.0.0", Remote: true},
97-
{Name: "ext2", Remote: true},
98-
{Name: "ext1", Version: "1.0.0", Remote: true},
96+
{Name: "ext1", Version: "1.0.0", Remote: true, RemoteRef: "ext1"},
97+
{Name: "ext2", Remote: true, RemoteRef: "ext2"},
98+
{Name: "ext1", Version: "1.0.0", Remote: true, RemoteRef: "ext1:1.0.0"},
9999
{Name: "local1", Path: localDir1 + "/manifest.yaml"},
100100
{Name: "local2", Path: localDir2 + "/manifest.yaml"},
101101
{Name: "local1", Path: localDir1 + "/manifest.yaml"},
102102
},
103103
want: []*extensions.Manifest{
104-
{Name: "ext1", Version: "1.0.0", Remote: true},
104+
{Name: "ext1", Version: "1.0.0", Remote: true, RemoteRef: "ext1"},
105105
{Name: "local1", Path: localDir1 + "/manifest.yaml"},
106-
{Name: "ext2", Remote: true},
106+
{Name: "ext2", Remote: true, RemoteRef: "ext2"},
107107
{Name: "local2", Path: localDir2 + "/manifest.yaml"},
108-
{Name: "ext1", Version: "1.0.0", Remote: true},
108+
{Name: "ext1", Version: "1.0.0", Remote: true, RemoteRef: "ext1:1.0.0"},
109109
{Name: "local1", Path: localDir1 + "/manifest.yaml"},
110110
},
111111
},

cli/cmd/run.go

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ func adminAddressForLocal(port uint32) string {
240240
func downloadExtensions(ctx context.Context, downloader *extensions.Downloader, refs []string, build bool) ([]*extensions.Manifest, error) {
241241
downloaded := make([]*extensions.Manifest, 0, len(refs))
242242
for _, ext := range refs {
243-
name, tag := splitRef(ext)
243+
bundle, name, tag := splitRef(ext)
244244

245245
// The reserved name "goplugin-loader" is not a downloadable extension: it drives
246246
// the composer's goplugin-loader filter directly from the user-supplied --config.
@@ -251,15 +251,19 @@ func downloadExtensions(ctx context.Context, downloader *extensions.Downloader,
251251
if err != nil {
252252
return nil, err
253253
}
254+
manifest.Remote = true
255+
manifest.RemoteRef = ext
254256
downloaded = append(downloaded, manifest)
255257
continue
256258
}
257259

258260
_, _ = fmt.Fprintf(os.Stderr, "→ %sFetching %s...%s\n", internal.ANSIBold, name, internal.ANSIReset)
259-
artifact, err := downloader.DownloadExtension(ctx, name, tag)
261+
artifact, err := downloader.DownloadExtension(ctx, bundle, name, tag)
260262
if err != nil {
261263
return nil, err
262264
}
265+
artifact.ExtensionManifest.Remote = true
266+
artifact.ExtensionManifest.RemoteRef = ext
263267

264268
switch artifact.ArtifactType {
265269
case extensions.ArtifactBinary:
@@ -319,9 +323,6 @@ func resolveGoPluginLoader(ctx context.Context, downloader *extensions.Downloade
319323
Parent: extensions.ComposerBundle,
320324
Version: version,
321325
ComposerVersion: version,
322-
// Marked remote so extensionPositions.sort can match it by reference, like
323-
// other downloaded extensions (it is fetched from the registry as composer-lite).
324-
Remote: true,
325326
}
326327
manifest.ApplyDefaults()
327328
return manifest, nil
@@ -436,20 +437,34 @@ func resolveParent(ctx context.Context, downloader *extensions.Downloader, m *ex
436437
dl, err = downloader.DownloadComposer(ctx, version, extensions.ComposerArtifactLite)
437438
} else {
438439
version := cmp.Or(m.Version, "latest")
439-
dl, err = downloader.DownloadExtension(ctx, m.Parent, version)
440+
dl, err = downloader.DownloadExtension(ctx, m.Parent, m.Parent, version)
440441
}
441442
if err != nil {
442443
return nil, fmt.Errorf("downloading parent %s: %w", m.Parent, err)
443444
}
444445
return dl.Manifest, nil
445446
}
446447

447-
// extractTag extracts the tag from a full OCI reference.
448-
func splitRef(ref string) (repo string, tag string) {
448+
// splitRef splits an extension reference into bundle, extension name, and tag.
449+
// The format is [bundle/]extension[:tag]. If no bundle prefix is given, the extension
450+
// name is used as the bundle name (standalone extension). If no tag is given, "latest" is used.
451+
//
452+
// Examples:
453+
//
454+
// "composer/example-go:v1.0.0" → ("composer", "example-go", "v1.0.0")
455+
// "cors:1.0.0" → ("cors", "cors", "1.0.0")
456+
// "cors" → ("cors", "cors", "latest")
457+
func splitRef(ref string) (bundle string, extension string, tag string) {
458+
tag = "latest"
459+
name := ref
449460
if colonIdx := strings.LastIndex(ref, ":"); colonIdx != -1 {
450-
return ref[:colonIdx], ref[colonIdx+1:]
461+
name = ref[:colonIdx]
462+
tag = ref[colonIdx+1:]
451463
}
452-
return ref, "latest"
464+
if slashIdx := strings.Index(name, "/"); slashIdx != -1 {
465+
return name[:slashIdx], name[slashIdx+1:], tag
466+
}
467+
return name, name, tag
453468
}
454469

455470
// errInvalidManifest is returned when some extension is not compatible with the requested Envoy version.

cli/cmd/run_test.go

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -463,41 +463,54 @@ func TestRunInvalidEnvoyPath(t *testing.T) {
463463

464464
func TestSplitRef(t *testing.T) {
465465
tests := []struct {
466-
name string
467-
input string
468-
wantRepo string
469-
wantTag string
466+
name string
467+
input string
468+
wantBundle string
469+
wantExtension string
470+
wantTag string
470471
}{
471472
{
472-
name: "simple name without tag",
473-
input: "cors",
474-
wantRepo: "cors",
475-
wantTag: "latest",
473+
name: "simple name without tag",
474+
input: "cors",
475+
wantBundle: "cors",
476+
wantExtension: "cors",
477+
wantTag: "latest",
478+
},
479+
{
480+
name: "simple name with tag",
481+
input: "cors:1.0.0",
482+
wantBundle: "cors",
483+
wantExtension: "cors",
484+
wantTag: "1.0.0",
476485
},
477486
{
478-
name: "simple name with tag",
479-
input: "cors:1.0.0",
480-
wantRepo: "cors",
481-
wantTag: "1.0.0",
487+
name: "empty string",
488+
input: "",
489+
wantBundle: "",
490+
wantExtension: "",
491+
wantTag: "latest",
482492
},
483493
{
484-
name: "empty string",
485-
input: "",
486-
wantRepo: "",
487-
wantTag: "latest",
494+
name: "bundle prefixed name with tag",
495+
input: "composer/example-go:v1.0.0",
496+
wantBundle: "composer",
497+
wantExtension: "example-go",
498+
wantTag: "v1.0.0",
488499
},
489500
{
490-
name: "name with multiple colons takes last",
491-
input: "foo:bar:baz",
492-
wantRepo: "foo:bar",
493-
wantTag: "baz",
501+
name: "bundle prefixed name without tag",
502+
input: "composer/example-go",
503+
wantBundle: "composer",
504+
wantExtension: "example-go",
505+
wantTag: "latest",
494506
},
495507
}
496508

497509
for _, tt := range tests {
498510
t.Run(tt.name, func(t *testing.T) {
499-
repo, tag := splitRef(tt.input)
500-
require.Equal(t, tt.wantRepo, repo)
511+
bundle, extension, tag := splitRef(tt.input)
512+
require.Equal(t, tt.wantBundle, bundle)
513+
require.Equal(t, tt.wantExtension, extension)
501514
require.Equal(t, tt.wantTag, tag)
502515
})
503516
}

cli/e2e/run_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"net/http/httptest"
1414
"os"
1515
"os/exec"
16+
"regexp"
1617
"runtime"
1718
"strings"
1819
"testing"
@@ -148,6 +149,17 @@ func TestRustLocalExtension(t *testing.T) {
148149
require.NoError(t, err)
149150
require.Equal(t, 0, status.ExitCode())
150151

152+
// Overwrite the dependency in Cargo.toml to the stable version of Envoy 1.38 to
153+
// avoid potential compatibility issues make the CI flaky.
154+
// TODO(wbpcode): remove this if we get the dependency more stable.
155+
cargoTomlPath := dataDir + "/rust-e2e/Cargo.toml"
156+
// #nosec G304
157+
cargoToml, err := os.ReadFile(cargoTomlPath)
158+
require.NoError(t, err)
159+
cargoToml = regexp.MustCompile(`rev = "[0-9a-f]+"`).
160+
ReplaceAll(cargoToml, []byte(`rev = "f1dd21b16c244bda00edfb5ffce577e12d0d2ec2"`))
161+
require.NoError(t, os.WriteFile(cargoTomlPath, cargoToml, 0o600))
162+
151163
// Run the newly created extension
152164
ports := internaltesting.FreePorts(t, 2)
153165
proxyPort := ports[0]
@@ -397,6 +409,52 @@ func TestGoPluginLoaderRemoteExtension(t *testing.T) {
397409
})
398410
}
399411

412+
// TestComposerBundleExtension exercises the bundle-prefixed extension syntax end to end:
413+
// 1. build the full composer image (non-lite, single platform) and push it to the local test registry;
414+
// 2. run `boe run --extension composer/example-go` which downloads the full composer binary
415+
// artifact and resolves the example-go child extension manifest from within the bundle.
416+
func TestComposerBundleExtension(t *testing.T) {
417+
t.Setenv("TEST_BOE_RUN_ENVOY_TIMEOUT", "5m")
418+
419+
const composerDir = "../../extensions/composer"
420+
421+
manifests, err := extensions.LoadManifests(internaltesting.ExtensionsFS(t), ".", false)
422+
require.NoError(t, err)
423+
composer, ok := manifests[extensions.ComposerArtifact]
424+
require.True(t, ok)
425+
version := composer.Version
426+
427+
t.Setenv("BOE_REGISTRY", registryAddr)
428+
t.Setenv("BOE_REGISTRY_INSECURE", "true")
429+
t.Setenv("BOE_DATA_HOME", t.TempDir())
430+
431+
// Build and push the full composer image (single platform) to the local registry.
432+
runCmd(t, composerDir, "make", "push_image",
433+
"PLATFORMS=linux/"+runtime.GOARCH,
434+
"OCI_REGISTRY="+registryAddr,
435+
)
436+
437+
// Run boe with the bundle-prefixed extension reference: composer/example-go.
438+
// This downloads the full composer artifact keyed by the "composer" bundle name,
439+
// then resolves the "example-go" child extension manifest within it.
440+
ports := internaltesting.FreePorts(t, 2)
441+
proxyPort := ports[0]
442+
args := []string{
443+
"--log-level", "dynamic_modules:debug",
444+
"--extension", "composer/example-go:" + version,
445+
}
446+
if strings.HasSuffix(version, "-dev") {
447+
args = append(args, "--dev")
448+
}
449+
internaltesting.RunEnvoy(t, cliBin, proxyPort, ports[1], args...)
450+
451+
internaltesting.RequireEventuallyGet(t,
452+
fmt.Sprintf("http://localhost:%d/status/200", proxyPort),
453+
func(r *http.Response) bool {
454+
return r.Header.Get("x-example-response-header") == "example-value"
455+
})
456+
}
457+
400458
// runCmd runs name with args (in dir, if non-empty), logging the combined output and failing the
401459
// test on a non-zero exit.
402460
func runCmd(t *testing.T, dir, name string, args ...string) {

cli/internal/extensions/downloader.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,14 @@ func (d *Downloader) DownloadComposer(ctx context.Context, version string, artif
7272
}
7373

7474
// DownloadExtension downloads the extension from the specified repository and tag into the downloadDir.
75-
func (d *Downloader) DownloadExtension(ctx context.Context, name, version string) (DownloadedExtension, error) {
76-
d.Logger.Info("downloading extension", "repository", d.Registry, "name", name, "version", version)
77-
repository := RepositoryName(d.Registry, name)
75+
// The bundle parameter is used to construct the OCI repository name for downloading the artifact.
76+
// The name parameter is used to resolve the specific extension manifest within the downloaded artifact.
77+
// For standalone extensions, bundle and name are the same. For bundled extensions (e.g. "composer/example-go"),
78+
// the bundle is the parent artifact and the name is the child whose manifest must have a Parent field
79+
// matching the bundle name.
80+
func (d *Downloader) DownloadExtension(ctx context.Context, bundle, name, version string) (DownloadedExtension, error) {
81+
d.Logger.Info("downloading extension", "repository", d.Registry, "bundle", bundle, "extension", name, "version", version)
82+
repository := RepositoryName(d.Registry, bundle)
7883

7984
artifact, err := d.download(ctx, repository, version, func(manifest *ocispec.Manifest) string {
8085
extensionManifest := ManifestFromOCI(manifest)
@@ -96,6 +101,21 @@ func (d *Downloader) DownloadExtension(ctx context.Context, name, version string
96101
if err != nil {
97102
return DownloadedExtension{}, fmt.Errorf("failed to resolve extension manifest for %q: %w", name, err)
98103
}
104+
105+
// For bundled extensions (bundle != name), validate that the resolved extension
106+
// declares the bundle as its parent.
107+
if bundle != name {
108+
if extensionManifest.Parent == "" {
109+
return DownloadedExtension{},
110+
fmt.Errorf("extension %q in bundle %q has no parent set in its manifest", name, bundle)
111+
}
112+
if extensionManifest.Parent != bundle {
113+
return DownloadedExtension{},
114+
fmt.Errorf("extension %q declares parent %q but was requested from bundle %q",
115+
name, extensionManifest.Parent, bundle)
116+
}
117+
}
118+
99119
extensionManifest.Remote = true
100120
artifact.ExtensionManifest = extensionManifest
101121
return artifact, nil

0 commit comments

Comments
 (0)