diff --git a/README.md b/README.md index 90084db..e6d4737 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ func Handle(req sdk.Request) sdk.Action { - **Registry**: S3-compatible object storage, local `file://`, or read-only `https://` - **Proxy**: Caddy handler module (`http.handlers.switchboard`), standalone `switchboard serve`, or embeddable Go middleware - **Runtime**: wazero with enforced timeouts, memory caps, and output validation -- **Guests**: TinyGo WASI modules against a small host ABI (`switchboard/v3`) +- **Guests**: TinyGo WASI modules against a small host ABI (`switchboard/v4`) ## Install @@ -82,11 +82,11 @@ Highlights: ## Examples -Real-world rules, each with a behavioral test suite, under [`examples/`](examples/): auth gating, legacy redirects, security headers, maintenance mode, and metadata-driven canary routing. +Real-world rules, each with a behavioral test suite, under [`examples/`](examples/): auth gating, legacy redirects, security headers, maintenance mode, metadata-driven canary routing, a data-file-driven [IP allowlist](examples/ip-allowlist/), and a typed-config [feature-flags](examples/feature-flags/) rule. ## Limitations -Rules cannot read request/response bodies, make network calls, or hold state. That's by design: it keeps decisions deterministic, replayable offline, and predictable when they fail. Switchboard is not a CDN, cache, or hosted control plane. Caddy is the reference adapter. +Rules cannot read request/response bodies, make network calls, or hold state. They can read immutable [data files](docs/content/guides/data-files.mdx) baked into the bundle (allowlists, redirect maps, cohort tables), which keeps lookups deterministic and replayable without reintroducing state. Switchboard is not a CDN, cache, or hosted control plane. Caddy is the reference adapter. ## Prior art diff --git a/abi/guest/guest.go b/abi/guest/guest.go index 4cdb4a1..4b753f7 100644 --- a/abi/guest/guest.go +++ b/abi/guest/guest.go @@ -9,3 +9,24 @@ func CurrentRequest() sdk.Request { func Return(action sdk.Action) int32 { return sdk.Return(action) } + +// Data accessors re-export the SDK's read-only bundled data files. +type Set = sdk.Set + +func DataBytes(name string) []byte { return sdk.DataBytes(name) } + +func DataString(name string) string { return sdk.DataString(name) } + +func DataMap(name string) map[string]string { return sdk.DataMap(name) } + +func DataSet(name string) Set { return sdk.DataSet(name) } + +func DataLines(name string) []string { return sdk.DataLines(name) } + +func DataJSON(name string, v any) error { return sdk.DataJSON(name, v) } + +func DataJSONL(name string, out any) error { return sdk.DataJSONL(name, out) } + +func DataTOML(name string, v any) error { return sdk.DataTOML(name, v) } + +func DataCSV(name string) ([][]string, error) { return sdk.DataCSV(name) } diff --git a/adapters/http/metrics.go b/adapters/http/metrics.go index f255e9f..073ae8b 100644 --- a/adapters/http/metrics.go +++ b/adapters/http/metrics.go @@ -13,6 +13,8 @@ import ( type Metrics struct { invocations *prometheus.CounterVec duration prometheus.Histogram + reg prometheus.Registerer + collectors []prometheus.Collector } func NewMetrics(reg prometheus.Registerer, service *engine.Service) (*Metrics, error) { @@ -60,12 +62,31 @@ func NewMetrics(reg prometheus.Registerer, service *engine.Service) (*Metrics, e } for _, collector := range collectors { if err := reg.Register(collector); err != nil { + // Roll back partial registration so a retry can start clean. + for _, registered := range m.collectors { + reg.Unregister(registered) + } return nil, err } + m.collectors = append(m.collectors, collector) } + m.reg = reg return m, nil } +// Close unregisters the collectors. Front-ends that register into a shared +// registry (e.g. Caddy's default) call it on teardown so a reload can +// re-register cleanly. +func (m *Metrics) Close() { + if m == nil || m.reg == nil { + return + } + for _, collector := range m.collectors { + m.reg.Unregister(collector) + } + m.collectors = nil +} + func (m *Metrics) ObserveInvocation(decision switchboard.Decision, err error, elapsed time.Duration) { if m == nil { return diff --git a/adapters/http/middleware.go b/adapters/http/middleware.go index ae5127a..4eb6c00 100644 --- a/adapters/http/middleware.go +++ b/adapters/http/middleware.go @@ -24,21 +24,17 @@ func Middleware(service *engine.Service, opts Options) func(http.Handler) http.H if logger == nil { logger = zap.NewNop() } + instrumentation := Instrumentation{Metrics: opts.Metrics, OnDecision: opts.OnDecision} return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() result, err := service.InvokeWithFallback(r.Context(), RequestFromHTTP(r)) - if opts.Metrics != nil { - opts.Metrics.ObserveInvocation(result.Action.Decision, err, time.Since(start)) - } + instrumentation.Observe(r, result, err, time.Since(start)) if err != nil { logger.Warn("switchboard rule invocation failed", zap.String("bundle_id", result.BundleID), zap.Error(err)) failUnavailable(w, r, next, opts.FailMode) return } - if opts.OnDecision != nil { - opts.OnDecision(r, result) - } callNext, err := ApplyAction(w, r, result.Action) if err != nil { logger.Warn("unknown switchboard action", zap.String("decision", string(result.Action.Decision)), zap.Error(err)) diff --git a/adapters/http/observability.go b/adapters/http/observability.go new file mode 100644 index 0000000..388c0f7 --- /dev/null +++ b/adapters/http/observability.go @@ -0,0 +1,42 @@ +package httpadapter + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/ethndotsh/switchboard/engine" +) + +// Instrumentation records metrics and surfaces decision metadata for a rule +// invocation. Both the standalone Middleware and the Caddy handler route +// through it so observability is identical across adapters. +type Instrumentation struct { + Metrics *Metrics + OnDecision func(r *http.Request, result engine.InvokeResult) +} + +// Observe records the invocation's metrics and, on success, surfaces its +// decision. It is safe to call on a zero Instrumentation. +func (in Instrumentation) Observe(r *http.Request, result engine.InvokeResult, err error, elapsed time.Duration) { + if in.Metrics != nil { + in.Metrics.ObserveInvocation(result.Action.Decision, err, elapsed) + } + if err == nil && in.OnDecision != nil { + in.OnDecision(r, result) + } +} + +// StatusHandler serves the service's status as JSON. Front-ends mount it at a +// path of their choosing so the status contract is defined once. +func StatusHandler(service *engine.Service) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, err := json.MarshalIndent(service.Status(), "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }) +} diff --git a/caddy/handler.go b/caddy/handler.go index 0aed31a..7e4dccc 100644 --- a/caddy/handler.go +++ b/caddy/handler.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "time" caddyserver "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" @@ -11,6 +12,7 @@ import ( "github.com/caddyserver/caddy/v2/modules/caddyhttp" httpadapter "github.com/ethndotsh/switchboard/adapters/http" "github.com/ethndotsh/switchboard/engine" + "github.com/prometheus/client_golang/prometheus" "go.uber.org/zap" ) @@ -38,16 +40,22 @@ type Switchboard struct { MaxActionBytes string `json:"max_action_bytes,omitempty"` MaxHeaderOps int `json:"max_header_ops,omitempty"` MaxResponseBody string `json:"max_response_body,omitempty"` + MaxDataBytes string `json:"max_data_bytes,omitempty"` CacheDir string `json:"cache_dir,omitempty"` BootstrapFromCache string `json:"bootstrap_from_cache,omitempty"` PoolAutoscale string `json:"pool_autoscale,omitempty"` PoolSize int `json:"pool_size,omitempty"` MinPoolSize int `json:"min_pool_size,omitempty"` MaxPoolSize int `json:"max_pool_size,omitempty"` + // StatusPath, when set, serves the switchboard status JSON at that request + // path instead of proxying the request downstream. + StatusPath string `json:"status_path,omitempty"` - service *engine.Service - manager *engine.RuntimeManager - logger *zap.Logger + service *engine.Service + manager *engine.RuntimeManager + logger *zap.Logger + metrics *httpadapter.Metrics + statusHandler http.Handler } func (Switchboard) CaddyModule() caddyserver.ModuleInfo { @@ -72,6 +80,7 @@ func (s *Switchboard) Provision(ctx caddyserver.Context) error { MaxActionBytes: s.MaxActionBytes, MaxHeaderOps: s.MaxHeaderOps, MaxResponseBody: s.MaxResponseBody, + MaxDataBytes: s.MaxDataBytes, CacheDir: s.CacheDir, BootstrapFromCache: s.BootstrapFromCache, PoolAutoscale: s.PoolAutoscale, @@ -83,10 +92,21 @@ func (s *Switchboard) Provision(ctx caddyserver.Context) error { return err } s.service = service + // Register switchboard_* metrics on Caddy's default registry (which backs + // the admin /metrics endpoint) so the Caddy path reports the same metrics + // as standalone serve. + metrics, err := httpadapter.NewMetrics(prometheus.DefaultRegisterer, service) + if err != nil { + s.logger.Warn("switchboard metrics registration failed", zap.Error(err)) + } else { + s.metrics = metrics + } + s.statusHandler = httpadapter.StatusHandler(service) return nil } func (s *Switchboard) Cleanup() error { + s.metrics.Close() if s.service != nil { return s.service.Close(context.Background()) } @@ -94,6 +114,11 @@ func (s *Switchboard) Cleanup() error { } func (s *Switchboard) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { + if s.StatusPath != "" && r.URL.Path == s.StatusPath && s.statusHandler != nil { + s.statusHandler.ServeHTTP(w, r) + return nil + } + req := httpadapter.RequestFromHTTP(r) // Prefer Caddy's client IP, which honors trusted_proxies. if clientIP, ok := caddyhttp.GetVar(r.Context(), caddyhttp.ClientIPVarKey).(string); ok && clientIP != "" { @@ -102,6 +127,7 @@ func (s *Switchboard) ServeHTTP(w http.ResponseWriter, r *http.Request, next cad var result engine.InvokeResult var err error + start := time.Now() if s.service != nil { result, err = s.service.InvokeWithFallback(r.Context(), req) } else { @@ -113,6 +139,7 @@ func (s *Switchboard) ServeHTTP(w http.ResponseWriter, r *http.Request, next cad result.Action, err = current.Invoke(r.Context(), req) } } + s.metrics.ObserveInvocation(result.Action.Decision, err, time.Since(start)) if err != nil { if s.logger != nil { s.logger.Warn("switchboard rule invocation failed", zap.String("bundle_id", result.BundleID), zap.Error(err)) @@ -257,6 +284,10 @@ func (s *Switchboard) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { err = positiveIntArg(&s.MaxHeaderOps, "max_header_ops") case "max_response_body": err = stringArg(&s.MaxResponseBody) + case "max_data_bytes": + err = stringArg(&s.MaxDataBytes) + case "status_path": + err = stringArg(&s.StatusPath) case "cache_dir": err = stringArg(&s.CacheDir) case "bootstrap_from_cache": diff --git a/caddy/metrics_integration_test.go b/caddy/metrics_integration_test.go new file mode 100644 index 0000000..7824382 --- /dev/null +++ b/caddy/metrics_integration_test.go @@ -0,0 +1,90 @@ +package caddy + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + httpadapter "github.com/ethndotsh/switchboard/adapters/http" + "github.com/ethndotsh/switchboard/engine" + "github.com/ethndotsh/switchboard/internal/bundle" + "github.com/prometheus/client_golang/prometheus" +) + +// TestHandlerRecordsMetrics locks the Track 2 fix: the Caddy ServeHTTP path, +// which previously bypassed the shared middleware, now records switchboard_* +// invocation metrics. +func TestHandlerRecordsMetrics(t *testing.T) { + dist := filepath.Join("..", "dist") + module, err := os.ReadFile(filepath.Join(dist, "module.wasm")) + if err != nil { + if os.IsNotExist(err) { + t.Skip("dist/module.wasm not present; run switchboard build for integration coverage") + } + t.Fatal(err) + } + manifestData, err := os.ReadFile(filepath.Join(dist, "manifest.json")) + if err != nil { + t.Fatal(err) + } + manifest, err := bundle.ParseManifest(manifestData) + if err != nil { + t.Fatal(err) + } + checksumData, err := os.ReadFile(filepath.Join(dist, "checksum.txt")) + if err != nil { + t.Fatal(err) + } + checksum := strings.TrimSpace(string(checksumData)) + + ctx := context.Background() + wasmRuntime, err := engine.NewWasmRuntime(ctx) + if err != nil { + t.Fatal(err) + } + defer wasmRuntime.Close(ctx) + runtime, err := engine.NewRuntime(ctx, wasmRuntime, bundle.Bundle{ + ID: manifest.Version, + Module: module, + Manifest: manifest, + Checksum: checksum, + }, engine.InvokeLimits{Timeout: 500 * time.Millisecond}, 1) + if err != nil { + t.Fatal(err) + } + + reg := prometheus.NewRegistry() + metrics, err := httpadapter.NewMetrics(reg, nil) + if err != nil { + t.Fatal(err) + } + handler := &Switchboard{FailMode: "open", manager: &engine.RuntimeManager{}, metrics: metrics} + handler.manager.Activate(runtime) + + req := httptest.NewRequest(http.MethodGet, "/blocked", nil) + if err := handler.ServeHTTP(httptest.NewRecorder(), req, &nextHandler{}); err != nil { + t.Fatal(err) + } + + families, err := reg.Gather() + if err != nil { + t.Fatal(err) + } + var total float64 + for _, family := range families { + if family.GetName() != "switchboard_invocations_total" { + continue + } + for _, metric := range family.GetMetric() { + total += metric.GetCounter().GetValue() + } + } + if total == 0 { + t.Fatal("expected switchboard_invocations_total to be recorded via the Caddy handler") + } +} diff --git a/cmd/switchboard/build.go b/cmd/switchboard/build.go index c80479a..7543809 100644 --- a/cmd/switchboard/build.go +++ b/cmd/switchboard/build.go @@ -13,15 +13,20 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/ethndotsh/switchboard/engine" + "github.com/ethndotsh/switchboard/internal/bundle" ) func build(ctx context.Context, args []string) error { cfg := loadConfigOrDefault() - args = normalizeFlagArgs(args, "out", "name", "cases", "tinygo-opt", "tinygo-panic", "wasm-opt-level") + args = normalizeFlagArgs(args, "out", "name", "cases", "data", "max-data-bytes", "tinygo-opt", "tinygo-panic", "wasm-opt-level") fs := flag.NewFlagSet("build", flag.ContinueOnError) out := fs.String("out", cfg.Dist, "output directory") name := fs.String("name", cfg.Name, "bundle name") cases := fs.String("cases", cfg.Tests, "tests.yaml to embed in the bundle") + dataDir := fs.String("data", cfg.Data, "directory of read-only data files to embed in the bundle") + maxDataBytes := fs.String("max-data-bytes", cfg.MaxDataBytes, "maximum total size of embedded data files") skipTidy := fs.Bool("skip-tidy", false, "skip go mod tidy before TinyGo build") tinyGoOpt := fs.String("tinygo-opt", "2", "TinyGo optimization level: 0, 1, 2, s, or z") tinyGoPanic := fs.String("tinygo-panic", "trap", "TinyGo panic strategy: trap or print") @@ -86,22 +91,87 @@ func build(ctx context.Context, args []string) error { if err != nil { return err } + data, err := resolveDataDir(*dataDir, source) + if err != nil { + return err + } + maxData := engine.DefaultMaxDataBytes + if *maxDataBytes != "" { + limit, err := engine.ParseByteSize(*maxDataBytes) + if err != nil { + return fmt.Errorf("invalid max-data-bytes: %w", err) + } + maxData = int(limit) + } result, err := writeBundleArtifacts(*out, module, bundleArtifactOptions{ - Name: bundleName, - Tests: tests, - Provenance: buildProvenance(ctx), + Name: bundleName, + Tests: tests, + Data: data, + MaxDataBytes: maxData, + Provenance: buildProvenance(ctx), }) if err != nil { return err } - if len(tests) > 0 { - fmt.Printf("built %s (%s, %d embedded test cases)\n", abbreviateBundleID(result.BundleID), result.Checksum, result.TestCases) - } else { - fmt.Printf("built %s (%s)\n", abbreviateBundleID(result.BundleID), result.Checksum) + summary := fmt.Sprintf("built %s (%s", abbreviateBundleID(result.BundleID), result.Checksum) + if result.TestCases > 0 { + summary += fmt.Sprintf(", %d embedded test cases", result.TestCases) + } + if result.DataFiles > 0 { + summary += fmt.Sprintf(", %d data files", result.DataFiles) } + fmt.Println(summary + ")") return nil } +// resolveDataDir reads a directory of read-only data files, keyed by their +// bundle artifact name (data/). An explicit path must exist; the default +// data/ directory next to the rule is optional. +func resolveDataDir(explicit, rulePath string) (map[string][]byte, error) { + dir := explicit + optional := false + if dir == "" { + dir = filepath.Join(rulePath, "data") + optional = true + } + info, err := os.Stat(dir) + if err != nil { + if optional && errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read data dir: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("data path %s is not a directory", dir) + } + data := map[string][]byte{} + walkErr := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + contents, err := os.ReadFile(path) + if err != nil { + return err + } + data[bundle.DataPrefix+filepath.ToSlash(rel)] = contents + return nil + }) + if walkErr != nil { + return nil, walkErr + } + if len(data) == 0 { + return nil, nil + } + return data, nil +} + // resolveTestsFile prefers an explicit path, then tests.yaml next to the // rule package. func resolveTestsFile(explicit, rulePath string) ([]byte, error) { diff --git a/cmd/switchboard/bundleio.go b/cmd/switchboard/bundleio.go index efecb56..5de1818 100644 --- a/cmd/switchboard/bundleio.go +++ b/cmd/switchboard/bundleio.go @@ -17,15 +17,18 @@ import ( ) type bundleArtifactOptions struct { - Name string - Tests []byte - Provenance bundle.Provenance + Name string + Tests []byte + Data map[string][]byte + MaxDataBytes int + Provenance bundle.Provenance } type bundleArtifactResult struct { BundleID string Checksum string TestCases int + DataFiles int } // writeBundleArtifacts packages a compiled module into a dist directory; @@ -47,6 +50,14 @@ func writeBundleArtifacts(out string, module []byte, opts bundleArtifactOptions) testCases = len(suite.Cases) artifacts[bundle.ArtifactTests] = opts.Tests } + dataTotal := 0 + for name, data := range opts.Data { + artifacts[name] = data + dataTotal += len(data) + } + if opts.MaxDataBytes > 0 && dataTotal > opts.MaxDataBytes { + return bundleArtifactResult{}, fmt.Errorf("bundle data %d bytes exceeds max_data_bytes %d", dataTotal, opts.MaxDataBytes) + } descriptor := bundle.NewDescriptor(identity, artifacts) bundleID, err := descriptor.BundleID() @@ -83,12 +94,21 @@ func writeBundleArtifacts(out string, module []byte, opts bundleArtifactOptions) // A stale tests.yaml would break descriptor verification. _ = os.Remove(filepath.Join(out, "tests.yaml")) } + for name, data := range opts.Data { + files[name] = data + } for name, data := range files { - if err := os.WriteFile(filepath.Join(out, name), data, 0o644); err != nil { + path := filepath.Join(out, filepath.FromSlash(name)) + if dir := filepath.Dir(path); dir != out { + if err := os.MkdirAll(dir, 0o755); err != nil { + return bundleArtifactResult{}, err + } + } + if err := os.WriteFile(path, data, 0o644); err != nil { return bundleArtifactResult{}, err } } - return bundleArtifactResult{BundleID: bundleID, Checksum: checksum, TestCases: testCases}, nil + return bundleArtifactResult{BundleID: bundleID, Checksum: checksum, TestCases: testCases, DataFiles: len(opts.Data)}, nil } func readBundleDir(dir string) (bundle.Bundle, error) { @@ -122,13 +142,24 @@ func readBundleDir(dir string) (bundle.Bundle, error) { return bundle.Bundle{}, err } files := map[string][]byte{bundle.ArtifactModule: module} - if _, declared := descriptor.Artifacts[bundle.ArtifactTests]; declared { - tests, err := os.ReadFile(filepath.Join(dir, "tests.yaml")) + for name := range descriptor.Artifacts { + if name == bundle.ArtifactModule { + continue + } + data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name))) if err != nil { - return bundle.Bundle{}, fmt.Errorf("descriptor declares tests.yaml but it is missing: %w", err) + return bundle.Bundle{}, fmt.Errorf("descriptor declares %s but it is missing: %w", name, err) + } + files[name] = data + switch { + case name == bundle.ArtifactTests: + b.Tests = data + case bundle.IsDataArtifact(name): + if b.Data == nil { + b.Data = map[string][]byte{} + } + b.Data[name] = data } - files[bundle.ArtifactTests] = tests - b.Tests = tests } if err := descriptor.Verify(files); err != nil { return bundle.Bundle{}, err diff --git a/cmd/switchboard/main.go b/cmd/switchboard/main.go index 5c8bb58..c302afd 100644 --- a/cmd/switchboard/main.go +++ b/cmd/switchboard/main.go @@ -8,7 +8,7 @@ import ( "strings" ) -const version = "0.1.0" +const version = "0.2.0" func main() { if err := run(context.Background(), os.Args[1:]); err != nil { diff --git a/cmd/switchboard/project.go b/cmd/switchboard/project.go index 77e0f1f..4e9fbc7 100644 --- a/cmd/switchboard/project.go +++ b/cmd/switchboard/project.go @@ -13,13 +13,15 @@ import ( ) type projectConfig struct { - Name string `yaml:"name"` - Rule string `yaml:"rule"` - Dist string `yaml:"dist"` - Tests string `yaml:"tests,omitempty"` - Namespace string `yaml:"namespace,omitempty"` - Channel string `yaml:"channel"` - Registry string `yaml:"registry,omitempty"` + Name string `yaml:"name"` + Rule string `yaml:"rule"` + Dist string `yaml:"dist"` + Tests string `yaml:"tests,omitempty"` + Data string `yaml:"data,omitempty"` + MaxDataBytes string `yaml:"max_data_bytes,omitempty"` + Namespace string `yaml:"namespace,omitempty"` + Channel string `yaml:"channel"` + Registry string `yaml:"registry,omitempty"` } func defaultConfig() projectConfig { diff --git a/cmd/switchboard/serve.go b/cmd/switchboard/serve.go index d3066eb..d7356f5 100644 --- a/cmd/switchboard/serve.go +++ b/cmd/switchboard/serve.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "errors" "flag" "fmt" @@ -25,7 +24,9 @@ import ( func serveCommand(ctx context.Context, args []string) error { cfg := loadConfigOrDefault() args = normalizeFlagArgs(args, "listen", "upstream", "registry", "namespace", "channel", - "status-listen", "fail-mode", "poll-interval", "invoke-timeout", "cache-dir") + "status-listen", "fail-mode", "fallback-fail-mode", "poll-interval", "invoke-timeout", + "memory-limit", "max-action-bytes", "max-header-ops", "max-response-body", "max-data-bytes", + "cache-dir", "bootstrap-from-cache", "pool-autoscale", "pool-size", "min-pool-size", "max-pool-size") fs := flag.NewFlagSet("serve", flag.ContinueOnError) listen := fs.String("listen", ":8080", "listen address") upstream := fs.String("upstream", "", "upstream URL or host:port to proxy to") @@ -34,9 +35,20 @@ func serveCommand(ctx context.Context, args []string) error { channel := fs.String("channel", cfg.Channel, "channel name") statusListen := fs.String("status-listen", "", "separate listen address for /switchboard/status and /metrics (default: same listener)") failMode := fs.String("fail-mode", "open", "fail mode: open, closed, or last_good") + fallbackFailMode := fs.String("fallback-fail-mode", "", "fallback fail mode when fail-mode is last_good: open or closed") pollInterval := fs.String("poll-interval", "", "registry poll interval (default 2s)") invokeTimeout := fs.String("invoke-timeout", "", "invocation timeout (default 50ms)") + memoryLimit := fs.String("memory-limit", "", "guest memory limit (default 32mb)") + maxActionBytes := fs.String("max-action-bytes", "", "maximum encoded action size (default 64kb)") + maxHeaderOps := fs.Int("max-header-ops", 0, "maximum header operations per action (default 32)") + maxResponseBody := fs.String("max-response-body", "", "maximum respond body size (default 8kb)") + maxDataBytes := fs.String("max-data-bytes", "", "maximum total bundle data size (default 4mb)") cacheDir := fs.String("cache-dir", "", "durable last-known-good cache directory") + bootstrapFromCache := fs.String("bootstrap-from-cache", "", "activate the cached bundle before the registry: on/off (default on with cache-dir)") + poolAutoscale := fs.String("pool-autoscale", "", "autoscale the instance pool: on/off (default on)") + poolSize := fs.Int("pool-size", 0, "instance pool size (default 16)") + minPoolSize := fs.Int("min-pool-size", 0, "minimum instance pool size") + maxPoolSize := fs.Int("max-pool-size", 0, "maximum instance pool size") if err := fs.Parse(args); err != nil { return err } @@ -55,13 +67,24 @@ func serveCommand(ctx context.Context, args []string) error { defer logger.Sync() service, err := engine.Start(ctx, engine.Config{ - RegistryURL: *registryURL, - Namespace: *namespace, - Channel: *channel, - PollInterval: *pollInterval, - FailMode: *failMode, - InvokeTimeout: *invokeTimeout, - CacheDir: *cacheDir, + RegistryURL: *registryURL, + Namespace: *namespace, + Channel: *channel, + PollInterval: *pollInterval, + FailMode: *failMode, + FallbackFailMode: *fallbackFailMode, + InvokeTimeout: *invokeTimeout, + MemoryLimit: *memoryLimit, + MaxActionBytes: *maxActionBytes, + MaxHeaderOps: *maxHeaderOps, + MaxResponseBody: *maxResponseBody, + MaxDataBytes: *maxDataBytes, + CacheDir: *cacheDir, + BootstrapFromCache: *bootstrapFromCache, + PoolAutoscale: *poolAutoscale, + PoolSize: *poolSize, + MinPoolSize: *minPoolSize, + MaxPoolSize: *maxPoolSize, }, logger) if err != nil { return err @@ -76,21 +99,14 @@ func serveCommand(ctx context.Context, args []string) error { proxy := httputil.NewSingleHostReverseProxy(upstreamURL) handler := httpadapter.Middleware(service, httpadapter.Options{ - FailMode: *failMode, - Logger: logger, - Metrics: metrics, + FailMode: *failMode, + Logger: logger, + Metrics: metrics, + OnDecision: logDecision(logger), })(proxy) statusMux := http.NewServeMux() - statusMux.HandleFunc("/switchboard/status", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - data, err := statusJSON(service) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - _, _ = w.Write(data) - }) + statusMux.Handle("/switchboard/status", httpadapter.StatusHandler(service)) statusMux.Handle("/metrics", promhttp.HandlerFor(promRegistry, promhttp.HandlerOpts{})) mainMux := http.NewServeMux() @@ -146,6 +162,26 @@ func parseUpstream(raw string) (*url.URL, error) { return parsed, nil } -func statusJSON(service *engine.Service) ([]byte, error) { - return json.MarshalIndent(service.Status(), "", " ") +// logDecision surfaces each rule decision as structured access-log fields, the +// standalone equivalent of the Caddy handler exposing decision metadata. +func logDecision(logger *zap.Logger) func(*http.Request, engine.InvokeResult) { + return func(r *http.Request, result engine.InvokeResult) { + // Check first so the field slice is not built per request when debug + // logging is off. + if !logger.Core().Enabled(zap.DebugLevel) { + return + } + fields := []zap.Field{ + zap.String("path", r.URL.Path), + zap.String("decision", string(result.Action.Decision)), + zap.String("bundle_id", result.BundleID), + } + if result.Action.Reason != "" { + fields = append(fields, zap.String("reason", result.Action.Reason)) + } + for key, value := range result.Action.Metadata { + fields = append(fields, zap.String("metadata."+key, value)) + } + logger.Debug("switchboard decision", fields...) + } } diff --git a/docs/content/concepts/bundles-and-channels.mdx b/docs/content/concepts/bundles-and-channels.mdx index 8327c2f..1bbb315 100644 --- a/docs/content/concepts/bundles-and-channels.mdx +++ b/docs/content/concepts/bundles-and-channels.mdx @@ -9,7 +9,7 @@ Three primitives make Switchboard deployments idempotent, auditable, and reversi ## Bundles -A **bundle** is the immutable unit of deployment: a compiled Wasm module, its embedded test suite, a manifest, and a descriptor that ties them together. The **bundle ID** is a sha256 digest over the descriptor's identity fields, which has two useful consequences: +A **bundle** is the immutable unit of deployment: a compiled Wasm module, its embedded test suite, any read-only [data files](/guides/data-files), a manifest, and a descriptor that ties them together. The **bundle ID** is a sha256 digest over the descriptor's identity fields (including every artifact's digest, so data files are part of identity too), which has two useful consequences: - **Rebuilds deduplicate.** Byte-identical content produces the same ID, so re-deploying unchanged rules skips the upload entirely. Provenance (build time, builder, commit) is an annotation and never changes identity. - **Content can't drift.** Every registry verifies every digest on upload and download, so a corrupted or tampered bundle fails before it can ever activate. diff --git a/docs/content/guides/data-files.mdx b/docs/content/guides/data-files.mdx new file mode 100644 index 0000000..81fca13 --- /dev/null +++ b/docs/content/guides/data-files.mdx @@ -0,0 +1,89 @@ +--- +title: Bundle data files +description: "Ship read-only lookup tables inside a bundle: allowlists, redirect maps, cohort tables, hashed into identity and exposed to the rule." +sidebar: + label: Data files +--- + +Rules are pure functions with no network, no state, and no body access. That keeps them deterministic and replayable, but it also means a rule can't look anything up. **Data files** close that gap without breaking the model: read-only files baked into the bundle, exposed to the rule through the SDK. + +Because the data is embedded in the bundle, it is immutable, hashed into the [bundle's identity](/concepts/bundles-and-channels#bundles), and validated by the same [activation gates](/concepts/reliability#activation-gates) as the code. Changing a data file produces a new bundle ID that must pass its embedded tests before it can activate, and rolls back exactly like a code change. A decision stays a pure function of the request, so [replay](/guides/replay) is still exact. + +Use data files for lookups that change at roughly the same cadence as the rule: IP allow/deny lists, redirect maps, feature-flag tables, canary cohorts. For data that changes far faster than the code, a deploy per change is the wrong tool. + +## Adding data to a bundle + +Put files in a `data/` directory next to the rule: + +```txt +my-rules/ + rule.go + tests.yaml + data/ + allowlist.txt + flags.json +``` + +`switchboard build` picks up `data/` automatically. Point elsewhere with `--data ./path`, and cap the total embedded size with `--max-data-bytes` (default 4mb): + +```sh +switchboard build --data ./config --max-data-bytes 8mb +``` + +Each file is added to the [descriptor](/reference/bundle-format) under a `data/` artifact with its own digest, so tampering with a data file fails verification just like tampering with the module. + +## Reading data in a rule + +Files are named relative to the data directory. The SDK parses and caches each file on first use, so the per-request cost is a map lookup. + +```go rule.go +package gate + +import "github.com/ethndotsh/switchboard/sdk" + +func Handle(req sdk.Request) sdk.Action { + if sdk.DataSet("allowlist.txt").Contains(req.ClientIP()) { + return sdk.Next() + } + return sdk.Deny(403).WithReason("not-allowlisted") +} +``` + +Pick the accessor for your file's shape: `DataSet` and `DataLines` for newline lists, `DataJSON` and `DataTOML` to unmarshal structured config into a struct, `DataJSONL` for JSON Lines, `DataCSV` for tables, and `DataBytes`/`DataString` for the raw contents. The [SDK reference](/reference/sdk#data-files) has the full signatures. + +Two things worth knowing while you write: + +- **A missing file is never an error** — the typed accessors leave their target empty and return nil, so a rule degrades predictably rather than trapping. +- **You only pay for the formats you use.** Each parser is linked into the guest only when its accessor is called (TinyGo strips the rest), so a `DataSet`-only rule stays tiny while a `DataTOML` rule links the TOML parser. + +:::note[No YAML] +YAML is intentionally absent: `gopkg.in/yaml.v3` fails at runtime under TinyGo. Use JSON or TOML for structured config. +::: + +See the [`ip-allowlist` example](https://github.com/ethndotsh/switchboard/tree/master/examples/ip-allowlist) for a `DataSet` membership rule, and [`feature-flags`](https://github.com/ethndotsh/switchboard/tree/master/examples/feature-flags) for typed struct config via `DataJSON`. + +## Testing with data + +The embedded test suite runs against the exact bundled data, on the same runtime that will serve traffic, so "tests passed" means "passed with this data." No mocking is needed; just write cases that depend on the lookup: + +```yaml tests.yaml +cases: + - name: allowlisted client passes + request: + client_ip: 203.0.113.7 + expect: + action: next +``` + +For pure-Go unit tests of a rule, inject data with `sdk.LoadTestData`: + +```go +sdk.LoadTestData(map[string][]byte{"allowlist.txt": []byte("203.0.113.7\n")}) +if got := Handle(sdk.NewRequest(sdk.RequestData{ClientIP: "203.0.113.7"})); got.Decision != sdk.DecisionNext { + t.Fatal("expected allowlisted client to pass") +} +``` + +## Limits + +The total size of a bundle's data is capped by `max_data_bytes` (default 4mb), enforced both at build time and as an activation gate: a bundle whose data exceeds the cap is quarantined rather than activated. Raise it in `switchboard.yaml` or per proxy with the `max_data_bytes` Caddyfile directive / `--max-data-bytes` serve flag. diff --git a/docs/content/guides/meta.ts b/docs/content/guides/meta.ts index a9421a0..4cd7a2f 100644 --- a/docs/content/guides/meta.ts +++ b/docs/content/guides/meta.ts @@ -5,6 +5,7 @@ export default defineMeta({ order: 3, pages: [ "writing-rules", + "data-files", "testing", "replay", "deploying", diff --git a/docs/content/guides/standalone.mdx b/docs/content/guides/standalone.mdx index b9232c3..219ceea 100644 --- a/docs/content/guides/standalone.mdx +++ b/docs/content/guides/standalone.mdx @@ -17,7 +17,7 @@ switchboard serve \ --channel dev ``` -It runs the same engine and reconciler as the Caddy module, and honors `--fail-mode` (`open`, `closed`, `last_good`) and `--cache-dir` for [durable last-known-good](/concepts/reliability#durable-last-known-good). +It runs the same engine and reconciler as the Caddy module, and exposes the same configuration surface: `--fail-mode` (`open`, `closed`, `last_good`), `--cache-dir` for [durable last-known-good](/concepts/reliability#durable-last-known-good), the execution limits (`--memory-limit`, `--max-action-bytes`, `--max-response-body`, `--max-data-bytes`), and pool sizing (`--pool-size`, `--min-pool-size`, `--max-pool-size`, `--pool-autoscale`). These are the Caddyfile directives in kebab-case; see the [CLI reference](/reference/cli#serve). Built-in endpoints: diff --git a/docs/content/reference/bundle-format.mdx b/docs/content/reference/bundle-format.mdx index 6e2df49..5c934af 100644 --- a/docs/content/reference/bundle-format.mdx +++ b/docs/content/reference/bundle-format.mdx @@ -10,16 +10,17 @@ A bundle is a directory of artifacts described by a `descriptor.json` with schem ```json descriptor.json { "schema": "switchboard.descriptor/v1", - "abi": "switchboard/v3", + "abi": "switchboard/v4", "manifest": { "name": "my-rules", - "abi": "switchboard/v3", + "abi": "switchboard/v4", "entrypoint": "handle", "language": "go-tinygo" }, "artifacts": { "module.wasm": { "digest": "sha256:…", "size": 30762 }, - "tests.yaml": { "digest": "sha256:…", "size": 372 } + "tests.yaml": { "digest": "sha256:…", "size": 372 }, + "data/allowlist.txt": { "digest": "sha256:…", "size": 172 } }, "provenance": { "built_at": "2026-07-16T14:02:58Z", @@ -33,9 +34,9 @@ A bundle is a directory of artifacts described by a `descriptor.json` with schem | Field | In identity | Description | | --- | --- | --- | | `schema` | yes | Descriptor schema version. | -| `abi` | yes | Host-function ABI the module targets (`switchboard/v3`). | +| `abi` | yes | Host-function ABI the module targets (`switchboard/v4`). | | `manifest` | yes | Bundle name, ABI, entrypoint export, source language. | -| `artifacts` | yes | Digest and size of every artifact in the bundle. | +| `artifacts` | yes | Digest and size of every artifact: `module.wasm`, optional `tests.yaml`, and any [data files](/guides/data-files) under `data/`. | | `provenance` | no | Build time, builder version, source commit. Annotation only. | | `signatures` | no | Reserved for future bundle signing (Ed25519/Sigstore planned). | @@ -59,14 +60,17 @@ revisions/prod/0000000042.json bundles/sha256-/module.wasm bundles/sha256-/manifest.json bundles/sha256-/tests.yaml +bundles/sha256-/data/allowlist.txt bundles/sha256-/checksum.txt bundles/sha256-/descriptor.json ``` +`descriptor.json` is written last, so its presence marks a complete upload after every other object (including `data/` files) is durably in place. + With a [namespace](/concepts/bundles-and-channels#namespaces), everything nests under `namespaces//…`. ## Compatibility - `checksum.txt` (a module-only digest) is still written and verified for compatibility, but `descriptor.json` is authoritative. - Legacy bundles without a descriptor still load, with a warning. -- Proxies refuse bundles whose `abi` they don't support. +- Proxies refuse bundles whose `abi` they don't support. The current ABI is `switchboard/v4`; bundles built against `switchboard/v3` or earlier must be rebuilt with the current SDK. diff --git a/docs/content/reference/caddyfile.mdx b/docs/content/reference/caddyfile.mdx index 6a768b7..f9514e8 100644 --- a/docs/content/reference/caddyfile.mdx +++ b/docs/content/reference/caddyfile.mdx @@ -57,6 +57,20 @@ All five are [hard bounds enforced per invocation](/concepts/reliability#executi | `max_header_ops` | `32` | Request + response header operations combined. | | `max_response_body` | `8kb` | Maximum `Respond` body size. | +## Data files + +| Directive | Default | Description | +| --- | --- | --- | +| `max_data_bytes` | `4mb` | Maximum total size of a bundle's [data files](/guides/data-files). A bundle exceeding it is quarantined at activation, not clamped. | + +## Observability + +| Directive | Default | Description | +| --- | --- | --- | +| `status_path` | — | When set (e.g. `/switchboard/status`), requests to that path are served the status JSON instead of being proxied. | + +The handler registers `switchboard_*` metrics on Caddy's default Prometheus registry, so they appear on Caddy's [`/metrics` endpoint](/reference/observability) alongside Caddy's own metrics. Decision, reason, and bundle ID are also exposed as Caddy request variables and access-log fields. + ## Cache | Directive | Default | Description | diff --git a/docs/content/reference/cli.mdx b/docs/content/reference/cli.mdx index 64023d7..13438d6 100644 --- a/docs/content/reference/cli.mdx +++ b/docs/content/reference/cli.mdx @@ -67,6 +67,8 @@ switchboard build | `--out` | config `dist` | Output directory. | | `--name` | config `name` | Bundle name. | | `--cases` | config `tests` | `tests.yaml` to embed in the bundle. | +| `--data` | config `data` | Directory of read-only [data files](/guides/data-files) to embed. Defaults to `data/` next to the rule. | +| `--max-data-bytes` | config `max_data_bytes` (4mb) | Maximum total size of embedded data files. | | `--skip-tidy` | `false` | Skip `go mod tidy` before the TinyGo build. | | `--tinygo-opt` | `2` | TinyGo optimization level: `0`, `1`, `2`, `s`, or `z`. | | `--tinygo-panic` | `trap` | TinyGo panic strategy: `trap` or `print`. | @@ -223,6 +225,19 @@ switchboard serve --listen :8080 --upstream localhost:3000 \ | `--channel` | config | Channel name. | | `--status-listen` | main listener | Separate listen address for `/switchboard/status` and `/metrics`. | | `--fail-mode` | `open` | `open`, `closed`, or `last_good`. | +| `--fallback-fail-mode` | `open` | Fallback when `--fail-mode last_good` can't apply: `open` or `closed`. | | `--poll-interval` | `2s` | Registry poll interval. | | `--invoke-timeout` | `50ms` | Invocation timeout. | +| `--memory-limit` | `32mb` | Guest linear-memory cap. | +| `--max-action-bytes` | `64kb` | Maximum encoded action size. | +| `--max-header-ops` | `32` | Maximum header operations per action. | +| `--max-response-body` | `8kb` | Maximum `respond` body size. | +| `--max-data-bytes` | `4mb` | Maximum total bundle data size. | | `--cache-dir` | — | Durable last-known-good cache directory. | +| `--bootstrap-from-cache` | on with `--cache-dir` | Activate the cached bundle before reaching the registry: `on`/`off`. | +| `--pool-autoscale` | `on` | Autoscale the instance pool: `on`/`off`. | +| `--pool-size` | `16` | Instance pool size (seeds min when min is unset). | +| `--min-pool-size` | `16` | Minimum instance pool size. | +| `--max-pool-size` | `min×4` (≤128) | Maximum instance pool size. | + +These are the same knobs as the [Caddyfile directives](/reference/caddyfile), in kebab-case; the snake_case directive names map one to one. diff --git a/docs/content/reference/observability.mdx b/docs/content/reference/observability.mdx index a4d76f0..4bdd56d 100644 --- a/docs/content/reference/observability.mdx +++ b/docs/content/reference/observability.mdx @@ -3,19 +3,25 @@ title: Observability description: The status endpoint, Prometheus metrics, and per-request decision fields in access logs. --- -## Status endpoint Standalone only +The status endpoint and metric set are the same across every adapter; each front-end only chooses how to expose them. -`GET /switchboard/status` (on `switchboard serve`, and `Service.Status()` when [embedding](/guides/standalone#embedding-in-a-go-server)) returns the full engine state as JSON: +## Status endpoint All adapters + +`Service.Status()` returns the full engine state as JSON: - **Active and last-good bundle IDs**: what's serving now and what the fallback is. - **Reconciler state**: desired vs. active bundle, quarantine status, current backoff, the last activation test report, and activation counters. - **Pool stats**: target size, live instances, in-flight invocations, exhaustion count. -With `switchboard serve`, use `--status-listen :9090` to keep status and metrics off the public listener. +How to reach it per adapter: + +- **`switchboard serve`**: `GET /switchboard/status`; use `--status-listen :9090` to keep status and metrics off the public listener. +- **Caddy**: set the `status_path` directive (e.g. `status_path /switchboard/status`). +- **Embedded**: mount `httpadapter.StatusHandler(service)`, or call `Service.Status()` directly. See [embedding](/guides/standalone#embedding-in-a-go-server). -## Prometheus metrics Standalone only +## Prometheus metrics All adapters -Served at `GET /metrics`: +The same `switchboard_*` collectors are recorded by every adapter: | Metric | Type | Labels | Meaning | | --- | --- | --- | --- | @@ -26,6 +32,12 @@ Served at `GET /metrics`: | `switchboard_pool_exhaustions_total` | counter | — | Requests that found no free instance. | | `switchboard_activation_total` | counter | `result` | Bundle activation attempts, by outcome. | +Where they are served: + +- **`switchboard serve`**: `GET /metrics` (its own registry). +- **Caddy**: registered on Caddy's default registry, so they appear on Caddy's admin `/metrics` endpoint next to Caddy's own metrics. +- **Embedded**: call `httpadapter.NewMetrics(registerer, service)` and expose your registry however you like. + Labels are deliberately low-cardinality; keep tenant IDs, paths, and user IDs in logs, not metrics. Alerts worth having: `switchboard_activation_total{result="failure"}` increasing (deploys failing their gates) and any `switchboard_pool_exhaustions_total` growth (undersized pool). ## Access log fields Caddy only diff --git a/docs/content/reference/sdk.mdx b/docs/content/reference/sdk.mdx index bdca8a3..ac1f529 100644 --- a/docs/content/reference/sdk.mdx +++ b/docs/content/reference/sdk.mdx @@ -91,6 +91,24 @@ func Handle(req sdk.Request) sdk.Action { Runs each `func(sdk.Request) sdk.Action` in order. `Next()` results accumulate their request patch, response headers, metadata, and reason; later rules observe the request as rewritten so far. The first `Deny`, `Redirect`, or `Respond` short-circuits with all accumulated state. See [Writing rules](/guides/writing-rules#composing-with-chains). +## Data files + +Read-only files bundled under `data/` (see [Bundle data files](/guides/data-files)). Each accessor parses and caches its file on first use; files are named relative to the data directory. + +| Accessor | File shape | Returns | +| --- | --- | --- | +| `sdk.DataBytes(name)` | any | raw `[]byte`, nil if absent | +| `sdk.DataString(name)` | any | contents as a string | +| `sdk.DataSet(name)` | newline-delimited (blank and `#` lines ignored) | `sdk.Set`; `Set.Contains(s) bool` (unordered, deduped) | +| `sdk.DataLines(name)` | newline-delimited (blank and `#` lines ignored) | `[]string`, ordered, duplicates kept | +| `sdk.DataMap(name)` | JSON object of strings | `map[string]string`, nil if absent or invalid | +| `sdk.DataJSON(name, &v)` | JSON value | unmarshals into `v`; error on invalid JSON | +| `sdk.DataJSONL(name, &v)` | JSON Lines | appends each line into `*[]T` | +| `sdk.DataTOML(name, &v)` | TOML | unmarshals into `v`; error on invalid TOML | +| `sdk.DataCSV(name)` | CSV | `[][]string` rows | + +Each format's parser is linked into the guest only when its accessor is called, so rules pay for a format only if they use it. YAML is not supported (`yaml.v3` fails under TinyGo). `sdk.LoadTestData(map[string][]byte)` injects data for host-side rule tests; keys may include or omit the `data/` prefix. See [Bundle data files](/guides/data-files). + ## Host-side testing The SDK doubles as a plain Go library for unit tests: `sdk.NewRequest(data)` builds a populated request from an `sdk.RequestData` literal, so rule functions can be tested with `go test` before ever compiling to Wasm: @@ -109,4 +127,4 @@ Decision constants: `DecisionNext`, `DecisionDeny`, `DecisionRedirect`, `Decisio ## ABI -The SDK targets the `switchboard/v3` host-function ABI, recorded in each bundle's [descriptor](/reference/bundle-format). Request-header and response-header operations are explicitly separate; the v2 API where one operation meant different things per action is gone. +The SDK targets the `switchboard/v4` host-function ABI, recorded in each bundle's [descriptor](/reference/bundle-format). v4 adds the data-file host functions; a host requires an exact ABI match, so bundles built for an earlier ABI must be rebuilt. Request-header and response-header operations are explicitly separate; the v2 API where one operation meant different things per action is gone. diff --git a/docs/content/reference/switchboard-yaml.mdx b/docs/content/reference/switchboard-yaml.mdx index 1bc4976..cac6908 100644 --- a/docs/content/reference/switchboard-yaml.mdx +++ b/docs/content/reference/switchboard-yaml.mdx @@ -24,6 +24,8 @@ registry: file://./registry | `rule` | yes | Path to the rule package `switchboard build` compiles. | | `dist` | yes | Bundle output directory; also the default ref for `test`, `eval`, and `replay --candidate`. | | `tests` | no | Path to the `tests.yaml` embedded at build time. Defaults to the `tests.yaml` next to the rule package. | +| `data` | no | Directory of read-only [data files](/guides/data-files) embedded at build time. Defaults to the `data/` directory next to the rule package. | +| `max_data_bytes` | no | Maximum total size of embedded data files (e.g. `8mb`). Defaults to 4mb. | | `namespace` | no | Registry [namespace](/concepts/bundles-and-channels#namespaces) for tenant isolation. | | `channel` | yes | Default deploy channel. | | `registry` | no | Registry URL; see [Registries](/guides/registries) for the schemes. | diff --git a/e2e/scripts/run.sh b/e2e/scripts/run.sh index 1b7cfd0..819eb59 100644 --- a/e2e/scripts/run.sh +++ b/e2e/scripts/run.sh @@ -120,7 +120,7 @@ cat > /tmp/switchboard-dist-bad/manifest.json < 64<<20 { + return ResolvedConfig{}, fmt.Errorf("max_data_bytes must be at most 64mb") + } + resolved.MaxDataBytes = int(limit) + } switch cfg.BootstrapFromCache { case "": resolved.BootstrapFromCache = resolved.CacheDir != "" diff --git a/engine/data_test.go b/engine/data_test.go new file mode 100644 index 0000000..b484906 --- /dev/null +++ b/engine/data_test.go @@ -0,0 +1,45 @@ +package engine + +import ( + "errors" + "testing" + + "github.com/ethndotsh/switchboard/internal/bundle" +) + +func TestCheckDataSize(t *testing.T) { + data := map[string][]byte{ + "data/a.txt": []byte("hello"), + "data/b.txt": []byte("world!"), + } + if err := checkDataSize(data, 100); err != nil { + t.Fatalf("within limit should pass: %v", err) + } + if err := checkDataSize(data, 0); err != nil { + t.Fatalf("zero limit disables the check: %v", err) + } + err := checkDataSize(data, 5) + if !errors.Is(err, bundle.ErrInvalid) { + t.Fatalf("over limit = %v, want bundle.ErrInvalid", err) + } +} + +func TestResolveConfigMaxDataBytes(t *testing.T) { + resolved, err := ResolveConfig(Config{MaxDataBytes: "1mb"}) + if err != nil { + t.Fatalf("ResolveConfig: %v", err) + } + if resolved.MaxDataBytes != 1<<20 { + t.Fatalf("MaxDataBytes = %d, want %d", resolved.MaxDataBytes, 1<<20) + } + if _, err := ResolveConfig(Config{MaxDataBytes: "128mb"}); err == nil { + t.Fatal("expected error for oversize max_data_bytes") + } + def, err := ResolveConfig(Config{}) + if err != nil { + t.Fatalf("ResolveConfig default: %v", err) + } + if def.MaxDataBytes != DefaultMaxDataBytes { + t.Fatalf("default MaxDataBytes = %d, want %d", def.MaxDataBytes, DefaultMaxDataBytes) + } +} diff --git a/engine/reconciler_test.go b/engine/reconciler_test.go index c7f732b..341faa2 100644 --- a/engine/reconciler_test.go +++ b/engine/reconciler_test.go @@ -405,17 +405,17 @@ func TestParseByteSize(t *testing.T) { " 8MB ": 8 << 20, } for input, want := range cases { - got, err := parseByteSize(input) + got, err := ParseByteSize(input) if err != nil { - t.Fatalf("parseByteSize(%q): %v", input, err) + t.Fatalf("ParseByteSize(%q): %v", input, err) } if got != want { - t.Fatalf("parseByteSize(%q) = %d, want %d", input, got, want) + t.Fatalf("ParseByteSize(%q) = %d, want %d", input, got, want) } } for _, invalid := range []string{"", "-1mb", "0", "lots", "1.5mb"} { - if _, err := parseByteSize(invalid); err == nil { - t.Fatalf("parseByteSize(%q) should fail", invalid) + if _, err := ParseByteSize(invalid); err == nil { + t.Fatalf("ParseByteSize(%q) should fail", invalid) } } if pages := bytesToWasmPages(32 << 20); pages != 512 { diff --git a/engine/runtime.go b/engine/runtime.go index f1f53f3..839eceb 100644 --- a/engine/runtime.go +++ b/engine/runtime.go @@ -2,6 +2,7 @@ package engine import ( "context" + "fmt" "net/http" "sync" "sync/atomic" @@ -20,6 +21,7 @@ const ( DefaultMaxActionBytes = 64 << 10 DefaultMaxHeaderOps = 32 DefaultMaxResponseBody = 8 << 10 + DefaultMaxDataBytes = 4 << 20 ) const ( @@ -169,7 +171,10 @@ func NewRuntimeWithPoolConfig(ctx context.Context, wasmRuntime WasmRuntime, b bu if maxPoolSize < minPoolSize { maxPoolSize = minPoolSize } - compiled, err := wasmRuntime.Compile(ctx, b.Module) + if err := checkDataSize(b.Data, limits.MaxDataBytes); err != nil { + return nil, err + } + compiled, err := wasmRuntime.Compile(ctx, b.Module, b.Data) if err != nil { return nil, err } @@ -201,6 +206,23 @@ func NewRuntimeWithPoolConfig(ctx context.Context, wasmRuntime WasmRuntime, b bu return runtime, nil } +// checkDataSize rejects a bundle whose data artifacts exceed the configured +// cap; returning an ErrInvalid keeps the reconciler quarantining it rather +// than retrying. +func checkDataSize(data map[string][]byte, limit int) error { + if limit <= 0 { + return nil + } + total := 0 + for _, value := range data { + total += len(value) + } + if total > limit { + return fmt.Errorf("%w: bundle data %d bytes exceeds max_data_bytes %d", bundle.ErrInvalid, total, limit) + } + return nil +} + func resolveInvokeLimits(limits InvokeLimits) InvokeLimits { if limits.Timeout <= 0 { limits.Timeout = DefaultInvokeTimeout @@ -214,6 +236,9 @@ func resolveInvokeLimits(limits InvokeLimits) InvokeLimits { if limits.MaxResponseBody <= 0 { limits.MaxResponseBody = DefaultMaxResponseBody } + if limits.MaxDataBytes <= 0 { + limits.MaxDataBytes = DefaultMaxDataBytes + } return limits } diff --git a/engine/size.go b/engine/size.go index cf4a6ab..61aae62 100644 --- a/engine/size.go +++ b/engine/size.go @@ -8,9 +8,9 @@ import ( const wasmPageSize = 64 << 10 -// parseByteSize parses sizes like "32mb", "64kb", or plain bytes; kb/mb/gb +// ParseByteSize parses sizes like "32mb", "64kb", or plain bytes; kb/mb/gb // and kib/mib/gib suffixes all mean powers of two. -func parseByteSize(s string) (int64, error) { +func ParseByteSize(s string) (int64, error) { trimmed := strings.ToLower(strings.TrimSpace(s)) if trimmed == "" { return 0, fmt.Errorf("empty size") diff --git a/engine/wasmapi/runtime.go b/engine/wasmapi/runtime.go index 6188688..8f9d449 100644 --- a/engine/wasmapi/runtime.go +++ b/engine/wasmapi/runtime.go @@ -23,10 +23,16 @@ type InvokeLimits struct { MaxActionBytes int MaxHeaderOps int MaxResponseBody int + // MaxDataBytes bounds the total size of a bundle's data artifacts. It is + // an activation-time gate, not a per-invocation limit. + MaxDataBytes int } type WasmRuntime interface { - Compile(ctx context.Context, module []byte) (CompiledRule, error) + // Compile builds a rule from its module bytes. data holds read-only + // bundled data files (keyed by artifact name) exposed to the guest at + // invocation time; it is immutable for the life of the compiled rule. + Compile(ctx context.Context, module []byte, data map[string][]byte) (CompiledRule, error) Close(ctx context.Context) error } diff --git a/engine/wazero/host.go b/engine/wazero/host.go index 4cf077e..e091784 100644 --- a/engine/wazero/host.go +++ b/engine/wazero/host.go @@ -3,6 +3,7 @@ package wazero import ( "context" + "github.com/ethndotsh/switchboard/internal/bundle" wazeroapi "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" ) @@ -17,10 +18,44 @@ func instantiateSwitchboardHostModule(ctx context.Context, runtime wazeroapi.Run builder := runtime.NewHostModuleBuilder("switchboard") exportRequestFunctions(builder) exportActionFunctions(builder) + exportDataFunctions(builder) _, err := builder.Instantiate(ctx) return err } +// exportDataFunctions serves read-only bundled data files to the guest. The +// guest names a file relative to the data dir (e.g. "allowlist.txt"); the host +// resolves it against the bundle's data artifacts. Values are immutable for +// the life of the instance, so the guest caches them after the first read. +func exportDataFunctions(builder wazeroapi.HostModuleBuilder) { + lookup := func(state *invocationState, name string) []byte { + if state == nil || state.data == nil { + return nil + } + return state.data[bundle.DataPrefix+name] + } + + builder.NewFunctionBuilder().WithFunc(func(ctx context.Context, mod api.Module, namePtr uint32, nameLen uint32) uint32 { + state := invocationFromContext(ctx) + name, ok := readGuestString(mod, namePtr, nameLen, maxLookupNameLen) + if !ok { + return 0 + } + return uint32(len(lookup(state, name))) + }).Export("data_read_len") + + builder.NewFunctionBuilder().WithFunc(func(ctx context.Context, mod api.Module, namePtr uint32, nameLen uint32, valuePtr uint32) { + state := invocationFromContext(ctx) + name, ok := readGuestString(mod, namePtr, nameLen, maxLookupNameLen) + if !ok { + return + } + if value := lookup(state, name); len(value) > 0 { + _ = mod.Memory().Write(valuePtr, value) + } + }).Export("read_data") +} + func exportRequestFunctions(builder wazeroapi.HostModuleBuilder) { exportStringField(builder, "method", func(s *invocationState) string { return s.request.Method }) exportStringField(builder, "path", func(s *invocationState) string { return s.request.Path }) diff --git a/engine/wazero/runtime.go b/engine/wazero/runtime.go index 85c2c9d..eb303b9 100644 --- a/engine/wazero/runtime.go +++ b/engine/wazero/runtime.go @@ -20,18 +20,21 @@ type Runtime struct { type CompiledRule struct { runtime wazeroapi.Runtime module wazeroapi.CompiledModule + data map[string][]byte } type Instance struct { module api.Module handleName string handle api.Function + data map[string][]byte } type invocationState struct { request switchboard.Request action switchboard.Action limits wasmapi.InvokeLimits + data map[string][]byte queryOnce bool queryValues url.Values actionBytes int @@ -67,12 +70,12 @@ func NewRuntime(ctx context.Context, opts wasmapi.RuntimeOptions) (wasmapi.WasmR return &Runtime{runtime: wasmRuntime, cache: cache}, nil } -func (r *Runtime) Compile(ctx context.Context, module []byte) (wasmapi.CompiledRule, error) { +func (r *Runtime) Compile(ctx context.Context, module []byte, data map[string][]byte) (wasmapi.CompiledRule, error) { compiled, err := r.runtime.CompileModule(ctx, module) if err != nil { return nil, err } - return &CompiledRule{runtime: r.runtime, module: compiled}, nil + return &CompiledRule{runtime: r.runtime, module: compiled, data: data}, nil } func (r *Runtime) Close(ctx context.Context) error { @@ -93,7 +96,7 @@ func (r *CompiledRule) Instantiate(ctx context.Context) (wasmapi.RuleInstance, e if err != nil { return nil, err } - return &Instance{module: mod}, nil + return &Instance{module: mod, data: r.data}, nil } func (r *CompiledRule) Close(ctx context.Context) error { @@ -121,6 +124,7 @@ func (i *Instance) Invoke(ctx context.Context, entrypoint string, req switchboar request: req, action: switchboard.Action{Decision: switchboard.DecisionNext}, limits: limits, + data: i.data, } ctx = context.WithValue(ctx, invocationStateKey{}, state) results, err := i.handle.Call(ctx) diff --git a/examples/feature-flags/README.md b/examples/feature-flags/README.md new file mode 100644 index 0000000..ac17b8f --- /dev/null +++ b/examples/feature-flags/README.md @@ -0,0 +1,43 @@ +# feature-flags + +Config-driven request policy from a typed data file. [`data/flags.json`](data/flags.json) +is decoded once into a Go struct with `sdk.DataJSON`; the rule reads no +hardcoded constants: + +```json +{ + "maintenance": false, + "banner": "beta program open", + "blocked_paths": ["/admin", "/internal"] +} +``` + +Flipping `maintenance`, adding a path to `blocked_paths`, or changing the banner +is a data-only edit. It still produces a new immutable bundle that must pass its +embedded tests before activating, and rolls back like any other deploy — so +"change a flag" gets the same safety as "change the code." + +## Behavior + +| Flag state | Result | +| --- | --- | +| `maintenance: true` | `respond 503`, reason `maintenance` | +| request path in `blocked_paths` | `deny 403`, reason `blocked-path` | +| otherwise | `next`, `x-banner` response header set from `banner` | + +## Data files + +This example shows the typed-config pattern; see the +[data files guide](../../docs/content/guides/data-files.mdx) for every accessor +(`DataJSON`, `DataTOML`, `DataCSV`, `DataJSONL`, `DataSet`, `DataLines`). For a +membership-list example, see [`ip-allowlist`](../ip-allowlist/). + +## Build, test, deploy + +```sh +switchboard build ./examples/feature-flags --out ./dist-example +switchboard test ./dist-example +``` + +The build embeds `tests.yaml` and `data/flags.json`; the suite runs against the +exact bundled config, so "tests passed" means "passed with these flags." diff --git a/examples/feature-flags/data/flags.json b/examples/feature-flags/data/flags.json new file mode 100644 index 0000000..5a35c01 --- /dev/null +++ b/examples/feature-flags/data/flags.json @@ -0,0 +1,5 @@ +{ + "maintenance": false, + "banner": "beta program open", + "blocked_paths": ["/admin", "/internal"] +} diff --git a/examples/feature-flags/rule.go b/examples/feature-flags/rule.go new file mode 100644 index 0000000..220d85a --- /dev/null +++ b/examples/feature-flags/rule.go @@ -0,0 +1,34 @@ +// Package featureflags drives request policy from a typed config file bundled +// with the rule. data/flags.json is decoded once into a struct via +// sdk.DataJSON; changing behavior (flip maintenance, block a path, change the +// banner) is a data-only edit that still ships as a validated, versioned +// bundle with the same rollback guarantees as a code change. +package featureflags + +import "github.com/ethndotsh/switchboard/sdk" + +type flags struct { + Maintenance bool `json:"maintenance"` + Banner string `json:"banner"` + BlockedPaths []string `json:"blocked_paths"` +} + +func Handle(req sdk.Request) sdk.Action { + var f flags + if err := sdk.DataJSON("flags.json", &f); err != nil { + return sdk.Deny(500).WithReason("bad-flags") + } + if f.Maintenance { + return sdk.Respond(503, "down for maintenance").WithReason("maintenance") + } + for _, path := range f.BlockedPaths { + if req.Path() == path { + return sdk.Deny(403).WithReason("blocked-path") + } + } + action := sdk.Next().WithReason("ok") + if f.Banner != "" { + action = action.SetResponseHeader("x-banner", f.Banner) + } + return action +} diff --git a/examples/feature-flags/tests.yaml b/examples/feature-flags/tests.yaml new file mode 100644 index 0000000..e74a507 --- /dev/null +++ b/examples/feature-flags/tests.yaml @@ -0,0 +1,26 @@ +schema: switchboard.tests/v1 +cases: + - name: normal request passes with banner header + request: + path: / + expect: + action: next + reason: ok + response_headers: + x-banner: beta program open + + - name: blocked path is denied + request: + path: /admin + expect: + action: deny + status: 403 + reason: blocked-path + + - name: another blocked path is denied + request: + path: /internal + expect: + action: deny + status: 403 + reason: blocked-path diff --git a/examples/ip-allowlist/README.md b/examples/ip-allowlist/README.md new file mode 100644 index 0000000..2ee8faf --- /dev/null +++ b/examples/ip-allowlist/README.md @@ -0,0 +1,41 @@ +# ip-allowlist + +Deny every request whose client IP is not on an allowlist that ships *inside* +the bundle. The list lives in [`data/allowlist.txt`](data/allowlist.txt), a +read-only data file embedded at build time. Because data files are hashed into +the bundle's identity, the allowlist is versioned, validated, and rolled back +exactly like the rule code: changing an entry produces a new immutable bundle +that must pass its embedded tests before it can activate. + +The rule reads the file through `sdk.DataSet`, which parses the newline-delimited +list into a membership set once and caches it for the life of the instance, so +the per-request cost is a single map lookup. + +## Behavior + +| Request client IP | Result | +| --- | --- | +| present in `allowlist.txt` | `next`, reason `allowlisted` | +| absent (or empty) | `deny 403`, reason `not-allowlisted` | + +The rule matches whole IP strings. The client IP is adapter-resolved: under +Caddy it honors `trusted_proxies`, so put the allowlist rule behind correct +proxy configuration if you terminate TLS upstream. + +## Data files + +`data/allowlist.txt` is picked up automatically from the `data/` directory next +to the rule. Point elsewhere with `switchboard build --data ./path`, and cap the +embedded size with `--max-data-bytes` (default 4mb). See the +[data files guide](../../docs/content/guides/data-files.mdx). + +## Build, test, deploy + +```sh +switchboard build ./examples/ip-allowlist --out ./dist-example +switchboard test ./dist-example +``` + +The build embeds both `tests.yaml` and `data/allowlist.txt`; the test suite runs +against the exact bundled data, so "tests passed" means "passed with this +allowlist." diff --git a/examples/ip-allowlist/data/allowlist.txt b/examples/ip-allowlist/data/allowlist.txt new file mode 100644 index 0000000..88e2557 --- /dev/null +++ b/examples/ip-allowlist/data/allowlist.txt @@ -0,0 +1,5 @@ +# Allowlisted client IPs, one per line. Blank lines and lines beginning with +# '#' are ignored. Edit and rebuild to change the policy. +203.0.113.7 +198.51.100.22 +192.0.2.10 diff --git a/examples/ip-allowlist/rule.go b/examples/ip-allowlist/rule.go new file mode 100644 index 0000000..7f11a33 --- /dev/null +++ b/examples/ip-allowlist/rule.go @@ -0,0 +1,16 @@ +// Package ipallowlist denies any request whose client IP is not in an +// allowlist bundled with the rule. The allowlist lives in data/allowlist.txt, +// a read-only data file baked into the bundle and hashed into its identity, so +// changing the list is a normal build-and-deploy with the same validation and +// rollback as a code change. The set is parsed once and cached for the life of +// the instance. +package ipallowlist + +import "github.com/ethndotsh/switchboard/sdk" + +func Handle(req sdk.Request) sdk.Action { + if sdk.DataSet("allowlist.txt").Contains(req.ClientIP()) { + return sdk.Next().WithReason("allowlisted") + } + return sdk.Deny(403).WithReason("not-allowlisted") +} diff --git a/examples/ip-allowlist/tests.yaml b/examples/ip-allowlist/tests.yaml new file mode 100644 index 0000000..e8ef20e --- /dev/null +++ b/examples/ip-allowlist/tests.yaml @@ -0,0 +1,34 @@ +schema: switchboard.tests/v1 +cases: + - name: allowlisted client passes through + request: + path: / + client_ip: 203.0.113.7 + expect: + action: next + reason: allowlisted + + - name: another allowlisted client passes through + request: + path: /admin + client_ip: 192.0.2.10 + expect: + action: next + reason: allowlisted + + - name: unknown client is denied + request: + path: / + client_ip: 10.0.0.5 + expect: + action: deny + status: 403 + reason: not-allowlisted + + - name: empty client ip is denied + request: + path: / + expect: + action: deny + status: 403 + reason: not-allowlisted diff --git a/go.mod b/go.mod index 9350694..57f9df6 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/ethndotsh/switchboard go 1.23 require ( + github.com/BurntSushi/toml v1.6.0 github.com/caddyserver/caddy/v2 v2.8.4 github.com/minio/minio-go/v7 v7.0.80 github.com/prometheus/client_golang v1.19.1 diff --git a/go.sum b/go.sum index e79435f..12114c9 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIo github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= diff --git a/internal/bundle/descriptor.go b/internal/bundle/descriptor.go index 8f22ed5..733831c 100644 --- a/internal/bundle/descriptor.go +++ b/internal/bundle/descriptor.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strings" "time" ) @@ -13,8 +14,16 @@ const DescriptorSchema = "switchboard.descriptor/v1" const ( ArtifactModule = "module.wasm" ArtifactTests = "tests.yaml" + // DataPrefix marks descriptor artifacts that are read-only data files + // bundled with the rule and exposed to the guest via the data ABI. + DataPrefix = "data/" ) +// IsDataArtifact reports whether an artifact name is a bundled data file. +func IsDataArtifact(name string) bool { + return strings.HasPrefix(name, DataPrefix) +} + type ArtifactRef struct { Digest string `json:"digest"` Size int64 `json:"size"` diff --git a/internal/bundle/descriptor_test.go b/internal/bundle/descriptor_test.go index 6d5f6c3..f0f0e28 100644 --- a/internal/bundle/descriptor_test.go +++ b/internal/bundle/descriptor_test.go @@ -41,7 +41,8 @@ func TestBundleIDGoldenValue(t *testing.T) { if err != nil { t.Fatal(err) } - const golden = "sha256-90f7b93c172c415669c2ff6d6efc5f3f52bde33e3b4a3a87cf6736d0a709731f" + // Locked to ABI switchboard/v4; the v3→v4 bump deliberately re-derives it. + const golden = "sha256-c88790f29c3bcbf35d493e4af73796b1feac2851efa95f95c5ac1b756d34a68e" if id != golden { t.Fatalf("bundle id = %s, want %s (hashing contract changed!)", id, golden) } diff --git a/internal/bundle/types.go b/internal/bundle/types.go index 7cc0787..c7b2b5c 100644 --- a/internal/bundle/types.go +++ b/internal/bundle/types.go @@ -10,7 +10,7 @@ import ( "time" ) -const ABIVersion = "switchboard/v3" +const ABIVersion = "switchboard/v4" // ErrInvalid marks a bundle that can never activate as-is; the reconciler // quarantines it instead of retrying. @@ -60,6 +60,9 @@ type Bundle struct { Tests []byte Descriptor Descriptor DescriptorRaw []byte + // Data holds read-only bundled data files keyed by their descriptor + // artifact name (e.g. "data/allowlist.txt"). + Data map[string][]byte } func ParseManifest(data []byte) (Manifest, error) { diff --git a/internal/bundle/types_test.go b/internal/bundle/types_test.go index 9f728d3..55a32bf 100644 --- a/internal/bundle/types_test.go +++ b/internal/bundle/types_test.go @@ -42,7 +42,7 @@ func TestParseChannelPointer(t *testing.T) { } func TestParseManifestRequiresABIV3(t *testing.T) { - if _, err := ParseManifest([]byte(`{"name":"rules","version":"v3","abi_version":"switchboard/v3","entrypoint":"handle"}`)); err != nil { + if _, err := ParseManifest([]byte(`{"name":"rules","version":"v3","abi_version":"switchboard/v4","entrypoint":"handle"}`)); err != nil { t.Fatalf("parse v3 manifest: %v", err) } for _, old := range []string{"switchboard/v0", "switchboard/v1", "switchboard/v2"} { diff --git a/internal/bundlecache/cache.go b/internal/bundlecache/cache.go index 9b851f3..6bebc9b 100644 --- a/internal/bundlecache/cache.go +++ b/internal/bundlecache/cache.go @@ -80,8 +80,17 @@ func (c *Cache) Store(namespace, channel string, b bundle.Bundle, meta Metadata) if len(b.Tests) > 0 { files["tests.yaml"] = b.Tests } + for name, data := range b.Data { + files[name] = data + } for name, data := range files { - if err := writeAndSync(filepath.Join(tmp, name), data); err != nil { + path := filepath.Join(tmp, filepath.FromSlash(name)) + if parent := filepath.Dir(path); parent != tmp { + if err := os.MkdirAll(parent, 0o755); err != nil { + return err + } + } + if err := writeAndSync(path, data); err != nil { return err } } @@ -139,6 +148,9 @@ func (c *Cache) Load(namespace, channel string) (bundle.Bundle, Metadata, error) Manifest: manifest, Checksum: bundle.ModuleChecksum(module), } + files := map[string][]byte{ + "module.wasm": module, + } if descriptorRaw, err := os.ReadFile(filepath.Join(current, "descriptor.json")); err == nil { descriptor, err := bundle.ParseDescriptor(descriptorRaw) if err != nil { @@ -146,20 +158,30 @@ func (c *Cache) Load(namespace, channel string) (bundle.Bundle, Metadata, error) } b.Descriptor = descriptor b.DescriptorRaw = descriptorRaw - } - if tests, err := os.ReadFile(filepath.Join(current, "tests.yaml")); err == nil { - b.Tests = tests - } - files := map[string][]byte{ - "module.wasm": module, - } - if len(b.Tests) > 0 { - files["tests.yaml"] = b.Tests - } - if len(b.DescriptorRaw) > 0 { + for name := range descriptor.Artifacts { + if name == bundle.ArtifactModule { + continue + } + data, err := os.ReadFile(filepath.Join(current, filepath.FromSlash(name))) + if err != nil { + return bundle.Bundle{}, Metadata{}, err + } + files[name] = data + switch { + case name == bundle.ArtifactTests: + b.Tests = data + case bundle.IsDataArtifact(name): + if b.Data == nil { + b.Data = map[string][]byte{} + } + b.Data[name] = data + } + } if err := b.Descriptor.Verify(files); err != nil { return bundle.Bundle{}, Metadata{}, err } + } else if tests, err := os.ReadFile(filepath.Join(current, "tests.yaml")); err == nil { + b.Tests = tests } var meta Metadata diff --git a/internal/bundlecache/cache_test.go b/internal/bundlecache/cache_test.go index 3f4eb87..debefd4 100644 --- a/internal/bundlecache/cache_test.go +++ b/internal/bundlecache/cache_test.go @@ -16,6 +16,10 @@ import ( // makeCacheBundle builds a valid descriptor-backed bundle. The manifest ABI // must be bundle.ABIVersion or Load's ParseManifest rejects the cache entry. func makeCacheBundle(t *testing.T, name string, module, tests []byte) bundle.Bundle { + return makeCacheBundleWithData(t, name, module, tests, nil) +} + +func makeCacheBundleWithData(t *testing.T, name string, module, tests []byte, data map[string][]byte) bundle.Bundle { t.Helper() identity := bundle.ManifestIdentity{ Name: name, @@ -27,6 +31,9 @@ func makeCacheBundle(t *testing.T, name string, module, tests []byte) bundle.Bun if len(tests) > 0 { artifacts[bundle.ArtifactTests] = tests } + for dataName, value := range data { + artifacts[dataName] = value + } descriptor := bundle.NewDescriptor(identity, artifacts) id, err := descriptor.BundleID() if err != nil { @@ -48,11 +55,28 @@ func makeCacheBundle(t *testing.T, name string, module, tests []byte) bundle.Bun }, Checksum: bundle.ModuleChecksum(module), Tests: tests, + Data: data, Descriptor: descriptor, DescriptorRaw: descriptorRaw, } } +func TestStoreLoadData(t *testing.T) { + cache := New(t.TempDir()) + data := map[string][]byte{"data/allowlist.txt": []byte("203.0.113.7\n")} + b := makeCacheBundleWithData(t, "gate", []byte("\x00asm"), nil, data) + if err := cache.Store("", "prod", b, testMetadata(b, "", "prod")); err != nil { + t.Fatalf("Store: %v", err) + } + got, _, err := cache.Load("", "prod") + if err != nil { + t.Fatalf("Load: %v", err) + } + if string(got.Data["data/allowlist.txt"]) != "203.0.113.7\n" { + t.Fatalf("data round-trip mismatch: %q", got.Data["data/allowlist.txt"]) + } +} + func testMetadata(b bundle.Bundle, namespace, channel string) Metadata { return Metadata{ BundleID: b.ID, diff --git a/registry/file.go b/registry/file.go index 3720f3d..cf6d9e3 100644 --- a/registry/file.go +++ b/registry/file.go @@ -103,12 +103,14 @@ func (r *FileRegistry) PutBundle(ctx context.Context, scope Scope, b bundle.Bund if err := os.MkdirAll(dir, 0o755); err != nil { return err } - for _, name := range BundleFileNames { - data, ok := files[name] - if !ok { - continue + for _, name := range bundleWriteOrder(files) { + path := filepath.Join(dir, filepath.FromSlash(name)) + if parent := filepath.Dir(path); parent != dir { + if err := os.MkdirAll(parent, 0o755); err != nil { + return err + } } - if err := writeFileAtomic(filepath.Join(dir, name), data); err != nil { + if err := writeFileAtomic(path, files[name]); err != nil { return err } } @@ -224,6 +226,9 @@ func bundleFiles(b bundle.Bundle) (map[string][]byte, error) { if len(b.Tests) > 0 { files["tests.yaml"] = b.Tests } + for name, data := range b.Data { + files[name] = data + } if len(b.DescriptorRaw) > 0 { files["descriptor.json"] = b.DescriptorRaw } diff --git a/registry/file_test.go b/registry/file_test.go index e1ec56c..13f7c27 100644 --- a/registry/file_test.go +++ b/registry/file_test.go @@ -18,6 +18,10 @@ import ( // the CLI does: content-addressed ID from the descriptor identity zone, // manifest version set to the bundle ID after hashing. func makeTestBundle(t *testing.T, name string, module, tests []byte) bundle.Bundle { + return makeTestBundleWithData(t, name, module, tests, nil) +} + +func makeTestBundleWithData(t *testing.T, name string, module, tests []byte, data map[string][]byte) bundle.Bundle { t.Helper() identity := bundle.ManifestIdentity{ Name: name, @@ -29,6 +33,9 @@ func makeTestBundle(t *testing.T, name string, module, tests []byte) bundle.Bund if len(tests) > 0 { artifacts[bundle.ArtifactTests] = tests } + for dataName, value := range data { + artifacts[dataName] = value + } descriptor := bundle.NewDescriptor(identity, artifacts) id, err := descriptor.BundleID() if err != nil { @@ -50,6 +57,7 @@ func makeTestBundle(t *testing.T, name string, module, tests []byte) bundle.Bund }, Checksum: bundle.ModuleChecksum(module), Tests: tests, + Data: data, Descriptor: descriptor, DescriptorRaw: descriptorRaw, } @@ -112,6 +120,39 @@ func TestFileBundleRoundTrip(t *testing.T) { } } +func TestFileBundleDataRoundTrip(t *testing.T) { + ctx := context.Background() + reg, root := newFileRegistry(t) + module := []byte("\x00asm-module-bytes") + data := map[string][]byte{ + "data/allowlist.txt": []byte("203.0.113.7\n192.0.2.10\n"), + "data/flags.json": []byte(`{"beta":"on"}`), + } + b := makeTestBundleWithData(t, "gate", module, nil, data) + + if err := reg.PutBundle(ctx, Scope{}, b); err != nil { + t.Fatalf("PutBundle: %v", err) + } + got, err := reg.GetBundle(ctx, Scope{}, b.ID) + if err != nil { + t.Fatalf("GetBundle: %v", err) + } + for name, want := range data { + if !bytes.Equal(got.Data[name], want) { + t.Errorf("data %s = %q, want %q", name, got.Data[name], want) + } + } + + // Tampering with a data file must fail descriptor verification. + dataPath := filepath.Join(root, "bundles", b.ID, "data", "allowlist.txt") + if err := os.WriteFile(dataPath, []byte("10.0.0.1\n"), 0o644); err != nil { + t.Fatalf("tamper data: %v", err) + } + if _, err := reg.GetBundle(ctx, Scope{}, b.ID); !errors.Is(err, bundle.ErrInvalid) { + t.Fatalf("GetBundle after data tamper = %v, want bundle.ErrInvalid", err) + } +} + func TestFileGetBundleLegacy(t *testing.T) { ctx := context.Background() reg, root := newFileRegistry(t) diff --git a/registry/registry.go b/registry/registry.go index 6eef062..367908e 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "regexp" + "sort" "strings" "github.com/ethndotsh/switchboard/internal/bundle" @@ -55,10 +56,31 @@ func revisionFileName(generation uint64) string { return fmt.Sprintf("%010d.json", generation) } -// BundleFileNames are the objects under bundles/{id}/, in upload order; -// descriptor.json goes last so its presence marks a complete bundle. +// BundleFileNames are the fixed objects under bundles/{id}/, in upload order; +// descriptor.json goes last so its presence marks a complete bundle. Data +// artifacts are named dynamically, so use bundleWriteOrder for the full set. var BundleFileNames = []string{"module.wasm", "manifest.json", "tests.yaml", "checksum.txt", "descriptor.json"} +// bundleWriteOrder returns every file name in files ordered so descriptor.json +// is written last, marking the bundle complete only once every other object +// (including data artifacts) is durably in place. +func bundleWriteOrder(files map[string][]byte) []string { + names := make([]string, 0, len(files)) + hasDescriptor := false + for name := range files { + if name == "descriptor.json" { + hasDescriptor = true + continue + } + names = append(names, name) + } + sort.Strings(names) + if hasDescriptor { + names = append(names, "descriptor.json") + } + return names +} + type fetchFunc func(name string) ([]byte, bool, error) // assembleBundle builds and verifies a bundle from per-file reads; every @@ -116,14 +138,26 @@ func assembleBundle(id string, fetch fetchFunc) (bundle.Bundle, error) { return bundle.Bundle{}, err } files := map[string][]byte{bundle.ArtifactModule: module} - if _, declared := descriptor.Artifacts[bundle.ArtifactTests]; declared { - tests, ok, err := fetch("tests.yaml") + for name := range descriptor.Artifacts { + if name == bundle.ArtifactModule { + continue + } + data, ok, err := fetch(name) if err != nil { return bundle.Bundle{}, err } - if ok { - files[bundle.ArtifactTests] = tests - b.Tests = tests + if !ok { + continue + } + files[name] = data + switch { + case name == bundle.ArtifactTests: + b.Tests = data + case bundle.IsDataArtifact(name): + if b.Data == nil { + b.Data = map[string][]byte{} + } + b.Data[name] = data } } if err := descriptor.Verify(files); err != nil { diff --git a/registry/s3.go b/registry/s3.go index 8d7798e..7fac70f 100644 --- a/registry/s3.go +++ b/registry/s3.go @@ -141,12 +141,8 @@ func (r *S3Registry) PutBundle(ctx context.Context, scope Scope, b bundle.Bundle if err != nil { return err } - for _, name := range BundleFileNames { - data, ok := files[name] - if !ok { - continue - } - if err := r.put(ctx, r.scopedKey(scope, "bundles", b.ID, name), data, contentTypeFor(name), minio.PutObjectOptions{}); err != nil { + for _, name := range bundleWriteOrder(files) { + if err := r.put(ctx, r.scopedKey(scope, "bundles", b.ID, name), files[name], contentTypeFor(name), minio.PutObjectOptions{}); err != nil { return err } } @@ -306,6 +302,8 @@ func contentTypeFor(name string) string { return "application/json" case strings.HasSuffix(name, ".yaml"), strings.HasSuffix(name, ".yml"): return "application/yaml" + case strings.HasSuffix(name, ".csv"): + return "text/csv" default: return "text/plain" } diff --git a/sdk/data.go b/sdk/data.go new file mode 100644 index 0000000..5dd188f --- /dev/null +++ b/sdk/data.go @@ -0,0 +1,193 @@ +package sdk + +import ( + "bufio" + "bytes" + "encoding/csv" + "encoding/json" + "errors" + "io" + "reflect" + "strings" + + "github.com/BurntSushi/toml" +) + +// Data accessors expose read-only files bundled under the project's data +// directory. Files are named relative to that directory (e.g. "allowlist.txt" +// for data/allowlist.txt) and are immutable for the life of the instance. +// +// The raw bytes of each file are memoized. The parsed helpers each link their +// parser into the guest only when called, so a rule pays for a format only if +// it uses it (a rule that calls just DataSet never links encoding/json or the +// TOML parser). +var ( + dataCache map[string][]byte + setCache map[string]Set + linesCache map[string][]string + mapCache map[string]map[string]string + // testData backs the accessors in host-side rule tests; LoadTestData + // fills it. In the wasm guest, data is read from the host instead. + testData map[string][]byte +) + +// LoadTestData injects bundled data files for host-side rule unit tests. Keys +// may be given with or without the "data/" prefix. It resets the accessor +// caches so successive tests see fresh data. +func LoadTestData(files map[string][]byte) { + testData = make(map[string][]byte, len(files)) + for name, value := range files { + testData[strings.TrimPrefix(name, "data/")] = value + } + dataCache = nil + setCache = nil + linesCache = nil + mapCache = nil +} + +// DataBytes returns the raw contents of a bundled data file, or nil if it is +// not present in the bundle. +func DataBytes(name string) []byte { + if dataCache == nil { + dataCache = map[string][]byte{} + } + if value, ok := dataCache[name]; ok { + return value + } + value := dataRead(name) + dataCache[name] = value + return value +} + +// DataString returns a bundled data file as a string. +func DataString(name string) string { + return string(DataBytes(name)) +} + +// DataJSON unmarshals a bundled JSON file into v (a pointer). A missing file +// leaves v untouched and returns nil. +func DataJSON(name string, v any) error { + raw := DataBytes(name) + if len(raw) == 0 { + return nil + } + return json.Unmarshal(raw, v) +} + +// DataJSONL decodes a bundled JSON Lines file (one JSON value per line) into +// out, which must be a pointer to a slice. A missing file leaves out empty. +func DataJSONL(name string, out any) error { + rv := reflect.ValueOf(out) + if rv.Kind() != reflect.Pointer || rv.Elem().Kind() != reflect.Slice { + return errors.New("DataJSONL: out must be a pointer to a slice") + } + raw := DataBytes(name) + if len(raw) == 0 { + return nil + } + slice := rv.Elem() + elemType := slice.Type().Elem() + dec := json.NewDecoder(bytes.NewReader(raw)) + for { + elem := reflect.New(elemType) + err := dec.Decode(elem.Interface()) + if err == io.EOF { + break + } + if err != nil { + return err + } + slice.Set(reflect.Append(slice, elem.Elem())) + } + return nil +} + +// DataTOML unmarshals a bundled TOML file into v (a pointer). A missing file +// leaves v untouched and returns nil. +func DataTOML(name string, v any) error { + raw := DataBytes(name) + if len(raw) == 0 { + return nil + } + return toml.Unmarshal(raw, v) +} + +// DataCSV parses a bundled CSV file into rows of fields. A missing file yields +// no rows. +func DataCSV(name string) ([][]string, error) { + raw := DataBytes(name) + if len(raw) == 0 { + return nil, nil + } + return csv.NewReader(bytes.NewReader(raw)).ReadAll() +} + +// DataMap parses a bundled JSON object file into a string map. A missing file +// or invalid JSON yields nil. +func DataMap(name string) map[string]string { + if mapCache == nil { + mapCache = map[string]map[string]string{} + } + if value, ok := mapCache[name]; ok { + return value + } + var out map[string]string + if raw := DataBytes(name); len(raw) > 0 { + if err := json.Unmarshal(raw, &out); err != nil { + out = nil + } + } + mapCache[name] = out + return out +} + +// DataLines parses a bundled newline-delimited file into ordered lines. Blank +// lines and lines beginning with '#' are ignored; order and duplicates are +// preserved (use DataSet for membership tests). +func DataLines(name string) []string { + if linesCache == nil { + linesCache = map[string][]string{} + } + if value, ok := linesCache[name]; ok { + return value + } + var lines []string + if raw := DataBytes(name); len(raw) > 0 { + scanner := bufio.NewScanner(bytes.NewReader(raw)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + lines = append(lines, line) + } + } + linesCache[name] = lines + return lines +} + +// Set is a membership lookup over newline-delimited data files. +type Set map[string]struct{} + +// Contains reports whether value is a member of the set. +func (s Set) Contains(value string) bool { + _, ok := s[value] + return ok +} + +// DataSet parses a bundled newline-delimited file into a Set. Blank lines and +// lines beginning with '#' are ignored. +func DataSet(name string) Set { + if setCache == nil { + setCache = map[string]Set{} + } + if value, ok := setCache[name]; ok { + return value + } + set := Set{} + for _, line := range DataLines(name) { + set[line] = struct{}{} + } + setCache[name] = set + return set +} diff --git a/sdk/data_test.go b/sdk/data_test.go new file mode 100644 index 0000000..4bea2d3 --- /dev/null +++ b/sdk/data_test.go @@ -0,0 +1,142 @@ +package sdk + +import "testing" + +func TestDataSet(t *testing.T) { + LoadTestData(map[string][]byte{ + "allowlist.txt": []byte("# comment\n203.0.113.7\n\n192.0.2.10\n"), + }) + set := DataSet("allowlist.txt") + if !set.Contains("203.0.113.7") || !set.Contains("192.0.2.10") { + t.Fatal("expected allowlisted entries to be present") + } + if set.Contains("# comment") || set.Contains("") || set.Contains("10.0.0.5") { + t.Fatal("comments, blanks, and absent entries must not be members") + } +} + +func TestDataLines(t *testing.T) { + LoadTestData(map[string][]byte{ + "order.txt": []byte("# ordered\nfirst\nsecond\nfirst\n\nthird\n"), + }) + lines := DataLines("order.txt") + want := []string{"first", "second", "first", "third"} + if len(lines) != len(want) { + t.Fatalf("got %v, want %v", lines, want) + } + for i := range want { + if lines[i] != want[i] { + t.Fatalf("line %d = %q, want %q", i, lines[i], want[i]) + } + } + // DataSet over the same file dedups and drops order. + if n := len(DataSet("order.txt")); n != 3 { + t.Fatalf("set size = %d, want 3", n) + } +} + +func TestDataMap(t *testing.T) { + LoadTestData(map[string][]byte{ + "flags.json": []byte(`{"beta":"on","legacy":"off"}`), + }) + flags := DataMap("flags.json") + if flags["beta"] != "on" || flags["legacy"] != "off" { + t.Fatalf("unexpected flags: %v", flags) + } + if DataMap("missing.json") != nil { + t.Fatal("missing file should yield nil map") + } +} + +func TestDataJSON(t *testing.T) { + LoadTestData(map[string][]byte{"config.json": []byte(`{"beta":true,"rollout":25,"paths":["/a","/b"]}`)}) + var cfg struct { + Beta bool `json:"beta"` + Rollout int `json:"rollout"` + Paths []string `json:"paths"` + } + if err := DataJSON("config.json", &cfg); err != nil { + t.Fatal(err) + } + if !cfg.Beta || cfg.Rollout != 25 || len(cfg.Paths) != 2 { + t.Fatalf("unexpected struct: %+v", cfg) + } + // Missing file leaves the target untouched, no error. + var empty struct{ X int } + if err := DataJSON("absent.json", &empty); err != nil { + t.Fatal(err) + } +} + +func TestDataJSONL(t *testing.T) { + LoadTestData(map[string][]byte{"rows.jsonl": []byte("{\"id\":\"a\"}\n{\"id\":\"b\"}\n{\"id\":\"c\"}\n")}) + var rows []struct { + ID string `json:"id"` + } + if err := DataJSONL("rows.jsonl", &rows); err != nil { + t.Fatal(err) + } + if len(rows) != 3 || rows[0].ID != "a" || rows[2].ID != "c" { + t.Fatalf("unexpected rows: %+v", rows) + } + if err := DataJSONL("rows.jsonl", &struct{}{}); err == nil { + t.Fatal("expected error when out is not a pointer to slice") + } +} + +func TestDataTOML(t *testing.T) { + LoadTestData(map[string][]byte{"config.toml": []byte("beta = true\nrollout = 9\n")}) + var cfg struct { + Beta bool `toml:"beta"` + Rollout int `toml:"rollout"` + } + if err := DataTOML("config.toml", &cfg); err != nil { + t.Fatal(err) + } + if !cfg.Beta || cfg.Rollout != 9 { + t.Fatalf("unexpected struct: %+v", cfg) + } +} + +func TestDataCSV(t *testing.T) { + LoadTestData(map[string][]byte{"t.csv": []byte("a,b\n1,2\n3,4\n")}) + rows, err := DataCSV("t.csv") + if err != nil { + t.Fatal(err) + } + if len(rows) != 3 || rows[0][1] != "b" || rows[2][0] != "3" { + t.Fatalf("unexpected rows: %v", rows) + } + if got, _ := DataCSV("absent.csv"); got != nil { + t.Fatal("absent CSV should be nil") + } +} + +func TestDataBytesAndString(t *testing.T) { + LoadTestData(map[string][]byte{"greeting.txt": []byte("hello")}) + if DataString("greeting.txt") != "hello" { + t.Fatal("unexpected string") + } + if DataBytes("absent.txt") != nil { + t.Fatal("absent file should be nil") + } +} + +func TestLoadTestDataStripsPrefix(t *testing.T) { + LoadTestData(map[string][]byte{"data/allowlist.txt": []byte("1.2.3.4\n")}) + if !DataSet("allowlist.txt").Contains("1.2.3.4") { + t.Fatal("keys given with the data/ prefix should resolve by relative name") + } +} + +func TestLoadTestDataResetsCache(t *testing.T) { + LoadTestData(map[string][]byte{"allowlist.txt": []byte("1.1.1.1\n")}) + if !DataSet("allowlist.txt").Contains("1.1.1.1") { + t.Fatal("first load missing entry") + } + LoadTestData(map[string][]byte{"allowlist.txt": []byte("2.2.2.2\n")}) + set := DataSet("allowlist.txt") + if set.Contains("1.1.1.1") || !set.Contains("2.2.2.2") { + t.Fatal("reload must reset the memoized set") + } +} diff --git a/sdk/imports_stub.go b/sdk/imports_stub.go index 0492c1e..576ee39 100644 --- a/sdk/imports_stub.go +++ b/sdk/imports_stub.go @@ -16,6 +16,8 @@ func requestHeaderValues(string) []string { return nil } func requestQueryValue(string) string { return "" } func requestCookie(string) string { return "" } +func dataRead(name string) []byte { return testData[name] } + func actionNext() {} func actionDeny(int32) {} func actionRedirect(int, string) {} diff --git a/sdk/imports_wasm.go b/sdk/imports_wasm.go index 744dead..9fc48e0 100644 --- a/sdk/imports_wasm.go +++ b/sdk/imports_wasm.go @@ -76,6 +76,12 @@ func requestCookieLen(namePtr uint32, nameLen uint32) uint32 //go:wasmimport switchboard read_request_cookie func readRequestCookie(namePtr uint32, nameLen uint32, valuePtr uint32) +//go:wasmimport switchboard data_read_len +func dataReadLen(namePtr uint32, nameLen uint32) uint32 + +//go:wasmimport switchboard read_data +func readData(namePtr uint32, nameLen uint32, valuePtr uint32) + //go:wasmimport switchboard action_next func actionNext() @@ -208,6 +214,17 @@ func requestCookie(name string) string { return string(buf) } +func dataRead(name string) []byte { + namePtr, nameLen := stringPtr(name) + length := dataReadLen(namePtr, nameLen) + if length == 0 { + return nil + } + buf := make([]byte, length) + readData(namePtr, nameLen, uint32(uintptr(unsafe.Pointer(&buf[0])))) + return buf +} + func actionRedirect(status int, location string) { ptr, length := stringPtr(location) rawActionRedirect(int32(status), ptr, length)