Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion demo/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ require (
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/docker/cli v28.2.2+incompatible // indirect
github.com/docker/cli v29.2.0+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/docker/go-units v0.5.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions demo/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwu
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A=
github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM=
github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
Expand Down
4 changes: 2 additions & 2 deletions router-tests/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/buger/jsonparser v1.1.1
github.com/cloudflare/backoff v0.0.0-20240920015135-e46b80a3a7d0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/go-containerregistry v0.20.3
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1
github.com/hashicorp/go-cleanhttp v0.5.2
Expand Down Expand Up @@ -66,7 +67,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/docker/cli v28.2.2+incompatible // indirect
github.com/docker/cli v29.2.0+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/docker/go-units v0.5.0 // indirect
Expand All @@ -88,7 +89,6 @@ require (
github.com/goccy/go-yaml v1.17.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-containerregistry v0.20.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
Expand Down
4 changes: 2 additions & 2 deletions router-tests/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7c
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A=
github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM=
github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
Expand Down
145 changes: 145 additions & 0 deletions router-tests/oci_test_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package integration

import (
"archive/tar"
"bytes"
"fmt"
"io"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/google/go-containerregistry/pkg/crane"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/partial"
"github.com/google/go-containerregistry/pkg/v1/tarball"
"github.com/google/go-containerregistry/pkg/v1/types"
"github.com/stretchr/testify/require"
)

// startTestOCIRegistry starts an in-memory OCI registry on localhost and returns the host:port.
func startTestOCIRegistry(t *testing.T) string {
t.Helper()
reg := registry.New()
server := httptest.NewServer(reg)
t.Cleanup(server.Close)
return strings.TrimPrefix(server.URL, "http://")
}

// buildAndPushPluginImage reads a plugin binary (and any adjacent files in its directory),
// wraps them in an OCI image, and pushes it to the test registry.
// The binary is placed at /plugin in the image with the entrypoint set to ["/plugin"].
// Any sibling files/directories next to the binary are included at the same relative paths.
func buildAndPushPluginImage(t *testing.T, registryHost, repo, tag, pluginBinaryPath string) {
t.Helper()

pluginDir := filepath.Dir(pluginBinaryPath)
binaryName := filepath.Base(pluginBinaryPath)

var buf bytes.Buffer
tw := tar.NewWriter(&buf)

err := filepath.Walk(pluginDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}

relPath, err := filepath.Rel(pluginDir, path)
if err != nil {
return err
}

// Skip the root directory itself
if relPath == "." {
return nil
}

// Rename the binary to "plugin"
tarPath := relPath
if relPath == binaryName {
tarPath = "plugin"
}

header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = tarPath

if err := tw.WriteHeader(header); err != nil {
return err
}

if !info.IsDir() {
data, err := os.ReadFile(path)
if err != nil {
return err
}
if _, err := tw.Write(data); err != nil {
return err
}
}

return nil
})
require.NoError(t, err)
require.NoError(t, tw.Close())

layerBytes := buf.Bytes()
layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(layerBytes)), nil
})
require.NoError(t, err)

img, err := mutate.AppendLayers(empty.Image, layer)
require.NoError(t, err)

cfgFile, err := img.ConfigFile()
require.NoError(t, err)
cfgFile.Config.Entrypoint = []string{"/plugin"}
cfgFile.OS = runtime.GOOS
cfgFile.Architecture = runtime.GOARCH
img, err = mutate.ConfigFile(img, cfgFile)
require.NoError(t, err)

img = &ociImage{img}

ref := fmt.Sprintf("%s/%s:%s", registryHost, repo, tag)
nameRef, err := name.ParseReference(ref)
require.NoError(t, err)
err = crane.Push(img, nameRef.String(), crane.Insecure)
require.NoError(t, err, "pushing image to test registry")
}

// ociImage wraps a v1.Image to force OCI media types.
type ociImage struct {
v1.Image
}

func (i *ociImage) MediaType() (types.MediaType, error) {
return types.OCIManifestSchema1, nil
}

func (i *ociImage) Digest() (v1.Hash, error) {
return partial.Digest(i)
}

func (i *ociImage) Manifest() (*v1.Manifest, error) {
m, err := i.Image.Manifest()
if err != nil {
return nil, err
}
m.MediaType = types.OCIManifestSchema1
return m, nil
}

func (i *ociImage) RawManifest() ([]byte, error) {
return partial.RawManifest(i)
}
108 changes: 108 additions & 0 deletions router-tests/router_oci_plugin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package integration

import (
"fmt"
"runtime"
"slices"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"

"github.com/wundergraph/cosmo/router-tests/testenv"
)

