Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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)
})
}
Loading
Loading