diff --git a/filters/filters.go b/filters/filters.go index a43c4b19e4..519a4f7931 100644 --- a/filters/filters.go +++ b/filters/filters.go @@ -352,6 +352,7 @@ const ( OpaServeResponseName = "opaServeResponse" OpaServeResponseWithReqBodyName = "opaServeResponseWithReqBody" TLSName = "tlsPassClientCertificates" + WASMName = "wasm" // Undocumented filters HealthCheckName = "healthcheck" diff --git a/filters/wasm/testdata/add.go b/filters/wasm/testdata/add.go new file mode 100644 index 0000000000..4d4d0e5ecd --- /dev/null +++ b/filters/wasm/testdata/add.go @@ -0,0 +1,15 @@ +package main + +//export request +func request(x, y uint32) uint32 { + return x + y +} + +//export response +func response(x, y uint32) uint32 { + return x - y +} + +// main is required for the `wasi` target, even if it isn't used. +// See https://wazero.io/languages/tinygo/#why-do-i-have-to-define-main +func main() {} diff --git a/filters/wasm/testdata/add.wasm b/filters/wasm/testdata/add.wasm new file mode 100755 index 0000000000..f8232bf422 Binary files /dev/null and b/filters/wasm/testdata/add.wasm differ diff --git a/filters/wasm/wasm.go b/filters/wasm/wasm.go new file mode 100644 index 0000000000..7a57c98569 --- /dev/null +++ b/filters/wasm/wasm.go @@ -0,0 +1,217 @@ +package wasm + +import ( + "context" + "fmt" + "net/url" + "os" + + log "github.com/sirupsen/logrus" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" + "github.com/zalando/skipper/filters" +) + +type WASMOpts struct { + Typ string + CacheDir string +} + +const ( + memoryLimitPages uint32 = 8 // 8*2^16 +) + +type cache int + +type compilationCache cache + +const ( + none cache = iota + 1 + inmemory + filesystem +) + +type wasmSpec struct { + typ cache + cacheDir string +} + +// TODO(sszuecs): think about: +// +// 1) If we want to provide internal Go functions to support our wasm +// modules, we can use +// https://pkg.go.dev/github.com/tetratelabs/wazero#HostFunctionBuilder, +// such that WASM binary can import and use these functions. +// see also https://pkg.go.dev/github.com/tetratelabs/wazero#HostModuleBuilder +type wasm struct { + code []byte + runtime wazero.Runtime + mod api.Module + request api.Function + response api.Function +} + +func NewWASM(o WASMOpts) filters.Spec { + typ := none + switch o.Typ { + case "none": + typ = none + case "in-memory": + typ = inmemory + case "fs": + typ = filesystem + default: + log.Errorf("Failed to find compilation cache type %q, available values 'none', 'in-memory' and 'fs'", typ) + } + + return &wasmSpec{ + typ: typ, + cacheDir: o.CacheDir, + } +} + +// Name implements filters.Spec. +func (*wasmSpec) Name() string { + return filters.WASMName +} + +// CreateFilter implements filters.Spec. +func (ws *wasmSpec) CreateFilter(args []interface{}) (filters.Filter, error) { + if len(args) != 1 { + return nil, filters.ErrInvalidFilterParameters + } + + src, ok := args[0].(string) + if !ok { + return nil, filters.ErrInvalidFilterParameters + } + u, err := url.Parse(src) + if err != nil { + return nil, filters.ErrInvalidFilterParameters + } + + var code []byte + + switch u.Scheme { + case "file": + code, err = os.ReadFile(u.Path) + if err != nil { + return nil, fmt.Errorf("failed to load file %q: %v", u.Path, filters.ErrInvalidFilterParameters) + } + case "https": + panic("not implemented") + default: + return nil, filters.ErrInvalidFilterParameters + } + + ctx := context.Background() + + var r wazero.Runtime + switch ws.typ { + // in general, we likely do not need compilation + // cache, but likely we want to use Pre-/PostProcessor + // to not recreate the filter and check in + // CreateFilter to not compile the WASM code again and + // again + case none: + // we could try to use NewRuntimeConfigCompiler for + // GOARCH specific asm for optimal performance as + // stated in + // https://pkg.go.dev/github.com/tetratelabs/wazero#NewRuntimeConfigCompiler + config := wazero.NewRuntimeConfig().WithMemoryLimitPages(memoryLimitPages) + r = wazero.NewRuntimeWithConfig(ctx, config) + + case inmemory: + // TODO(sszuecs): unclear if we hit the bug described in https://pkg.go.dev/github.com/tetratelabs/wazero#RuntimeConfig for WithCompilationCache(): + + // Cached files are keyed on the version of wazero. This is obtained from go.mod of your application, + // and we use it to verify the compatibility of caches against the currently-running wazero. + // However, if you use this in tests of a package not named as `main`, then wazero cannot obtain the correct + // version of wazero due to the known issue of debug.BuildInfo function: https://github.com/golang/go/issues/33976. + // As a consequence, your cache won't contain the correct version information and always be treated as `dev` version. + // To avoid this issue, you can pass -ldflags "-X github.com/tetratelabs/wazero/internal/version.version=foo" when running tests. + + cache := wazero.NewCompilationCache() + config := wazero.NewRuntimeConfig().WithCompilationCache(cache) + config = config.WithMemoryLimitPages(memoryLimitPages) + r = wazero.NewRuntimeWithConfig(ctx, config) + + case filesystem: + cache, err := wazero.NewCompilationCacheWithDir(ws.cacheDir) + if err != nil { + return nil, fmt.Errorf("failed to create compilation cache dir with %q as directory: %w", ws.cacheDir, filters.ErrInvalidFilterParameters) + } + + config := wazero.NewRuntimeConfig().WithCompilationCache(cache) + config = config.WithMemoryLimitPages(memoryLimitPages) + r = wazero.NewRuntimeWithConfig(ctx, config) + + default: + return nil, fmt.Errorf("failed to create wazero runtime typ %q: %w", ws.typ, filters.ErrInvalidFilterParameters) + } + + // Instantiate WASI, which implements host functions needed for TinyGo to + // implement `panic`. + // see also https://github.com/tetratelabs/wazero/blob/main/imports/README.md + // and https://wazero.io/languages/ + // + // we do not need the closer because of https://pkg.go.dev/github.com/tetratelabs/wazero@v1.6.0/imports/wasi_snapshot_preview1#hdr-Notes + // "Closing the wazero.Runtime has the same effect as closing the result." + _, err = wasi_snapshot_preview1.Instantiate(ctx, r) + if err != nil { + return nil, fmt.Errorf("failed to wasi_snapshot_preview1: %w: %w", err, filters.ErrInvalidFilterParameters) + } + + // TODO(sszuecs): create modules to be used from user wasm code + // cmod, err := r.CompileModule(ctx, []byte("")) + // if err != nil { + // return nil, fmt.Errorf("failed to compile module: %w: %w", err, filters.ErrInvalidFilterParameters) + // } + // r.InstantiateModule(ctx) + // + // Instantiate the guest Wasm into the same runtime. It exports the `add` + // function, implemented in WebAssembly. + // mod, err := r.Instantiate(ctx, cmod, moduleConfig) + // if err != nil { + // return nil, fmt.Errorf("failed to instantiate module: %w: %w", err, filters.ErrInvalidFilterParameters) + // } + + mod, err := r.Instantiate(ctx, code) + if err != nil { + return nil, fmt.Errorf("failed to instantiate module: %w: %w", err, filters.ErrInvalidFilterParameters) + } + request := mod.ExportedFunction("request") + response := mod.ExportedFunction("response") + + return &wasm{ + code: code, + runtime: r, + mod: mod, + request: request, + response: response, + }, nil +} + +func (w *wasm) Request(ctx filters.FilterContext) { + + result, err := w.request.Call(ctx.Request().Context(), 2, 3) + if err != nil { + log.Errorf("failed to call add: %v", err) + } + log.Infof("request result: %v", result) + +} + +func (w *wasm) Response(ctx filters.FilterContext) { + result, err := w.response.Call(context.Background(), 3, 2) + if err != nil { + log.Errorf("failed to call add: %v", err) + } + log.Infof("response result: %v", result) + +} + +func (w *wasm) Close() error { + return w.runtime.Close(context.Background()) +} diff --git a/go.mod b/go.mod index c949222f46..133f70a434 100644 --- a/go.mod +++ b/go.mod @@ -146,6 +146,7 @@ require ( github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/streadway/quantile v0.0.0-20220407130108-4246515d968d // indirect github.com/tchap/go-patricia/v2 v2.3.1 // indirect + github.com/tetratelabs/wazero v1.6.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect diff --git a/go.sum b/go.sum index 7349368ebb..5ad35b0327 100644 --- a/go.sum +++ b/go.sum @@ -407,6 +407,8 @@ github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BG github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= github.com/testcontainers/testcontainers-go v0.27.0 h1:IeIrJN4twonTDuMuBNQdKZ+K97yd7VrmNGu+lDpYcDk= github.com/testcontainers/testcontainers-go v0.27.0/go.mod h1:+HgYZcd17GshBUZv9b+jKFJ198heWPQq3KQIp2+N+7U= +github.com/tetratelabs/wazero v1.6.0 h1:z0H1iikCdP8t+q341xqepY4EWvHEw8Es7tlqiVzlP3g= +github.com/tetratelabs/wazero v1.6.0/go.mod h1:0U0G41+ochRKoPKCJlh0jMg1CHkyfK8kDqiirMmKY8A= github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= diff --git a/skipper.go b/skipper.go index 3457ffcbf7..ce5dcc99b6 100644 --- a/skipper.go +++ b/skipper.go @@ -42,6 +42,7 @@ import ( ratelimitfilters "github.com/zalando/skipper/filters/ratelimit" "github.com/zalando/skipper/filters/shedder" teefilters "github.com/zalando/skipper/filters/tee" + "github.com/zalando/skipper/filters/wasm" "github.com/zalando/skipper/loadbalancer" "github.com/zalando/skipper/logging" "github.com/zalando/skipper/metrics" @@ -1524,6 +1525,13 @@ func run(o Options, sig chan os.Signal, idleConnsCH chan struct{}) error { return err } + o.CustomFilters = append(o.CustomFilters, + wasm.NewWASM(wasm.WASMOpts{ + Typ: "none", + CacheDir: "", + }), + ) + // tee filters override with initialized tracer o.CustomFilters = append(o.CustomFilters, // tee()