func TestOCIPlugin_PullAndRun(t *testing.T) {
t.Parallel()

registryHost := startTestOCIRegistry(t)

projectsBinary := fmt.Sprintf("../router/plugins/projects/bin/%s_%s", runtime.GOOS, runtime.GOARCH)
coursesBinary := fmt.Sprintf("../router/plugins/courses/bin/%s_%s", runtime.GOOS, runtime.GOARCH)

buildAndPushPluginImage(t, registryHost, "test-org/projects", "v1", projectsBinary)
buildAndPushPluginImage(t, registryHost, "test-org/courses", "v1", coursesBinary)

testenv.Run(t, &testenv.Config{
RouterConfigJSONTemplate: testenv.ConfigWithOCIPluginsJSONTemplate,
Plugins: testenv.PluginConfig{
Enabled: true,
RegistryURL: registryHost,
},
}, func(t *testing.T, xEnv *testenv.Environment) {
response := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `query { projects { id name } }`,
})
require.Equal(t, `{"data":{"projects":[{"id":"1","name":"Cloud Migration Overhaul"},{"id":"2","name":"Microservices Revolution"},{"id":"3","name":"AI-Powered Analytics"},{"id":"4","name":"DevOps Transformation"},{"id":"5","name":"Security Overhaul"},{"id":"6","name":"Mobile App Development"},{"id":"7","name":"Data Lake Implementation"}]}}`, response.Body)

response = xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `query { courses { id title description } }`,
})
require.Equal(t, `{"data":{"courses":[{"id":"1","title":"Introduction to TypeScript","description":"Learn the basics of TypeScript"},{"id":"2","title":"Advanced GraphQL","description":"Master GraphQL federation"},{"id":"3","title":"Go Programming","description":"Build services with Go"}]}}`, response.Body)
})
}

func TestOCIPlugin_ImageNotFound(t *testing.T) {
t.Parallel()

registryHost := startTestOCIRegistry(t)
// Don't push any images — registry is empty

testenv.FailsOnStartup(t, &testenv.Config{
RouterConfigJSONTemplate: testenv.ConfigWithOCIPluginsJSONTemplate,
Plugins: testenv.PluginConfig{
Enabled: true,
RegistryURL: registryHost,
},
}, func(t *testing.T, err error) {
require.ErrorContains(t, err, "pulling image")
})
}

func TestOCIPlugin_Restart(t *testing.T) {
t.Parallel()

registryHost := startTestOCIRegistry(t)

projectsBinary := fmt.Sprintf("../router/plugins/projects/bin/%s_%s", runtime.GOOS, runtime.GOARCH)
coursesBinary := fmt.Sprintf("../router/plugins/courses/bin/%s_%s", runtime.GOOS, runtime.GOARCH)

buildAndPushPluginImage(t, registryHost, "test-org/projects", "v1", projectsBinary)
buildAndPushPluginImage(t, registryHost, "test-org/courses", "v1", coursesBinary)

testenv.Run(t, &testenv.Config{
RouterConfigJSONTemplate: testenv.ConfigWithOCIPluginsJSONTemplate,
LogObservation: testenv.LogObservationConfig{
Enabled: true,
LogLevel: zapcore.ErrorLevel,
},
Plugins: testenv.PluginConfig{
Enabled: true,
RegistryURL: registryHost,
},
}, func(t *testing.T, xEnv *testenv.Environment) {
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `query { killService }`,
})

require.EventuallyWithT(t, func(c *assert.CollectT) {
logMessages := xEnv.Observer().All()
require.True(c, slices.ContainsFunc(logMessages, func(msg observer.LoggedEntry) bool {
return strings.Contains(msg.Message, "plugin process exited")
}), "expected to find 'plugin process exited' message in logs")
}, 5*time.Second, 1*time.Second)

require.EventuallyWithT(t, func(c *assert.CollectT) {
response, err := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{
Query: `query { projects { id name } }`,
})
require.NoError(c, err)
require.Equal(c, 200, response.Response.StatusCode)
require.Equal(c, `{"data":{"projects":[{"id":"1","name":"Cloud Migration Overhaul"},{"id":"2","name":"Microservices Revolution"},{"id":"3","name":"AI-Powered Analytics"},{"id":"4","name":"DevOps Transformation"},{"id":"5","name":"Security Overhaul"},{"id":"6","name":"Mobile App Development"},{"id":"7","name":"Data Lake Implementation"}]}}`, response.Body)
}, 20*time.Second, 2*time.Second)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
}
Loading
Loading