Add http-wasm plugin support to Traefik

This commit is contained in:
Jesse Haka 2023-11-30 22:42:06 +02:00 committed by GitHub
parent b2bb96390a
commit 6858dbdd07
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 338 additions and 119 deletions

2
go.mod
View file

@ -32,6 +32,7 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.4
github.com/hashicorp/go-version v1.6.0
github.com/hashicorp/nomad/api v0.0.0-20220506174431-b5665129cd1f
github.com/http-wasm/http-wasm-host-go v0.5.2
github.com/influxdata/influxdb-client-go/v2 v2.7.0
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d
github.com/instana/go-sensor v1.38.3
@ -62,6 +63,7 @@ require (
github.com/stretchr/testify v1.8.4
github.com/stvp/go-udp-testing v0.0.0-20191102171040-06b61409b154
github.com/tailscale/tscert v0.0.0-20220316030059-54bbcb9f74e2
github.com/tetratelabs/wazero v1.5.0
github.com/traefik/grpc-web v0.16.0
github.com/traefik/paerser v0.2.0
github.com/traefik/yaegi v0.15.1

4
go.sum
View file

@ -1085,6 +1085,8 @@ github.com/hashicorp/uuid v0.0.0-20160311170451-ebb0a03e909c/go.mod h1:fHzc09Uny
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/http-wasm/http-wasm-host-go v0.5.2 h1:5d/QgaaJtTF+qd0goBaxJJ7tcHP9n+gQUldJ7TsTexA=
github.com/http-wasm/http-wasm-host-go v0.5.2/go.mod h1:zQB3w+df4hryDEqBorGyA1DwPJ86LfKIASNLFuj6CuI=
github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
@ -1807,6 +1809,8 @@ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.490 h1:mmz2
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.490/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.490 h1:g9SWTaTy/rEuhMErC2jWq9Qt5ci+jBYSvXnJsLq4adg=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.490/go.mod h1:l9q4vc1QiawUB1m3RU+87yLvrrxe54jc0w/kEl4DbSQ=
github.com/tetratelabs/wazero v1.5.0 h1:Yz3fZHivfDiZFUXnWMPUoiW7s8tC1sjdBtlJn08qYa0=
github.com/tetratelabs/wazero v1.5.0/go.mod h1:0U0G41+ochRKoPKCJlh0jMg1CHkyfK8kDqiirMmKY8A=
github.com/theupdateframework/notary v0.6.1 h1:7wshjstgS9x9F5LuB1L5mBI2xNMObWqjz+cjWoom6l0=
github.com/theupdateframework/notary v0.6.1/go.mod h1:MOfgIfmox8s7/7fduvB2xyPPMJCrjRLRizA8OFwpnKY=
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=

32
pkg/logs/wasm.go Normal file
View file

@ -0,0 +1,32 @@
package logs
import (
"context"
"github.com/http-wasm/http-wasm-host-go/api"
"github.com/rs/zerolog"
)
// compile-time check to ensure ConsoleLogger implements api.Logger.
var _ api.Logger = WasmLogger{}
// WasmLogger is a convenience which writes anything above LogLevelInfo to os.Stdout.
type WasmLogger struct {
logger *zerolog.Logger
}
func NewWasmLogger(logger *zerolog.Logger) *WasmLogger {
return &WasmLogger{
logger: logger,
}
}
// IsEnabled implements the same method as documented on api.Logger.
func (w WasmLogger) IsEnabled(level api.LogLevel) bool {
return true
}
// Log implements the same method as documented on api.Logger.
func (w WasmLogger) Log(_ context.Context, level api.LogLevel, message string) {
w.logger.WithLevel(zerolog.Level(level + 1)).Msg(message)
}

View file

@ -4,28 +4,34 @@ import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/traefik/traefik/v3/pkg/logs"
"github.com/traefik/yaegi/interp"
"github.com/traefik/yaegi/stdlib"
)
// Constructor creates a plugin handler.
type Constructor func(context.Context, http.Handler) (http.Handler, error)
type pluginMiddleware interface {
NewHandler(ctx context.Context, next http.Handler) (http.Handler, error)
}
type middlewareBuilder interface {
newMiddleware(config map[string]interface{}, middlewareName string) (pluginMiddleware, error)
}
// Builder is a plugin builder.
type Builder struct {
middlewareBuilders map[string]*middlewareBuilder
providerBuilders map[string]providerBuilder
middlewareBuilders map[string]middlewareBuilder
}
// NewBuilder creates a new Builder.
func NewBuilder(client *Client, plugins map[string]Descriptor, localPlugins map[string]LocalDescriptor) (*Builder, error) {
ctx := context.Background()
pb := &Builder{
middlewareBuilders: map[string]*middlewareBuilder{},
middlewareBuilders: map[string]middlewareBuilder{},
providerBuilders: map[string]providerBuilder{},
}
@ -36,44 +42,30 @@ func NewBuilder(client *Client, plugins map[string]Descriptor, localPlugins map[
return nil, fmt.Errorf("%s: failed to read manifest: %w", desc.ModuleName, err)
}
logger := log.With().Str("plugin", "plugin-"+pName).Str("module", desc.ModuleName).Logger()
i := interp.New(interp.Options{
GoPath: client.GoPath(),
Env: os.Environ(),
Stdout: logs.NoLevel(logger, zerolog.DebugLevel),
Stderr: logs.NoLevel(logger, zerolog.ErrorLevel),
})
err = i.Use(stdlib.Symbols)
if err != nil {
return nil, fmt.Errorf("%s: failed to load symbols: %w", desc.ModuleName, err)
}
err = i.Use(ppSymbols())
if err != nil {
return nil, fmt.Errorf("%s: failed to load provider symbols: %w", desc.ModuleName, err)
}
_, err = i.Eval(fmt.Sprintf(`import "%s"`, manifest.Import))
if err != nil {
return nil, fmt.Errorf("%s: failed to import plugin code %q: %w", desc.ModuleName, manifest.Import, err)
}
logger := log.With().
Str("plugin", "plugin-"+pName).
Str("module", desc.ModuleName).
Str("runtime", manifest.Runtime).
Logger()
logCtx := logger.WithContext(ctx)
switch manifest.Type {
case "middleware":
middleware, err := newMiddlewareBuilder(i, manifest.BasePkg, manifest.Import)
case typeMiddleware:
middleware, err := newMiddlewareBuilder(logCtx, client.GoPath(), manifest, desc.ModuleName)
if err != nil {
return nil, err
}
pb.middlewareBuilders[pName] = middleware
case "provider":
pb.providerBuilders[pName] = providerBuilder{
interpreter: i,
Import: manifest.Import,
BasePkg: manifest.BasePkg,
case typeProvider:
pBuilder, err := newProviderBuilder(logCtx, manifest, client.GoPath())
if err != nil {
return nil, fmt.Errorf("%s: %w", desc.ModuleName, err)
}
pb.providerBuilders[pName] = pBuilder
default:
return nil, fmt.Errorf("unknow plugin type: %s", manifest.Type)
}
@ -85,48 +77,107 @@ func NewBuilder(client *Client, plugins map[string]Descriptor, localPlugins map[
return nil, fmt.Errorf("%s: failed to read manifest: %w", desc.ModuleName, err)
}
logger := log.With().Str("plugin", "plugin-"+pName).Str("module", desc.ModuleName).Logger()
i := interp.New(interp.Options{
GoPath: localGoPath,
Env: os.Environ(),
Stdout: logs.NoLevel(logger, zerolog.DebugLevel),
Stderr: logs.NoLevel(logger, zerolog.ErrorLevel),
})
err = i.Use(stdlib.Symbols)
if err != nil {
return nil, fmt.Errorf("%s: failed to load symbols: %w", desc.ModuleName, err)
}
err = i.Use(ppSymbols())
if err != nil {
return nil, fmt.Errorf("%s: failed to load provider symbols: %w", desc.ModuleName, err)
}
_, err = i.Eval(fmt.Sprintf(`import "%s"`, manifest.Import))
if err != nil {
return nil, fmt.Errorf("%s: failed to import plugin code %q: %w", desc.ModuleName, manifest.Import, err)
}
logger := log.With().
Str("plugin", "plugin-"+pName).
Str("module", desc.ModuleName).
Str("runtime", manifest.Runtime).
Logger()
logCtx := logger.WithContext(ctx)
switch manifest.Type {
case "middleware":
middleware, err := newMiddlewareBuilder(i, manifest.BasePkg, manifest.Import)
case typeMiddleware:
middleware, err := newMiddlewareBuilder(logCtx, localGoPath, manifest, desc.ModuleName)
if err != nil {
return nil, err
}
pb.middlewareBuilders[pName] = middleware
case "provider":
pb.providerBuilders[pName] = providerBuilder{
interpreter: i,
Import: manifest.Import,
BasePkg: manifest.BasePkg,
case typeProvider:
builder, err := newProviderBuilder(logCtx, manifest, localGoPath)
if err != nil {
return nil, fmt.Errorf("%s: %w", desc.ModuleName, err)
}
pb.providerBuilders[pName] = builder
default:
return nil, fmt.Errorf("unknow plugin type: %s", manifest.Type)
}
}
return pb, nil
}
// Build builds a middleware plugin.
func (b Builder) Build(pName string, config map[string]interface{}, middlewareName string) (Constructor, error) {
if b.middlewareBuilders == nil {
return nil, fmt.Errorf("no plugin definitions in the static configuration: %s", pName)
}
// plugin (pName) can be located in yaegi or wasm middleware builders.
if descriptor, ok := b.middlewareBuilders[pName]; ok {
m, err := descriptor.newMiddleware(config, middlewareName)
if err != nil {
return nil, err
}
return m.NewHandler, nil
}
return nil, fmt.Errorf("unknown plugin type: %s", pName)
}
func newMiddlewareBuilder(ctx context.Context, goPath string, manifest *Manifest, moduleName string) (middlewareBuilder, error) {
switch manifest.Runtime {
case runtimeWasm:
wasmPath, err := getWasmPath(manifest)
if err != nil {
return nil, fmt.Errorf("wasm path: %w", err)
}
return newWasmMiddlewareBuilder(goPath, moduleName, wasmPath), nil
case runtimeYaegi, "":
i, err := newInterpreter(ctx, goPath, manifest.Import)
if err != nil {
return nil, fmt.Errorf("failed to craete Yaegi intepreter: %w", err)
}
return newYaegiMiddlewareBuilder(i, manifest.BasePkg, manifest.Import)
default:
return nil, fmt.Errorf("unknown plugin runtime: %s", manifest.Runtime)
}
}
func newProviderBuilder(ctx context.Context, manifest *Manifest, goPath string) (providerBuilder, error) {
switch manifest.Runtime {
case runtimeYaegi, "":
i, err := newInterpreter(ctx, goPath, manifest.Import)
if err != nil {
return providerBuilder{}, err
}
return providerBuilder{
interpreter: i,
Import: manifest.Import,
BasePkg: manifest.BasePkg,
}, nil
default:
return providerBuilder{}, fmt.Errorf("unknown plugin runtime: %s", manifest.Runtime)
}
}
func getWasmPath(manifest *Manifest) (string, error) {
wasmPath := manifest.WasmPath
if wasmPath == "" {
wasmPath = "plugin.wasm"
}
if !filepath.IsLocal(wasmPath) {
return "", fmt.Errorf("wasmPath must be a local path")
}
return wasmPath, nil
}

View file

@ -279,7 +279,15 @@ func unzipFile(f *zipa.File, dest string) error {
defer func() { _ = rc.Close() }()
pathParts := strings.SplitN(f.Name, "/", 2)
p := filepath.Join(dest, pathParts[1])
var pp string
if len(pathParts) < 2 {
pp = pathParts[0]
} else {
pp = pathParts[1]
}
p := filepath.Join(dest, pp)
if f.FileInfo().IsDir() {
err = os.MkdirAll(p, f.Mode())

View file

@ -0,0 +1,81 @@
package plugins
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"reflect"
"github.com/http-wasm/http-wasm-host-go/handler"
wasm "github.com/http-wasm/http-wasm-host-go/handler/nethttp"
"github.com/tetratelabs/wazero"
"github.com/traefik/traefik/v3/pkg/logs"
"github.com/traefik/traefik/v3/pkg/middlewares"
)
type wasmMiddlewareBuilder struct {
path string
}
func newWasmMiddlewareBuilder(goPath string, moduleName, wasmPath string) *wasmMiddlewareBuilder {
return &wasmMiddlewareBuilder{path: filepath.Join(goPath, "src", moduleName, wasmPath)}
}
func (b wasmMiddlewareBuilder) newMiddleware(config map[string]interface{}, middlewareName string) (pluginMiddleware, error) {
return &WasmMiddleware{
middlewareName: middlewareName,
config: reflect.ValueOf(config),
builder: b,
}, nil
}
func (b wasmMiddlewareBuilder) newHandler(ctx context.Context, next http.Handler, cfg reflect.Value, middlewareName string) (http.Handler, error) {
code, err := os.ReadFile(b.path)
if err != nil {
return nil, fmt.Errorf("loading Wasm binary: %w", err)
}
logger := middlewares.GetLogger(ctx, middlewareName, "wasm")
opts := []handler.Option{
handler.ModuleConfig(wazero.NewModuleConfig().WithSysWalltime()),
handler.Logger(logs.NewWasmLogger(logger)),
}
i := cfg.Interface()
if i != nil {
config, ok := i.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("could not type assert config: %T", i)
}
data, err := json.Marshal(config)
if err != nil {
return nil, fmt.Errorf("marshaling config: %w", err)
}
opts = append(opts, handler.GuestConfig(data))
}
mw, err := wasm.NewMiddleware(context.Background(), code, opts...)
if err != nil {
return nil, err
}
return mw.NewHandler(ctx, next), nil
}
// WasmMiddleware is an HTTP handler plugin wrapper.
type WasmMiddleware struct {
middlewareName string
config reflect.Value
builder wasmMiddlewareBuilder
}
// NewHandler creates a new HTTP handler.
func (m WasmMiddleware) NewHandler(ctx context.Context, next http.Handler) (http.Handler, error) {
return m.builder.newHandler(ctx, next, m.config, m.middlewareName)
}

View file

@ -4,39 +4,25 @@ import (
"context"
"fmt"
"net/http"
"os"
"path"
"reflect"
"strings"
"github.com/mitchellh/mapstructure"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/traefik/traefik/v3/pkg/logs"
"github.com/traefik/yaegi/interp"
"github.com/traefik/yaegi/stdlib"
)
// Build builds a middleware plugin.
func (b Builder) Build(pName string, config map[string]interface{}, middlewareName string) (Constructor, error) {
if b.middlewareBuilders == nil {
return nil, fmt.Errorf("no plugin definition in the static configuration: %s", pName)
}
descriptor, ok := b.middlewareBuilders[pName]
if !ok {
return nil, fmt.Errorf("unknown plugin type: %s", pName)
}
m, err := newMiddleware(descriptor, config, middlewareName)
if err != nil {
return nil, err
}
return m.NewHandler, err
}
type middlewareBuilder struct {
type yaegiMiddlewareBuilder struct {
fnNew reflect.Value
fnCreateConfig reflect.Value
}
func newMiddlewareBuilder(i *interp.Interpreter, basePkg, imp string) (*middlewareBuilder, error) {
func newYaegiMiddlewareBuilder(i *interp.Interpreter, basePkg, imp string) (*yaegiMiddlewareBuilder, error) {
if basePkg == "" {
basePkg = strings.ReplaceAll(path.Base(imp), "-", "_")
}
@ -51,21 +37,35 @@ func newMiddlewareBuilder(i *interp.Interpreter, basePkg, imp string) (*middlewa
return nil, fmt.Errorf("failed to eval CreateConfig: %w", err)
}
return &middlewareBuilder{
return &yaegiMiddlewareBuilder{
fnNew: fnNew,
fnCreateConfig: fnCreateConfig,
}, nil
}
func (p middlewareBuilder) newHandler(ctx context.Context, next http.Handler, cfg reflect.Value, middlewareName string) (http.Handler, error) {
func (b yaegiMiddlewareBuilder) newMiddleware(config map[string]interface{}, middlewareName string) (pluginMiddleware, error) {
vConfig, err := b.createConfig(config)
if err != nil {
return nil, err
}
return &YaegiMiddleware{
middlewareName: middlewareName,
config: vConfig,
builder: b,
}, nil
}
func (b yaegiMiddlewareBuilder) newHandler(ctx context.Context, next http.Handler, cfg reflect.Value, middlewareName string) (http.Handler, error) {
args := []reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(next), cfg, reflect.ValueOf(middlewareName)}
results := p.fnNew.Call(args)
results := b.fnNew.Call(args)
if len(results) > 1 && results[1].Interface() != nil {
err, ok := results[1].Interface().(error)
if !ok {
return nil, fmt.Errorf("invalid error type: %T", results[0].Interface())
}
return nil, err
}
@ -77,8 +77,8 @@ func (p middlewareBuilder) newHandler(ctx context.Context, next http.Handler, cf
return handler, nil
}
func (p middlewareBuilder) createConfig(config map[string]interface{}) (reflect.Value, error) {
results := p.fnCreateConfig.Call(nil)
func (b yaegiMiddlewareBuilder) createConfig(config map[string]interface{}) (reflect.Value, error) {
results := b.fnCreateConfig.Call(nil)
if len(results) != 1 {
return reflect.Value{}, fmt.Errorf("invalid number of return for the CreateConfig function: %d", len(results))
}
@ -107,27 +107,40 @@ func (p middlewareBuilder) createConfig(config map[string]interface{}) (reflect.
return vConfig, nil
}
// Middleware is an HTTP handler plugin wrapper.
type Middleware struct {
// YaegiMiddleware is an HTTP handler plugin wrapper.
type YaegiMiddleware struct {
middlewareName string
config reflect.Value
builder *middlewareBuilder
}
func newMiddleware(builder *middlewareBuilder, config map[string]interface{}, middlewareName string) (*Middleware, error) {
vConfig, err := builder.createConfig(config)
if err != nil {
return nil, err
}
return &Middleware{
middlewareName: middlewareName,
config: vConfig,
builder: builder,
}, nil
builder yaegiMiddlewareBuilder
}
// NewHandler creates a new HTTP handler.
func (m *Middleware) NewHandler(ctx context.Context, next http.Handler) (http.Handler, error) {
func (m *YaegiMiddleware) NewHandler(ctx context.Context, next http.Handler) (http.Handler, error) {
return m.builder.newHandler(ctx, next, m.config, m.middlewareName)
}
func newInterpreter(ctx context.Context, goPath string, manifestImport string) (*interp.Interpreter, error) {
i := interp.New(interp.Options{
GoPath: goPath,
Env: os.Environ(),
Stdout: logs.NoLevel(*log.Ctx(ctx), zerolog.DebugLevel),
Stderr: logs.NoLevel(*log.Ctx(ctx), zerolog.ErrorLevel),
})
err := i.Use(stdlib.Symbols)
if err != nil {
return nil, fmt.Errorf("failed to load symbols: %w", err)
}
err = i.Use(ppSymbols())
if err != nil {
return nil, fmt.Errorf("failed to load provider symbols: %w", err)
}
_, err = i.Eval(fmt.Sprintf(`import "%s"`, manifestImport))
if err != nil {
return nil, fmt.Errorf("failed to import plugin code %q: %w", manifestImport, err)
}
return i, nil
}

View file

@ -138,18 +138,28 @@ func checkLocalPluginManifest(descriptor LocalDescriptor) error {
var errs *multierror.Error
switch m.Type {
case "middleware", "provider":
// noop
case typeMiddleware:
if m.Runtime != runtimeYaegi && m.Runtime != runtimeWasm && m.Runtime != "" {
errs = multierror.Append(errs, fmt.Errorf("%s: unsupported runtime '%q'", descriptor.ModuleName, m.Runtime))
}
case typeProvider:
if m.Runtime != runtimeYaegi && m.Runtime != "" {
errs = multierror.Append(errs, fmt.Errorf("%s: unsupported runtime '%q'", descriptor.ModuleName, m.Runtime))
}
default:
errs = multierror.Append(errs, fmt.Errorf("%s: unsupported type %q", descriptor.ModuleName, m.Type))
}
if m.Import == "" {
errs = multierror.Append(errs, fmt.Errorf("%s: missing import", descriptor.ModuleName))
}
if m.IsYaegiPlugin() {
if m.Import == "" {
errs = multierror.Append(errs, fmt.Errorf("%s: missing import", descriptor.ModuleName))
}
if !strings.HasPrefix(m.Import, descriptor.ModuleName) {
errs = multierror.Append(errs, fmt.Errorf("the import %q must be related to the module name %q", m.Import, descriptor.ModuleName))
if !strings.HasPrefix(m.Import, descriptor.ModuleName) {
errs = multierror.Append(errs, fmt.Errorf("the import %q must be related to the module name %q", m.Import, descriptor.ModuleName))
}
}
if m.DisplayName == "" {

View file

@ -1,5 +1,15 @@
package plugins
const (
runtimeYaegi = "yaegi"
runtimeWasm = "wasm"
)
const (
typeMiddleware = "middleware"
typeProvider = "provider"
)
// Descriptor The static part of a plugin configuration.
type Descriptor struct {
// ModuleName (required)
@ -19,9 +29,17 @@ type LocalDescriptor struct {
type Manifest struct {
DisplayName string `yaml:"displayName"`
Type string `yaml:"type"`
Runtime string `yaml:"runtime"`
WasmPath string `yaml:"wasmPath"`
Import string `yaml:"import"`
BasePkg string `yaml:"basePkg"`
Compatibility string `yaml:"compatibility"`
Summary string `yaml:"summary"`
TestData map[string]interface{} `yaml:"testData"`
}
// IsYaegiPlugin returns true if the plugin is a Yaegi plugin.
func (m *Manifest) IsYaegiPlugin() bool {
// defaults always Yaegi to have backwards compatibility to plugins without runtime
return m.Runtime == runtimeYaegi || m.Runtime == ""
}