From 2fe324761d575a836d2d5b4fe645a2d649895ad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Wed, 28 Jan 2026 19:08:10 +0800 Subject: [PATCH] feat: add higress wasm go plugin development skill for Claude (#3402) --- .../skills/higress-wasm-go-plugin/SKILL.md | 251 +++++++++++++++++ .../references/advanced-patterns.md | 253 ++++++++++++++++++ .../references/http-client.md | 179 +++++++++++++ .../references/local-testing.md | 189 +++++++++++++ .../references/redis-client.md | 215 +++++++++++++++ .licenserc.yaml | 3 +- 6 files changed, 1089 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/higress-wasm-go-plugin/SKILL.md create mode 100644 .claude/skills/higress-wasm-go-plugin/references/advanced-patterns.md create mode 100644 .claude/skills/higress-wasm-go-plugin/references/http-client.md create mode 100644 .claude/skills/higress-wasm-go-plugin/references/local-testing.md create mode 100644 .claude/skills/higress-wasm-go-plugin/references/redis-client.md diff --git a/.claude/skills/higress-wasm-go-plugin/SKILL.md b/.claude/skills/higress-wasm-go-plugin/SKILL.md new file mode 100644 index 000000000..0d127e704 --- /dev/null +++ b/.claude/skills/higress-wasm-go-plugin/SKILL.md @@ -0,0 +1,251 @@ +--- +name: higress-wasm-go-plugin +description: Develop Higress WASM plugins using Go 1.24+. Use when creating, modifying, or debugging Higress gateway plugins for HTTP request/response processing, external service calls, Redis integration, or custom gateway logic. +--- + +# Higress WASM Go Plugin Development + +Develop Higress gateway WASM plugins using Go language with the `wasm-go` SDK. + +## Quick Start + +### Project Setup + +```bash +# Create project directory +mkdir my-plugin && cd my-plugin + +# Initialize Go module +go mod init my-plugin + +# Set proxy (China) +go env -w GOPROXY=https://proxy.golang.com.cn,direct + +# Download dependencies +go get github.com/higress-group/proxy-wasm-go-sdk@go-1.24 +go get github.com/higress-group/wasm-go@main +go get github.com/tidwall/gjson +``` + +### Minimal Plugin Template + +```go +package main + +import ( + "github.com/higress-group/wasm-go/pkg/wrapper" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/tidwall/gjson" +) + +func main() {} + +func init() { + wrapper.SetCtx( + "my-plugin", + wrapper.ParseConfig(parseConfig), + wrapper.ProcessRequestHeaders(onHttpRequestHeaders), + ) +} + +type MyConfig struct { + Enabled bool +} + +func parseConfig(json gjson.Result, config *MyConfig) error { + config.Enabled = json.Get("enabled").Bool() + return nil +} + +func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig) types.Action { + if config.Enabled { + proxywasm.AddHttpRequestHeader("x-my-header", "hello") + } + return types.HeaderContinue +} +``` + +### Compile + +```bash +go mod tidy +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o main.wasm ./ +``` + +## Core Concepts + +### Plugin Lifecycle + +1. **init()** - Register plugin with `wrapper.SetCtx()` +2. **parseConfig** - Parse YAML config (auto-converted to JSON) +3. **HTTP processing phases** - Handle requests/responses + +### HTTP Processing Phases + +| Phase | Trigger | Handler | +|-------|---------|---------| +| Request Headers | Gateway receives client request headers | `ProcessRequestHeaders` | +| Request Body | Gateway receives client request body | `ProcessRequestBody` | +| Response Headers | Gateway receives backend response headers | `ProcessResponseHeaders` | +| Response Body | Gateway receives backend response body | `ProcessResponseBody` | +| Stream Done | HTTP stream completes | `ProcessStreamDone` | + +### Action Return Values + +| Action | Behavior | +|--------|----------| +| `types.HeaderContinue` | Continue to next filter | +| `types.HeaderStopIteration` | Stop header processing, wait for body | +| `types.HeaderStopAllIterationAndWatermark` | Stop all processing, buffer data, call `proxywasm.ResumeHttpRequest/Response()` to resume | + +## API Reference + +### HttpContext Methods + +```go +// Request info (cached, safe to call in any phase) +ctx.Scheme() // :scheme +ctx.Host() // :authority +ctx.Path() // :path +ctx.Method() // :method + +// Body handling +ctx.HasRequestBody() // Check if request has body +ctx.HasResponseBody() // Check if response has body +ctx.DontReadRequestBody() // Skip reading request body +ctx.DontReadResponseBody() // Skip reading response body +ctx.BufferRequestBody() // Buffer instead of stream +ctx.BufferResponseBody() // Buffer instead of stream + +// Content detection +ctx.IsWebsocket() // Check WebSocket upgrade +ctx.IsBinaryRequestBody() // Check binary content +ctx.IsBinaryResponseBody() // Check binary content + +// Context storage +ctx.SetContext(key, value) +ctx.GetContext(key) +ctx.GetStringContext(key, defaultValue) +ctx.GetBoolContext(key, defaultValue) + +// Custom logging +ctx.SetUserAttribute(key, value) +ctx.WriteUserAttributeToLog() +``` + +### Header/Body Operations (proxywasm) + +```go +// Request headers +proxywasm.GetHttpRequestHeader(name) +proxywasm.AddHttpRequestHeader(name, value) +proxywasm.ReplaceHttpRequestHeader(name, value) +proxywasm.RemoveHttpRequestHeader(name) +proxywasm.GetHttpRequestHeaders() +proxywasm.ReplaceHttpRequestHeaders(headers) + +// Response headers +proxywasm.GetHttpResponseHeader(name) +proxywasm.AddHttpResponseHeader(name, value) +proxywasm.ReplaceHttpResponseHeader(name, value) +proxywasm.RemoveHttpResponseHeader(name) +proxywasm.GetHttpResponseHeaders() +proxywasm.ReplaceHttpResponseHeaders(headers) + +// Request body (only in body phase) +proxywasm.GetHttpRequestBody(start, size) +proxywasm.ReplaceHttpRequestBody(body) +proxywasm.AppendHttpRequestBody(data) +proxywasm.PrependHttpRequestBody(data) + +// Response body (only in body phase) +proxywasm.GetHttpResponseBody(start, size) +proxywasm.ReplaceHttpResponseBody(body) +proxywasm.AppendHttpResponseBody(data) +proxywasm.PrependHttpResponseBody(data) + +// Direct response +proxywasm.SendHttpResponse(statusCode, headers, body, grpcStatus) + +// Flow control +proxywasm.ResumeHttpRequest() // Resume paused request +proxywasm.ResumeHttpResponse() // Resume paused response +``` + +## Common Patterns + +### External HTTP Call + +See [references/http-client.md](references/http-client.md) for complete HTTP client patterns. + +```go +func parseConfig(json gjson.Result, config *MyConfig) error { + serviceName := json.Get("serviceName").String() + servicePort := json.Get("servicePort").Int() + config.client = wrapper.NewClusterClient(wrapper.FQDNCluster{ + FQDN: serviceName, + Port: servicePort, + }) + return nil +} + +func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig) types.Action { + err := config.client.Get("/api/check", nil, func(statusCode int, headers http.Header, body []byte) { + if statusCode != 200 { + proxywasm.SendHttpResponse(403, nil, []byte("Forbidden"), -1) + return + } + proxywasm.ResumeHttpRequest() + }, 3000) // timeout ms + + if err != nil { + return types.HeaderContinue // fallback on error + } + return types.HeaderStopAllIterationAndWatermark +} +``` + +### Redis Integration + +See [references/redis-client.md](references/redis-client.md) for complete Redis patterns. + +```go +func parseConfig(json gjson.Result, config *MyConfig) error { + config.redis = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{ + FQDN: json.Get("redisService").String(), + Port: json.Get("redisPort").Int(), + }) + return config.redis.Init( + json.Get("username").String(), + json.Get("password").String(), + json.Get("timeout").Int(), + ) +} +``` + +### Multi-level Config + +插件配置支持在控制台不同级别设置:全局、域名级、路由级。控制面会自动处理配置的优先级和匹配逻辑,插件代码中通过 `parseConfig` 解析到的就是当前请求匹配到的配置。 + +## Local Testing + +See [references/local-testing.md](references/local-testing.md) for Docker Compose setup. + +## Advanced Topics + +See [references/advanced-patterns.md](references/advanced-patterns.md) for: +- Streaming body processing +- Route call pattern +- Tick functions (periodic tasks) +- Leader election +- Memory management +- Custom logging + +## Best Practices + +1. **Never call Resume after SendHttpResponse** - Response auto-resumes +2. **Check HasRequestBody() before returning HeaderStopIteration** - Avoids blocking +3. **Use cached ctx methods** - `ctx.Path()` works in any phase, `GetHttpRequestHeader(":path")` only in header phase +4. **Handle external call failures gracefully** - Return `HeaderContinue` on error to avoid blocking +5. **Set appropriate timeouts** - Default HTTP call timeout is 500ms diff --git a/.claude/skills/higress-wasm-go-plugin/references/advanced-patterns.md b/.claude/skills/higress-wasm-go-plugin/references/advanced-patterns.md new file mode 100644 index 000000000..cfa7e01ac --- /dev/null +++ b/.claude/skills/higress-wasm-go-plugin/references/advanced-patterns.md @@ -0,0 +1,253 @@ +# Advanced Patterns + +## Streaming Body Processing + +Process body chunks as they arrive without buffering: + +```go +func init() { + wrapper.SetCtx( + "streaming-plugin", + wrapper.ParseConfig(parseConfig), + wrapper.ProcessStreamingRequestBody(onStreamingRequestBody), + wrapper.ProcessStreamingResponseBody(onStreamingResponseBody), + ) +} + +func onStreamingRequestBody(ctx wrapper.HttpContext, config MyConfig, chunk []byte, isLastChunk bool) []byte { + // Modify chunk and return + modified := bytes.ReplaceAll(chunk, []byte("old"), []byte("new")) + return modified +} + +func onStreamingResponseBody(ctx wrapper.HttpContext, config MyConfig, chunk []byte, isLastChunk bool) []byte { + // Can call external services with NeedPauseStreamingResponse() + return chunk +} +``` + +## Buffered Body Processing + +Buffer entire body before processing: + +```go +func init() { + wrapper.SetCtx( + "buffered-plugin", + wrapper.ParseConfig(parseConfig), + wrapper.ProcessRequestBody(onRequestBody), + wrapper.ProcessResponseBody(onResponseBody), + ) +} + +func onRequestBody(ctx wrapper.HttpContext, config MyConfig, body []byte) types.Action { + // Full request body available + var data map[string]interface{} + json.Unmarshal(body, &data) + + // Modify and replace + data["injected"] = "value" + newBody, _ := json.Marshal(data) + proxywasm.ReplaceHttpRequestBody(newBody) + + return types.ActionContinue +} +``` + +## Route Call Pattern + +Call the current route's upstream with modified request: + +```go +func onRequestBody(ctx wrapper.HttpContext, config MyConfig, body []byte) types.Action { + err := ctx.RouteCall("POST", "/modified-path", [][2]string{ + {"Content-Type", "application/json"}, + {"X-Custom", "header"}, + }, body, func(statusCode int, headers [][2]string, body []byte) { + // Handle response from upstream + proxywasm.SendHttpResponse(statusCode, headers, body, -1) + }) + + if err != nil { + proxywasm.SendHttpResponse(500, nil, []byte("Route call failed"), -1) + } + return types.ActionContinue +} +``` + +## Tick Functions (Periodic Tasks) + +Register periodic background tasks: + +```go +func parseConfig(json gjson.Result, config *MyConfig) error { + // Register tick functions during config parsing + wrapper.RegisterTickFunc(1000, func() { + // Executes every 1 second + log.Info("1s tick") + }) + + wrapper.RegisterTickFunc(5000, func() { + // Executes every 5 seconds + log.Info("5s tick") + }) + + return nil +} +``` + +## Leader Election + +For tasks that should run on only one VM instance: + +```go +func init() { + wrapper.SetCtx( + "leader-plugin", + wrapper.PrePluginStartOrReload(onPluginStart), + wrapper.ParseConfig(parseConfig), + ) +} + +func onPluginStart(ctx wrapper.PluginContext) error { + ctx.DoLeaderElection() + return nil +} + +func parseConfig(json gjson.Result, config *MyConfig) error { + wrapper.RegisterTickFunc(10000, func() { + if ctx.IsLeader() { + // Only leader executes this + log.Info("Leader task") + } + }) + return nil +} +``` + +## Plugin Context Storage + +Store data across requests at plugin level: + +```go +type MyConfig struct { + // Config fields +} + +func init() { + wrapper.SetCtx( + "context-plugin", + wrapper.ParseConfigWithContext(parseConfigWithContext), + wrapper.ProcessRequestHeaders(onHttpRequestHeaders), + ) +} + +func parseConfigWithContext(ctx wrapper.PluginContext, json gjson.Result, config *MyConfig) error { + // Store in plugin context (survives across requests) + ctx.SetContext("initTime", time.Now().Unix()) + return nil +} +``` + +## Rule-Level Config Isolation + +Enable graceful degradation when rule config parsing fails: + +```go +func init() { + wrapper.SetCtx( + "isolated-plugin", + wrapper.PrePluginStartOrReload(func(ctx wrapper.PluginContext) error { + ctx.EnableRuleLevelConfigIsolation() + return nil + }), + wrapper.ParseOverrideConfig(parseGlobal, parseRule), + ) +} + +func parseGlobal(json gjson.Result, config *MyConfig) error { + // Parse global config + return nil +} + +func parseRule(json gjson.Result, global MyConfig, config *MyConfig) error { + // Parse per-rule config, inheriting from global + *config = global // Copy global defaults + // Override with rule-specific values + return nil +} +``` + +## Memory Management + +Configure automatic VM rebuild to prevent memory leaks: + +```go +func init() { + wrapper.SetCtxWithOptions( + "memory-managed-plugin", + wrapper.ParseConfig(parseConfig), + wrapper.WithRebuildAfterRequests(10000), // Rebuild after 10k requests + wrapper.WithRebuildMaxMemBytes(100*1024*1024), // Rebuild at 100MB + wrapper.WithMaxRequestsPerIoCycle(20), // Limit concurrent requests + ) +} +``` + +## Custom Logging + +Add structured fields to access logs: + +```go +func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig) types.Action { + // Set custom attributes + ctx.SetUserAttribute("user_id", "12345") + ctx.SetUserAttribute("request_type", "api") + + return types.HeaderContinue +} + +func onHttpResponseHeaders(ctx wrapper.HttpContext, config MyConfig) types.Action { + // Write to access log + ctx.WriteUserAttributeToLog() + + // Or write to trace spans + ctx.WriteUserAttributeToTrace() + + return types.HeaderContinue +} +``` + +## Disable Re-routing + +Prevent Envoy from recalculating routes after header modification: + +```go +func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig) types.Action { + // Call BEFORE modifying headers + ctx.DisableReroute() + + // Now safe to modify headers without triggering re-route + proxywasm.ReplaceHttpRequestHeader(":path", "/new-path") + + return types.HeaderContinue +} +``` + +## Buffer Limits + +Set per-request buffer limits to control memory usage: + +```go +func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig) types.Action { + // Allow larger request bodies for this request + ctx.SetRequestBodyBufferLimit(10 * 1024 * 1024) // 10MB + return types.HeaderContinue +} + +func onHttpResponseHeaders(ctx wrapper.HttpContext, config MyConfig) types.Action { + // Allow larger response bodies + ctx.SetResponseBodyBufferLimit(50 * 1024 * 1024) // 50MB + return types.HeaderContinue +} +``` diff --git a/.claude/skills/higress-wasm-go-plugin/references/http-client.md b/.claude/skills/higress-wasm-go-plugin/references/http-client.md new file mode 100644 index 000000000..308f284b9 --- /dev/null +++ b/.claude/skills/higress-wasm-go-plugin/references/http-client.md @@ -0,0 +1,179 @@ +# HTTP Client Reference + +## Cluster Types + +### FQDNCluster (Most Common) + +For services registered in Higress with FQDN: + +```go +wrapper.NewClusterClient(wrapper.FQDNCluster{ + FQDN: "my-service.dns", // Service FQDN with suffix + Port: 8080, + Host: "optional-host-header", // Optional +}) +``` + +Common FQDN suffixes: +- `.dns` - DNS service +- `.static` - Static IP service (port defaults to 80) +- `.nacos` - Nacos service + +### K8sCluster + +For Kubernetes services: + +```go +wrapper.NewClusterClient(wrapper.K8sCluster{ + ServiceName: "my-service", + Namespace: "default", + Port: 8080, + Version: "", // Optional subset version +}) +// Generates: outbound|8080||my-service.default.svc.cluster.local +``` + +### NacosCluster + +For Nacos registry services: + +```go +wrapper.NewClusterClient(wrapper.NacosCluster{ + ServiceName: "my-service", + Group: "DEFAULT-GROUP", + NamespaceID: "public", + Port: 8080, + IsExtRegistry: false, // true for EDAS/SAE +}) +``` + +### StaticIpCluster + +For static IP services: + +```go +wrapper.NewClusterClient(wrapper.StaticIpCluster{ + ServiceName: "my-service", + Port: 8080, +}) +// Generates: outbound|8080||my-service.static +``` + +### DnsCluster + +For DNS-resolved services: + +```go +wrapper.NewClusterClient(wrapper.DnsCluster{ + ServiceName: "my-service", + Domain: "api.example.com", + Port: 443, +}) +``` + +### RouteCluster + +Use current route's upstream: + +```go +wrapper.NewClusterClient(wrapper.RouteCluster{ + Host: "optional-host-override", +}) +``` + +### TargetCluster + +Direct cluster name specification: + +```go +wrapper.NewClusterClient(wrapper.TargetCluster{ + Cluster: "outbound|8080||my-service.dns", + Host: "api.example.com", +}) +``` + +## HTTP Methods + +```go +client.Get(path, headers, callback, timeout...) +client.Post(path, headers, body, callback, timeout...) +client.Put(path, headers, body, callback, timeout...) +client.Patch(path, headers, body, callback, timeout...) +client.Delete(path, headers, body, callback, timeout...) +client.Head(path, headers, callback, timeout...) +client.Options(path, headers, callback, timeout...) +client.Call(method, path, headers, body, callback, timeout...) +``` + +## Callback Signature + +```go +func(statusCode int, responseHeaders http.Header, responseBody []byte) +``` + +## Complete Example + +```go +type MyConfig struct { + client wrapper.HttpClient + requestPath string + tokenHeader string +} + +func parseConfig(json gjson.Result, config *MyConfig) error { + config.tokenHeader = json.Get("tokenHeader").String() + if config.tokenHeader == "" { + return errors.New("missing tokenHeader") + } + + config.requestPath = json.Get("requestPath").String() + if config.requestPath == "" { + return errors.New("missing requestPath") + } + + serviceName := json.Get("serviceName").String() + servicePort := json.Get("servicePort").Int() + if servicePort == 0 { + if strings.HasSuffix(serviceName, ".static") { + servicePort = 80 + } + } + + config.client = wrapper.NewClusterClient(wrapper.FQDNCluster{ + FQDN: serviceName, + Port: servicePort, + }) + return nil +} + +func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig) types.Action { + err := config.client.Get(config.requestPath, nil, + func(statusCode int, responseHeaders http.Header, responseBody []byte) { + if statusCode != http.StatusOK { + log.Errorf("http call failed, status: %d", statusCode) + proxywasm.SendHttpResponse(http.StatusInternalServerError, nil, + []byte("http call failed"), -1) + return + } + + token := responseHeaders.Get(config.tokenHeader) + if token != "" { + proxywasm.AddHttpRequestHeader(config.tokenHeader, token) + } + proxywasm.ResumeHttpRequest() + }) + + if err != nil { + log.Errorf("http call dispatch failed: %v", err) + return types.HeaderContinue + } + return types.HeaderStopAllIterationAndWatermark +} +``` + +## Important Notes + +1. **Cannot use net/http** - Must use wrapper's HTTP client +2. **Default timeout is 500ms** - Pass explicit timeout for longer calls +3. **Callback is async** - Must return `HeaderStopAllIterationAndWatermark` and call `ResumeHttpRequest()` in callback +4. **Error handling** - If dispatch fails, return `HeaderContinue` to avoid blocking diff --git a/.claude/skills/higress-wasm-go-plugin/references/local-testing.md b/.claude/skills/higress-wasm-go-plugin/references/local-testing.md new file mode 100644 index 000000000..0a31cc291 --- /dev/null +++ b/.claude/skills/higress-wasm-go-plugin/references/local-testing.md @@ -0,0 +1,189 @@ +# Local Testing with Docker Compose + +## Prerequisites + +- Docker installed +- Compiled `main.wasm` file + +## Setup + +Create these files in your plugin directory: + +### docker-compose.yaml + +```yaml +version: '3.7' +services: + envoy: + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v2.1.5 + entrypoint: /usr/local/bin/envoy + command: -c /etc/envoy/envoy.yaml --component-log-level wasm:debug + depends_on: + - httpbin + networks: + - wasmtest + ports: + - "10000:10000" + volumes: + - ./envoy.yaml:/etc/envoy/envoy.yaml + - ./main.wasm:/etc/envoy/main.wasm + + httpbin: + image: kennethreitz/httpbin:latest + networks: + - wasmtest + ports: + - "12345:80" + +networks: + wasmtest: {} +``` + +### envoy.yaml + +```yaml +admin: + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 9901 + +static_resources: + listeners: + - name: listener_0 + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 10000 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + scheme_header_transformation: + scheme_to_overwrite: https + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: "/" + route: + cluster: httpbin + http_filters: + - name: wasmdemo + typed_config: + "@type": type.googleapis.com/udpa.type.v1.TypedStruct + type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm + value: + config: + name: wasmdemo + vm_config: + runtime: envoy.wasm.runtime.v8 + code: + local: + filename: /etc/envoy/main.wasm + configuration: + "@type": "type.googleapis.com/google.protobuf.StringValue" + value: | + { + "mockEnable": false + } + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + clusters: + - name: httpbin + connect_timeout: 30s + type: LOGICAL_DNS + dns_lookup_family: V4_ONLY + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: httpbin + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: httpbin + port_value: 80 +``` + +## Running + +```bash +# Start +docker compose up + +# Test without gateway (baseline) +curl http://127.0.0.1:12345/get + +# Test with gateway (plugin applied) +curl http://127.0.0.1:10000/get + +# Stop +docker compose down +``` + +## Modifying Plugin Config + +1. Edit the `configuration.value` section in `envoy.yaml` +2. Restart: `docker compose restart envoy` + +## Viewing Logs + +```bash +# Follow Envoy logs +docker compose logs -f envoy + +# WASM debug logs (enabled by --component-log-level wasm:debug) +``` + +## Adding External Services + +To test external HTTP/Redis calls, add services to docker-compose.yaml: + +```yaml +services: + # ... existing services ... + + redis: + image: redis:7-alpine + networks: + - wasmtest + ports: + - "6379:6379" + + auth-service: + image: your-auth-service:latest + networks: + - wasmtest +``` + +Then add clusters to envoy.yaml: + +```yaml +clusters: + # ... existing clusters ... + + - name: outbound|6379||redis.static + connect_timeout: 5s + type: LOGICAL_DNS + dns_lookup_family: V4_ONLY + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: redis + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: redis + port_value: 6379 +``` diff --git a/.claude/skills/higress-wasm-go-plugin/references/redis-client.md b/.claude/skills/higress-wasm-go-plugin/references/redis-client.md new file mode 100644 index 000000000..825c52ac0 --- /dev/null +++ b/.claude/skills/higress-wasm-go-plugin/references/redis-client.md @@ -0,0 +1,215 @@ +# Redis Client Reference + +## Initialization + +```go +type MyConfig struct { + redis wrapper.RedisClient + qpm int +} + +func parseConfig(json gjson.Result, config *MyConfig) error { + serviceName := json.Get("serviceName").String() + servicePort := json.Get("servicePort").Int() + if servicePort == 0 { + servicePort = 6379 + } + + config.redis = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{ + FQDN: serviceName, + Port: servicePort, + }) + + return config.redis.Init( + json.Get("username").String(), + json.Get("password").String(), + json.Get("timeout").Int(), // milliseconds + // Optional settings: + // wrapper.WithDataBase(1), + // wrapper.WithBufferFlushTimeout(3*time.Millisecond), + // wrapper.WithMaxBufferSizeBeforeFlush(1024), + // wrapper.WithDisableBuffer(), // For latency-sensitive scenarios + ) +} +``` + +## Callback Signature + +```go +func(response resp.Value) + +// Check for errors +if response.Error() != nil { + // Handle error +} + +// Get values +response.Integer() // int +response.String() // string +response.Bool() // bool +response.Array() // []resp.Value +response.Bytes() // []byte +``` + +## Available Commands + +### Key Operations + +```go +redis.Del(key, callback) +redis.Exists(key, callback) +redis.Expire(key, ttlSeconds, callback) +redis.Persist(key, callback) +``` + +### String Operations + +```go +redis.Get(key, callback) +redis.Set(key, value, callback) +redis.SetEx(key, value, ttlSeconds, callback) +redis.SetNX(key, value, ttlSeconds, callback) // ttl=0 means no expiry +redis.MGet(keys, callback) +redis.MSet(kvMap, callback) +redis.Incr(key, callback) +redis.Decr(key, callback) +redis.IncrBy(key, delta, callback) +redis.DecrBy(key, delta, callback) +``` + +### List Operations + +```go +redis.LLen(key, callback) +redis.RPush(key, values, callback) +redis.RPop(key, callback) +redis.LPush(key, values, callback) +redis.LPop(key, callback) +redis.LIndex(key, index, callback) +redis.LRange(key, start, stop, callback) +redis.LRem(key, count, value, callback) +redis.LInsertBefore(key, pivot, value, callback) +redis.LInsertAfter(key, pivot, value, callback) +``` + +### Hash Operations + +```go +redis.HExists(key, field, callback) +redis.HDel(key, fields, callback) +redis.HLen(key, callback) +redis.HGet(key, field, callback) +redis.HSet(key, field, value, callback) +redis.HMGet(key, fields, callback) +redis.HMSet(key, kvMap, callback) +redis.HKeys(key, callback) +redis.HVals(key, callback) +redis.HGetAll(key, callback) +redis.HIncrBy(key, field, delta, callback) +redis.HIncrByFloat(key, field, delta, callback) +``` + +### Set Operations + +```go +redis.SCard(key, callback) +redis.SAdd(key, values, callback) +redis.SRem(key, values, callback) +redis.SIsMember(key, value, callback) +redis.SMembers(key, callback) +redis.SDiff(key1, key2, callback) +redis.SDiffStore(dest, key1, key2, callback) +redis.SInter(key1, key2, callback) +redis.SInterStore(dest, key1, key2, callback) +redis.SUnion(key1, key2, callback) +redis.SUnionStore(dest, key1, key2, callback) +``` + +### Sorted Set Operations + +```go +redis.ZCard(key, callback) +redis.ZAdd(key, memberScoreMap, callback) +redis.ZCount(key, min, max, callback) +redis.ZIncrBy(key, member, delta, callback) +redis.ZScore(key, member, callback) +redis.ZRank(key, member, callback) +redis.ZRevRank(key, member, callback) +redis.ZRem(key, members, callback) +redis.ZRange(key, start, stop, callback) +redis.ZRevRange(key, start, stop, callback) +``` + +### Lua Script + +```go +redis.Eval(script, numkeys, keys, args, callback) +``` + +### Raw Command + +```go +redis.Command([]interface{}{"SET", "key", "value"}, callback) +``` + +## Rate Limiting Example + +```go +func onHttpRequestHeaders(ctx wrapper.HttpContext, config MyConfig) types.Action { + now := time.Now() + minuteAligned := now.Truncate(time.Minute) + timeStamp := strconv.FormatInt(minuteAligned.Unix(), 10) + + err := config.redis.Incr(timeStamp, func(response resp.Value) { + if response.Error() != nil { + log.Errorf("redis error: %v", response.Error()) + proxywasm.ResumeHttpRequest() + return + } + + count := response.Integer() + ctx.SetContext("timeStamp", timeStamp) + ctx.SetContext("callTimeLeft", strconv.Itoa(config.qpm - count)) + + if count == 1 { + // First request in this minute, set expiry + config.redis.Expire(timeStamp, 60, func(response resp.Value) { + if response.Error() != nil { + log.Errorf("expire error: %v", response.Error()) + } + proxywasm.ResumeHttpRequest() + }) + } else if count > config.qpm { + proxywasm.SendHttpResponse(429, [][2]string{ + {"timeStamp", timeStamp}, + {"callTimeLeft", "0"}, + }, []byte("Too many requests\n"), -1) + } else { + proxywasm.ResumeHttpRequest() + } + }) + + if err != nil { + log.Errorf("redis call failed: %v", err) + return types.HeaderContinue + } + return types.HeaderStopAllIterationAndWatermark +} + +func onHttpResponseHeaders(ctx wrapper.HttpContext, config MyConfig) types.Action { + if ts := ctx.GetContext("timeStamp"); ts != nil { + proxywasm.AddHttpResponseHeader("timeStamp", ts.(string)) + } + if left := ctx.GetContext("callTimeLeft"); left != nil { + proxywasm.AddHttpResponseHeader("callTimeLeft", left.(string)) + } + return types.HeaderContinue +} +``` + +## Important Notes + +1. **Check Ready()** - `redis.Ready()` returns false if init failed +2. **Auto-reconnect** - Client handles NOAUTH errors and re-authenticates automatically +3. **Buffering** - Default 3ms flush timeout and 1024 byte buffer; use `WithDisableBuffer()` for latency-sensitive scenarios +4. **Error handling** - Always check `response.Error()` in callbacks diff --git a/.licenserc.yaml b/.licenserc.yaml index e78d87489..c4920786c 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -35,7 +35,8 @@ header: - 'hgctl/pkg/manifests' - 'pkg/ingress/kube/gateway/istio/testdata' - 'release-notes/**' - - '.cursor/**' + - '.cursor/**' + - '.claude/**' comment: on-failure dependency: