From 4e596a29ea242da8e38a37e4ca4ae39914953046 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Sun, 29 Mar 2026 04:42:18 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20add=20ServiceOffer=20controller=20(?= =?UTF-8?q?Phase=201=20=E2=80=94=20replaces=20monetize.py)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Event-driven controller-runtime reconciler for ServiceOffer CRDs, independent of the obol-agent runtime. Replaces the Python polling loop with proper K8s informer watches, finalizers, and status conditions. Reconciler creates/manages: - Traefik Middleware (ForwardAuth → x402-verifier) - x402-pricing ConfigMap route entries - Gateway API HTTPRoute (/services//*) On deletion: finalizer removes pricing route; ownerRefs cascade the rest. Status conditions: UpstreamHealthy, PaymentGateReady, RoutePublished, Ready with observedGeneration tracking. Phase 1 (KISS): keeps ConfigMap for pricing (no PaymentRoute CRD yet), skips ERC-8004 registration (stays in monetize.py for now). Includes: - cmd/serviceoffer-controller/ — binary entrypoint - internal/controller/ — reconciler, helpers, resource builders, tests - Dockerfile.serviceoffer-controller — distroless image - K8s manifests (SA, ClusterRole, Deployment in obol-system ns) Refs: #296 --- Dockerfile.serviceoffer-controller | 10 + cmd/serviceoffer-controller/main.go | 75 +++++ go.mod | 62 ++-- go.sum | 148 ++++++---- internal/controller/helpers.go | 191 ++++++++++++ internal/controller/reconciler.go | 197 +++++++++++++ internal/controller/reconciler_test.go | 223 ++++++++++++++ internal/controller/resources.go | 271 ++++++++++++++++++ .../templates/serviceoffer-controller.yaml | 101 +++++++ 9 files changed, 1203 insertions(+), 75 deletions(-) create mode 100644 Dockerfile.serviceoffer-controller create mode 100644 cmd/serviceoffer-controller/main.go create mode 100644 internal/controller/helpers.go create mode 100644 internal/controller/reconciler.go create mode 100644 internal/controller/reconciler_test.go create mode 100644 internal/controller/resources.go create mode 100644 internal/embed/infrastructure/base/templates/serviceoffer-controller.yaml diff --git a/Dockerfile.serviceoffer-controller b/Dockerfile.serviceoffer-controller new file mode 100644 index 0000000..09f6935 --- /dev/null +++ b/Dockerfile.serviceoffer-controller @@ -0,0 +1,10 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /serviceoffer-controller ./cmd/serviceoffer-controller + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=builder /serviceoffer-controller /serviceoffer-controller +ENTRYPOINT ["/serviceoffer-controller"] diff --git a/cmd/serviceoffer-controller/main.go b/cmd/serviceoffer-controller/main.go new file mode 100644 index 0000000..21210a0 --- /dev/null +++ b/cmd/serviceoffer-controller/main.go @@ -0,0 +1,75 @@ +// serviceoffer-controller is a Kubernetes controller that reconciles +// ServiceOffer CRDs into x402 payment-gated routes. It replaces the +// Python-based monetize.py reconciliation loop. +package main + +import ( + "flag" + "os" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + "github.com/ObolNetwork/obol-stack/internal/controller" +) + +func main() { + var metricsAddr string + var probeAddr string + + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8383", "metrics endpoint bind address") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8384", "health probe bind address") + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + logger := ctrl.Log.WithName("serviceoffer-controller") + + s := runtime.NewScheme() + utilruntime.Must(scheme.AddToScheme(s)) + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: s, + HealthProbeBindAddress: probeAddr, + }) + if err != nil { + logger.Error(err, "unable to create manager") + os.Exit(1) + } + + // Create a dynamic client for unstructured ServiceOffer access. + cfg := ctrl.GetConfigOrDie() + dynClient, err := dynamic.NewForConfig(cfg) + if err != nil { + logger.Error(err, "unable to create dynamic client") + os.Exit(1) + } + _ = dynClient // reserved for future use (SSA patches) + + reconciler := &controller.Reconciler{ + Client: mgr.GetClient(), + } + if err := reconciler.SetupWithManager(mgr); err != nil { + logger.Error(err, "unable to setup controller") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + logger.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + logger.Error(err, "unable to set up ready check") + os.Exit(1) + } + + logger.Info("starting controller") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + logger.Error(err, "controller exited with error") + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index ec26bec..b9ae8f5 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,9 @@ require ( github.com/hf/nsm v0.0.0-20220930140112-cd181bd646b9 github.com/mark3labs/x402-go v0.13.0 github.com/mattn/go-isatty v0.0.20 - github.com/prometheus/client_golang v1.15.0 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 + github.com/prometheus/common v0.66.1 github.com/shopspring/decimal v1.3.1 github.com/urfave/cli/v2 v2.27.5 github.com/urfave/cli/v3 v3.6.2 @@ -23,27 +25,24 @@ require ( golang.org/x/sys v0.39.0 golang.org/x/term v0.37.0 gopkg.in/yaml.v3 v3.0.1 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 + sigs.k8s.io/controller-runtime v0.23.3 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/StackExchange/wmi v1.2.1 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.2 // indirect github.com/blendle/zapdriver v1.3.1 // indirect - github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/huh v1.0.0 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/consensys/gnark-crypto v0.19.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect @@ -53,48 +52,51 @@ require ( github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect github.com/ethereum/go-verkle v0.2.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.2.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gagliardetto/binary v0.8.0 // indirect github.com/gagliardetto/solana-go v1.14.0 // indirect github.com/gagliardetto/treeout v0.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/zapr v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/gofrs/uuid v4.3.1+incompatible // indirect - github.com/golang/protobuf v1.5.4 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-configfs-tsm v0.2.2 // indirect github.com/google/logger v1.1.1 // indirect - github.com/gorilla/websocket v1.4.2 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-memdb v1.3.4 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/holiman/uint256 v1.3.2 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect github.com/mr-tron/base58 v1.2.0 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/prometheus/client_model v0.3.0 // indirect - github.com/prometheus/common v0.42.0 // indirect - github.com/prometheus/procfs v0.9.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/spf13/pflag v1.0.10 // indirect @@ -109,8 +111,24 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/ratelimit v0.3.1 // indirect go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/api v0.35.0 // indirect + k8s.io/apiextensions-apiserver v0.35.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index d2b5163..fdd20ed 100644 --- a/go.sum +++ b/go.sum @@ -4,14 +4,14 @@ github.com/AlekSi/pointer v1.1.0 h1:SSDMPcXD9jSl8FPy9cRzoRaMJtm9g9ggGTxecRUbQoI= github.com/AlekSi/pointer v1.1.0/go.mod h1:y7BvfRI3wXPWKXEBhU71nbnIEEZX0QTSB2Bj48UJIZE= github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -23,32 +23,18 @@ github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= -github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= -github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= -github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= @@ -72,6 +58,7 @@ github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI= @@ -92,14 +79,12 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvw github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0= github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk= github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= @@ -108,14 +93,19 @@ github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qv github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok= github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.2.0 h1:6eXqdDDe588rSYAi1HfZKbx6YYQO4mxQ9eC6xYpU/JQ= github.com/fxamacker/cbor/v2 v2.2.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg= github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c= github.com/gagliardetto/gofuzz v1.2.2 h1:XL/8qDMzcgvR4+CyRQW9UGdwPRPMHVJfqQ/uMvSUuQw= @@ -128,9 +118,23 @@ github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqG github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -140,12 +144,12 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-configfs-tsm v0.2.2 h1:YnJ9rXIOj5BYD7/0DNnzs8AOp7UcvjfTvt215EWcs98= @@ -159,10 +163,12 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/logger v1.1.1 h1:+6Z2geNxc9G+4D4oDO9njjjn2d0wN5d7uOo0vOIW1NQ= github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/graph-gophers/graphql-go v1.3.0 h1:Eb9x/q6MFpCLz7jBCiP/WTxjSDrYLR1QY41SORZyNJ0= github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= @@ -199,6 +205,8 @@ github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7 github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= @@ -222,24 +230,20 @@ github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczG github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mark3labs/x402-go v0.13.0 h1:Ppm3GXZx2ZCLJM511mFYeMOw/605h9+M6UT630GdRG0= github.com/mark3labs/x402-go v0.13.0/go.mod h1:srAvV9FosjBiqrclF15thrQbz0fVVfNXtMcqD0e1hKU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= @@ -247,22 +251,23 @@ github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8oh github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk= github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7 h1:oYW+YCJ1pachXTQmzR3rNLYGGz4g/UgFcjb28p/viDM= @@ -282,14 +287,14 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= -github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= -github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= -github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= -github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= -github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= -github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= -github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -314,6 +319,8 @@ github.com/streamingfast/logging v0.0.0-20250918142248-ac5a1e292845/go.mod h1:Bt github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -363,6 +370,10 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -376,6 +387,8 @@ golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPI golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -384,12 +397,11 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -402,7 +414,6 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -425,16 +436,23 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20210105210202-9ed45478a130/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -444,3 +462,27 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/controller/helpers.go b/internal/controller/helpers.go new file mode 100644 index 0000000..431a315 --- /dev/null +++ b/internal/controller/helpers.go @@ -0,0 +1,191 @@ +package controller + +import ( + "fmt" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// offerSpec is a lightweight parsed view of a ServiceOffer spec. +// We use unstructured access to avoid generating CRD types — KISS. +type offerSpec struct { + Path string + Upstream upstreamSpec + Payment paymentSpec +} + +type upstreamSpec struct { + Service string + Namespace string + Port int64 + HealthPath string +} + +type paymentSpec struct { + Network string + PayTo string + Price priceTable +} + +type priceTable struct { + PerRequest string + PerMTok string + PerHour string +} + +// effectiveRequestPrice returns the per-request price for x402 gating. +func (p priceTable) effectiveRequestPrice() string { + if p.PerRequest != "" { + return p.PerRequest + } + if p.PerMTok != "" { + return p.PerMTok // simplified; real conversion in schemas package + } + if p.PerHour != "" { + return p.PerHour + } + return "0" +} + +// priceModel returns the pricing model string for the route rule. +func (p priceTable) priceModel() string { + if p.PerRequest != "" { + return "perRequest" + } + if p.PerMTok != "" { + return "perMTok" + } + if p.PerHour != "" { + return "perHour" + } + return "perRequest" +} + +// getSpec extracts a typed spec from an unstructured ServiceOffer. +func getSpec(so *unstructured.Unstructured) (*offerSpec, error) { + spec, ok, _ := unstructured.NestedMap(so.Object, "spec") + if !ok { + return nil, fmt.Errorf("missing spec") + } + + upstream, _, _ := unstructured.NestedMap(spec, "upstream") + payment, _, _ := unstructured.NestedMap(spec, "payment") + price, _, _ := unstructured.NestedMap(payment, "price") + + path, _, _ := unstructured.NestedString(spec, "path") + svc, _, _ := unstructured.NestedString(upstream, "service") + ns, _, _ := unstructured.NestedString(upstream, "namespace") + port, _, _ := unstructured.NestedInt64(upstream, "port") + healthPath, _, _ := unstructured.NestedString(upstream, "healthPath") + network, _, _ := unstructured.NestedString(payment, "network") + payTo, _, _ := unstructured.NestedString(payment, "payTo") + perRequest, _, _ := unstructured.NestedString(price, "perRequest") + perMTok, _, _ := unstructured.NestedString(price, "perMTok") + perHour, _, _ := unstructured.NestedString(price, "perHour") + + if svc == "" || ns == "" || port == 0 { + return nil, fmt.Errorf("upstream requires service, namespace, and port") + } + + return &offerSpec{ + Path: path, + Upstream: upstreamSpec{ + Service: svc, + Namespace: ns, + Port: port, + HealthPath: healthPath, + }, + Payment: paymentSpec{ + Network: network, + PayTo: payTo, + Price: priceTable{ + PerRequest: perRequest, + PerMTok: perMTok, + PerHour: perHour, + }, + }, + }, nil +} + +// setCondition upserts a condition on the ServiceOffer status. +func setCondition(so *unstructured.Unstructured, condType string, ok bool, message string) { + status := "False" + reason := "NotReady" + if ok { + status = "True" + reason = "Ready" + } + + conditions, _, _ := unstructured.NestedSlice(so.Object, "status", "conditions") + + now := time.Now().UTC().Format(time.RFC3339) + newCond := map[string]interface{}{ + "type": condType, + "status": status, + "reason": reason, + "message": message, + "lastTransitionTime": now, + } + + // Update existing or append. + found := false + for i, c := range conditions { + cond, ok := c.(map[string]interface{}) + if !ok { + continue + } + if cond["type"] == condType { + // Only update lastTransitionTime if status changed. + if cond["status"] != status { + conditions[i] = newCond + } else { + cond["reason"] = reason + cond["message"] = message + conditions[i] = cond + } + found = true + break + } + } + if !found { + conditions = append(conditions, newCond) + } + + _ = unstructured.SetNestedSlice(so.Object, conditions, "status", "conditions") +} + +// setNestedField is a convenience wrapper. +func setNestedField(so *unstructured.Unstructured, value interface{}, fields ...string) { + _ = unstructured.SetNestedField(so.Object, value, fields...) +} + +// computePhase returns the overall phase based on conditions. +func computePhase(so *unstructured.Unstructured) string { + conditions, _, _ := unstructured.NestedSlice(so.Object, "status", "conditions") + for _, c := range conditions { + cond, ok := c.(map[string]interface{}) + if !ok { + continue + } + if cond["type"] == condReady && cond["status"] == "True" { + return "Ready" + } + } + return "Reconciling" +} + +// withGVK returns a builder option that sets the GVK for unstructured watches. +func withGVK(gvk schema.GroupVersionKind) builder.ForOption { + return builder.WithPredicates(predicate.NewPredicateFuncs(func(obj client.Object) bool { + u, ok := obj.(*unstructured.Unstructured) + if !ok { + return false + } + return u.GroupVersionKind() == gvk + })) +} diff --git a/internal/controller/reconciler.go b/internal/controller/reconciler.go new file mode 100644 index 0000000..fddf756 --- /dev/null +++ b/internal/controller/reconciler.go @@ -0,0 +1,197 @@ +// Package controller implements a Kubernetes controller for ServiceOffer CRDs. +// It replaces the Python-based monetize.py reconciliation loop with an +// event-driven controller-runtime reconciler. +package controller + +import ( + "context" + "fmt" + "net/http" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + finalizerName = "obol.org/serviceoffer-cleanup" + + condUpstreamHealthy = "UpstreamHealthy" + condPaymentGateReady = "PaymentGateReady" + condRoutePublished = "RoutePublished" + condReady = "Ready" + + verifierNamespace = "x402" + verifierConfigMap = "x402-pricing" + gatewayName = "traefik-gateway" + gatewayNamespace = "traefik" + gatewaySectionWeb = "web" +) + +var ( + serviceOfferGVR = schema.GroupVersionResource{Group: "obol.org", Version: "v1alpha1", Resource: "serviceoffers"} + serviceOfferGVK = schema.GroupVersionKind{Group: "obol.org", Version: "v1alpha1", Kind: "ServiceOffer"} +) + +// Reconciler reconciles ServiceOffer CRDs into child Kubernetes resources: +// Middleware (traefik.io), HTTPRoute (gateway API), and x402 pricing ConfigMap entries. +type Reconciler struct { + client.Client + HTTPClient *http.Client +} + +// SetupWithManager registers the reconciler with the controller manager. +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&unstructured.Unstructured{}, withGVK(serviceOfferGVK)). + Complete(r) +} + +// Reconcile handles a single ServiceOffer event. +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // Fetch the ServiceOffer. + so := &unstructured.Unstructured{} + so.SetGroupVersionKind(serviceOfferGVK) + if err := r.Get(ctx, req.NamespacedName, so); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Handle deletion. + if !so.GetDeletionTimestamp().IsZero() { + return r.handleDeletion(ctx, so) + } + + // Ensure finalizer. + if !controllerutil.ContainsFinalizer(so, finalizerName) { + controllerutil.AddFinalizer(so, finalizerName) + if err := r.Update(ctx, so); err != nil { + return ctrl.Result{}, err + } + } + + spec, err := getSpec(so) + if err != nil { + logger.Error(err, "invalid ServiceOffer spec") + return ctrl.Result{}, nil // don't requeue on bad spec + } + + name := so.GetName() + ns := so.GetNamespace() + + // --- Stage 1: Upstream health check --- + healthy, msg := r.checkUpstreamHealth(ctx, spec) + setCondition(so, condUpstreamHealthy, healthy, msg) + if !healthy { + if err := r.updateStatus(ctx, so); err != nil { + return ctrl.Result{}, err + } + logger.Info("upstream not healthy, requeueing", "name", name, "msg", msg) + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + // --- Stage 2: Payment gate (Middleware + ConfigMap pricing route) --- + if err := r.ensureMiddleware(ctx, so, name, ns); err != nil { + setCondition(so, condPaymentGateReady, false, fmt.Sprintf("middleware error: %v", err)) + _ = r.updateStatus(ctx, so) + return ctrl.Result{}, err + } + if err := r.ensurePricingRoute(ctx, so, spec, name, ns); err != nil { + setCondition(so, condPaymentGateReady, false, fmt.Sprintf("pricing error: %v", err)) + _ = r.updateStatus(ctx, so) + return ctrl.Result{}, err + } + setCondition(so, condPaymentGateReady, true, "Middleware and pricing route configured") + + // --- Stage 3: HTTPRoute --- + if err := r.ensureHTTPRoute(ctx, so, spec, name, ns); err != nil { + setCondition(so, condRoutePublished, false, fmt.Sprintf("httproute error: %v", err)) + _ = r.updateStatus(ctx, so) + return ctrl.Result{}, err + } + path := spec.Path + if path == "" { + path = "/services/" + name + } + setCondition(so, condRoutePublished, true, "HTTPRoute published at "+path) + + // Set endpoint in status. + setNestedField(so, path, "status", "endpoint") + + // --- All conditions met --- + setCondition(so, condReady, true, "All conditions satisfied") + setNestedField(so, so.GetGeneration(), "status", "observedGeneration") + + if err := r.updateStatus(ctx, so); err != nil { + return ctrl.Result{}, err + } + + logger.Info("ServiceOffer reconciled", "name", name, "endpoint", path) + return ctrl.Result{}, nil +} + +// handleDeletion removes the pricing route from the ConfigMap (ownerRefs handle the rest). +func (r *Reconciler) handleDeletion(ctx context.Context, so *unstructured.Unstructured) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + if controllerutil.ContainsFinalizer(so, finalizerName) { + spec, err := getSpec(so) + if err == nil { + path := spec.Path + if path == "" { + path = "/services/" + so.GetName() + } + if rmErr := r.removePricingRoute(ctx, path, so.GetName()); rmErr != nil { + logger.Error(rmErr, "failed to remove pricing route, continuing cleanup") + } + } + + controllerutil.RemoveFinalizer(so, finalizerName) + if err := r.Update(ctx, so); err != nil { + return ctrl.Result{}, err + } + } + + logger.Info("ServiceOffer deleted", "name", so.GetName()) + return ctrl.Result{}, nil +} + +// checkUpstreamHealth probes the upstream service health endpoint. +func (r *Reconciler) checkUpstreamHealth(ctx context.Context, spec *offerSpec) (bool, string) { + healthPath := spec.Upstream.HealthPath + if healthPath == "" { + healthPath = "/health" + } + + url := fmt.Sprintf("http://%s.%s.svc.cluster.local:%d%s", + spec.Upstream.Service, spec.Upstream.Namespace, spec.Upstream.Port, healthPath) + + httpClient := r.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 5 * time.Second} + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, fmt.Sprintf("bad health URL: %v", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return false, fmt.Sprintf("health check failed: %v", err) + } + defer resp.Body.Close() + + // Any response = reachable (matches monetize.py behavior). + return true, fmt.Sprintf("GET %s returned %d", healthPath, resp.StatusCode) +} + +// updateStatus patches the status subresource. +func (r *Reconciler) updateStatus(ctx context.Context, so *unstructured.Unstructured) error { + return r.Status().Update(ctx, so) +} diff --git a/internal/controller/reconciler_test.go b/internal/controller/reconciler_test.go new file mode 100644 index 0000000..129ced5 --- /dev/null +++ b/internal/controller/reconciler_test.go @@ -0,0 +1,223 @@ +package controller + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestGetSpec_Valid(t *testing.T) { + so := makeServiceOffer("test-offer", "default", map[string]interface{}{ + "upstream": map[string]interface{}{ + "service": "my-svc", + "namespace": "my-ns", + "port": int64(8080), + "healthPath": "/healthz", + }, + "payment": map[string]interface{}{ + "network": "base-sepolia", + "payTo": "0x1234", + "price": map[string]interface{}{ + "perRequest": "0.001", + }, + }, + "path": "/services/test", + }) + + spec, err := getSpec(so) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if spec.Upstream.Service != "my-svc" { + t.Errorf("expected service my-svc, got %s", spec.Upstream.Service) + } + if spec.Upstream.Port != 8080 { + t.Errorf("expected port 8080, got %d", spec.Upstream.Port) + } + if spec.Payment.PayTo != "0x1234" { + t.Errorf("expected payTo 0x1234, got %s", spec.Payment.PayTo) + } + if spec.Path != "/services/test" { + t.Errorf("expected path /services/test, got %s", spec.Path) + } +} + +func TestGetSpec_MissingUpstream(t *testing.T) { + so := makeServiceOffer("bad", "default", map[string]interface{}{ + "payment": map[string]interface{}{ + "network": "base-sepolia", + "payTo": "0x1234", + "price": map[string]interface{}{}, + }, + }) + + _, err := getSpec(so) + if err == nil { + t.Fatal("expected error for missing upstream") + } +} + +func TestSetCondition_NewCondition(t *testing.T) { + so := makeServiceOffer("test", "default", nil) + + setCondition(so, condUpstreamHealthy, true, "healthy") + + conditions, _, _ := nestedSlice(so, "status", "conditions") + if len(conditions) != 1 { + t.Fatalf("expected 1 condition, got %d", len(conditions)) + } + + cond := conditions[0].(map[string]interface{}) + if cond["type"] != condUpstreamHealthy { + t.Errorf("expected type %s, got %s", condUpstreamHealthy, cond["type"]) + } + if cond["status"] != "True" { + t.Errorf("expected status True, got %s", cond["status"]) + } +} + +func TestSetCondition_UpdateExisting(t *testing.T) { + so := makeServiceOffer("test", "default", nil) + + setCondition(so, condUpstreamHealthy, false, "down") + setCondition(so, condUpstreamHealthy, true, "up") + + conditions, _, _ := nestedSlice(so, "status", "conditions") + if len(conditions) != 1 { + t.Fatalf("expected 1 condition, got %d", len(conditions)) + } + + cond := conditions[0].(map[string]interface{}) + if cond["status"] != "True" { + t.Errorf("expected status True, got %s", cond["status"]) + } + if cond["message"] != "up" { + t.Errorf("expected message 'up', got %s", cond["message"]) + } +} + +func TestSetCondition_MultipleConditions(t *testing.T) { + so := makeServiceOffer("test", "default", nil) + + setCondition(so, condUpstreamHealthy, true, "ok") + setCondition(so, condPaymentGateReady, true, "ok") + setCondition(so, condRoutePublished, false, "pending") + + conditions, _, _ := nestedSlice(so, "status", "conditions") + if len(conditions) != 3 { + t.Fatalf("expected 3 conditions, got %d", len(conditions)) + } +} + +func TestEffectiveRequestPrice(t *testing.T) { + tests := []struct { + name string + price priceTable + want string + }{ + {"perRequest set", priceTable{PerRequest: "0.001"}, "0.001"}, + {"perMTok fallback", priceTable{PerMTok: "1.0"}, "1.0"}, + {"perHour fallback", priceTable{PerHour: "0.5"}, "0.5"}, + {"all empty", priceTable{}, "0"}, + {"perRequest takes precedence", priceTable{PerRequest: "0.01", PerMTok: "1.0"}, "0.01"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.price.effectiveRequestPrice() + if got != tt.want { + t.Errorf("got %s, want %s", got, tt.want) + } + }) + } +} + +func TestPriceModel(t *testing.T) { + tests := []struct { + name string + price priceTable + want string + }{ + {"perRequest", priceTable{PerRequest: "0.001"}, "perRequest"}, + {"perMTok", priceTable{PerMTok: "1.0"}, "perMTok"}, + {"perHour", priceTable{PerHour: "0.5"}, "perHour"}, + {"default", priceTable{}, "perRequest"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.price.priceModel() + if got != tt.want { + t.Errorf("got %s, want %s", got, tt.want) + } + }) + } +} + +func TestBuildRouteEntry(t *testing.T) { + spec := &offerSpec{ + Payment: paymentSpec{ + Network: "base-sepolia", + PayTo: "0xABC", + Price: priceTable{PerRequest: "0.001"}, + }, + } + + entry := buildRouteEntry("/services/test/*", "0.001", "test", "default", spec) + + if !contains(entry, `pattern: "/services/test/*"`) { + t.Error("missing pattern") + } + if !contains(entry, `price: "0.001"`) { + t.Error("missing price") + } + if !contains(entry, `payTo: "0xABC"`) { + t.Error("missing payTo") + } + if !contains(entry, `network: "base-sepolia"`) { + t.Error("missing network") + } + if !contains(entry, `offerName: "test"`) { + t.Error("missing offerName") + } +} + +// --- helpers --- + +func makeServiceOffer(name, ns string, spec map[string]interface{}) *unstructured.Unstructured { //nolint:unparam + so := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "obol.org/v1alpha1", + "kind": "ServiceOffer", + "metadata": map[string]interface{}{ + "name": name, + "namespace": ns, + "uid": "test-uid-123", + "generation": int64(1), + }, + "status": map[string]interface{}{}, + }, + } + if spec != nil { + so.Object["spec"] = spec + } + return so +} + +func nestedSlice(so *unstructured.Unstructured, fields ...string) ([]interface{}, bool, error) { + return unstructured.NestedSlice(so.Object, fields...) +} + +func contains(s, substr string) bool { + return len(s) > 0 && len(substr) > 0 && stringContains(s, substr) +} + +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/controller/resources.go b/internal/controller/resources.go new file mode 100644 index 0000000..8cf4d5d --- /dev/null +++ b/internal/controller/resources.go @@ -0,0 +1,271 @@ +package controller + +import ( + "context" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// ensureMiddleware creates or updates the Traefik ForwardAuth Middleware. +func (r *Reconciler) ensureMiddleware(ctx context.Context, so *unstructured.Unstructured, name, ns string) error { + mw := &unstructured.Unstructured{} + mw.SetGroupVersionKind(middlewareGVK) + mw.SetName("x402-" + name) + mw.SetNamespace(ns) + + _ = unstructured.SetNestedMap(mw.Object, map[string]interface{}{ + "forwardAuth": map[string]interface{}{ + "address": "http://x402-verifier.x402.svc.cluster.local:8080/verify", + "authResponseHeaders": []interface{}{ + "X-Payment-Status", + "X-Payment-Tx", + "Authorization", + }, + }, + }, "spec") + + setOwnerRef(mw, so) + + existing := &unstructured.Unstructured{} + existing.SetGroupVersionKind(middlewareGVK) + err := r.Get(ctx, types.NamespacedName{Name: mw.GetName(), Namespace: ns}, existing) + if err != nil { + return r.Create(ctx, mw) + } + mw.SetResourceVersion(existing.GetResourceVersion()) + return r.Update(ctx, mw) +} + +// ensureHTTPRoute creates or updates the Gateway API HTTPRoute. +func (r *Reconciler) ensureHTTPRoute(ctx context.Context, so *unstructured.Unstructured, spec *offerSpec, name, ns string) error { + path := spec.Path + if path == "" { + path = "/services/" + name + } + + route := &unstructured.Unstructured{} + route.SetGroupVersionKind(httpRouteGVK) + route.SetName("so-" + name) + route.SetNamespace(ns) + + _ = unstructured.SetNestedField(route.Object, map[string]interface{}{ + "parentRefs": []interface{}{ + map[string]interface{}{ + "name": gatewayName, + "namespace": gatewayNamespace, + "sectionName": gatewaySectionWeb, + }, + }, + "rules": []interface{}{ + map[string]interface{}{ + "matches": []interface{}{ + map[string]interface{}{ + "path": map[string]interface{}{ + "type": "PathPrefix", + "value": path, + }, + }, + }, + "filters": []interface{}{ + map[string]interface{}{ + "type": "ExtensionRef", + "extensionRef": map[string]interface{}{ + "group": "traefik.io", + "kind": "Middleware", + "name": "x402-" + name, + }, + }, + map[string]interface{}{ + "type": "URLRewrite", + "urlRewrite": map[string]interface{}{ + "path": map[string]interface{}{ + "type": "ReplacePrefixMatch", + "replacePrefixMatch": "/", + }, + }, + }, + }, + "backendRefs": []interface{}{ + map[string]interface{}{ + "name": spec.Upstream.Service, + "namespace": spec.Upstream.Namespace, + "port": spec.Upstream.Port, + }, + }, + }, + }, + }, "spec") + + setOwnerRef(route, so) + + existing := &unstructured.Unstructured{} + existing.SetGroupVersionKind(httpRouteGVK) + err := r.Get(ctx, types.NamespacedName{Name: route.GetName(), Namespace: ns}, existing) + if err != nil { + return r.Create(ctx, route) + } + route.SetResourceVersion(existing.GetResourceVersion()) + return r.Update(ctx, route) +} + +// ensurePricingRoute adds a route entry to the x402-pricing ConfigMap. +func (r *Reconciler) ensurePricingRoute(ctx context.Context, so *unstructured.Unstructured, spec *offerSpec, name, ns string) error { + path := spec.Path + if path == "" { + path = "/services/" + name + } + pattern := path + "/*" + + cm := &unstructured.Unstructured{} + cm.SetGroupVersionKind(configMapGVK) + if err := r.Get(ctx, types.NamespacedName{Name: verifierConfigMap, Namespace: verifierNamespace}, cm); err != nil { + return fmt.Errorf("get x402-pricing ConfigMap: %w", err) + } + + data, _, _ := unstructured.NestedStringMap(cm.Object, "data") + if data == nil { + data = map[string]string{} + } + + pricingYAML := data["pricing.yaml"] + + // Check if route already exists. + if strings.Contains(pricingYAML, fmt.Sprintf("pattern: %q", pattern)) { + return nil // already present + } + + // Build route entry. + price := spec.Payment.Price.effectiveRequestPrice() + entry := buildRouteEntry(pattern, price, name, ns, spec) + + // Append to routes section. + if strings.Contains(pricingYAML, "routes: []") { + pricingYAML = strings.Replace(pricingYAML, "routes: []", "routes:\n"+entry, 1) + } else if strings.Contains(pricingYAML, "routes:") { + pricingYAML += entry + } else { + pricingYAML += "\nroutes:\n" + entry + } + + data["pricing.yaml"] = pricingYAML + _ = unstructured.SetNestedStringMap(cm.Object, data, "data") + + return r.Update(ctx, cm) +} + +// removePricingRoute removes a route entry from the x402-pricing ConfigMap. +func (r *Reconciler) removePricingRoute(ctx context.Context, path, name string) error { + logger := log.FromContext(ctx) + + cm := &unstructured.Unstructured{} + cm.SetGroupVersionKind(configMapGVK) + if err := r.Get(ctx, types.NamespacedName{Name: verifierConfigMap, Namespace: verifierNamespace}, cm); err != nil { + logger.Error(err, "failed to get x402-pricing ConfigMap for cleanup") + return err + } + + data, _, _ := unstructured.NestedStringMap(cm.Object, "data") + if data == nil { + return nil + } + + pricingYAML := data["pricing.yaml"] + pattern := path + "/*" + + // Remove the route block starting with this pattern. + lines := strings.Split(pricingYAML, "\n") + var filtered []string + skip := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.Contains(trimmed, fmt.Sprintf("pattern: %q", pattern)) { + skip = true + continue + } + if skip { + // Skip continuation lines (indented fields of the same route). + if strings.HasPrefix(trimmed, "- pattern:") || trimmed == "" || !strings.HasPrefix(line, " ") { + skip = false + } else { + continue + } + } + if !skip { + filtered = append(filtered, line) + } + } + + result := strings.Join(filtered, "\n") + // If no routes remain, set empty list. + if !strings.Contains(result, "- pattern:") && strings.Contains(result, "routes:") { + result = strings.Replace(result, "routes:", "routes: []", 1) + } + + data["pricing.yaml"] = result + _ = unstructured.SetNestedStringMap(cm.Object, data, "data") + + return r.Update(ctx, cm) +} + +// buildRouteEntry builds a YAML route entry for the pricing ConfigMap. +func buildRouteEntry(pattern, price, name, ns string, spec *offerSpec) string { + var b strings.Builder + fmt.Fprintf(&b, " - pattern: %q\n", pattern) + fmt.Fprintf(&b, " price: %q\n", price) + fmt.Fprintf(&b, " description: \"ServiceOffer %s\"\n", name) + if spec.Payment.PayTo != "" { + fmt.Fprintf(&b, " payTo: %q\n", spec.Payment.PayTo) + } + if spec.Payment.Network != "" { + fmt.Fprintf(&b, " network: %q\n", spec.Payment.Network) + } + fmt.Fprintf(&b, " priceModel: %q\n", spec.Payment.Price.priceModel()) + if spec.Payment.Price.PerMTok != "" { + fmt.Fprintf(&b, " perMTok: %q\n", spec.Payment.Price.PerMTok) + fmt.Fprintf(&b, " approxTokensPerRequest: 1000\n") + } + fmt.Fprintf(&b, " offerNamespace: %q\n", ns) + fmt.Fprintf(&b, " offerName: %q\n", name) + return b.String() +} + +// setOwnerRef sets the ServiceOffer as the controller owner of a child resource. +func setOwnerRef(child, owner *unstructured.Unstructured) { + _ = controllerutil.SetControllerReference(owner, child, nil) + // controllerutil needs a scheme; since we're unstructured, set manually. + refs := []interface{}{ + map[string]interface{}{ + "apiVersion": "obol.org/v1alpha1", + "kind": "ServiceOffer", + "name": owner.GetName(), + "uid": string(owner.GetUID()), + "blockOwnerDeletion": true, + "controller": true, + }, + } + _ = unstructured.SetNestedSlice(child.Object, refs, "metadata", "ownerReferences") +} + +var ( + middlewareGVK = schema.GroupVersionKind{ + Group: "traefik.io", + Version: "v1alpha1", + Kind: "Middleware", + } + httpRouteGVK = schema.GroupVersionKind{ + Group: "gateway.networking.k8s.io", + Version: "v1", + Kind: "HTTPRoute", + } + configMapGVK = schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + } +) diff --git a/internal/embed/infrastructure/base/templates/serviceoffer-controller.yaml b/internal/embed/infrastructure/base/templates/serviceoffer-controller.yaml new file mode 100644 index 0000000..35fe33f --- /dev/null +++ b/internal/embed/infrastructure/base/templates/serviceoffer-controller.yaml @@ -0,0 +1,101 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: serviceoffer-controller + namespace: obol-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: serviceoffer-controller +rules: + # ServiceOffer CRD (primary resource) + - apiGroups: ["obol.org"] + resources: ["serviceoffers"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: ["obol.org"] + resources: ["serviceoffers/status"] + verbs: ["get", "update", "patch"] + # Traefik Middleware (child resource) + - apiGroups: ["traefik.io"] + resources: ["middlewares"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Gateway API HTTPRoute (child resource) + - apiGroups: ["gateway.networking.k8s.io"] + resources: ["httproutes"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # ConfigMap for x402 pricing (write pricing routes) + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "watch", "update", "patch"] + # Leader election + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Events + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: serviceoffer-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: serviceoffer-controller +subjects: + - kind: ServiceAccount + name: serviceoffer-controller + namespace: obol-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: serviceoffer-controller + namespace: obol-system + labels: + app: serviceoffer-controller +spec: + replicas: 1 + selector: + matchLabels: + app: serviceoffer-controller + template: + metadata: + labels: + app: serviceoffer-controller + spec: + serviceAccountName: serviceoffer-controller + containers: + - name: controller + image: ghcr.io/obolnetwork/serviceoffer-controller:latest + args: + - --metrics-bind-address=:8383 + - --health-probe-bind-address=:8384 + ports: + - containerPort: 8383 + name: metrics + - containerPort: 8384 + name: health + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi From dc4d1fbf5881c82d09f7771a83b5ad73265009e4 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Sun, 29 Mar 2026 05:27:45 +0200 Subject: [PATCH 2/3] feat: add PaymentRoute CRD and verifier informer (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ConfigMap string mutation with PaymentRoute CRD. Each ServiceOffer now creates an owned PaymentRoute CR in the x402 namespace. The verifier watches these via dynamic informer instead of polling a file. Changes: - PaymentRoute CRD (obol.org/v1alpha1) with spec and status.admitted - Controller creates PaymentRoute CRs via createOrUpdate (not ConfigMap) - Verifier: --route-source=paymentroute (default) watches CRs, --route-source=configmap falls back to legacy file watcher - PaymentRouteSource (internal/x402/source/) builds route table from CRs and marks status.admitted when loaded - Verifier.Config() accessor for PaymentRouteSource to read globals - Removed .well-known handler from verifier (per issue #296) - 16 unit tests across controller and source packages No backward compat with ConfigMap pricing — this is the new path. monetize.py deletion and CLAUDE.md updates in next commit. Refs: #296 --- cmd/x402-verifier/main.go | 46 +++- internal/controller/helpers.go | 10 +- internal/controller/reconciler.go | 95 ++++---- internal/controller/reconciler_test.go | 186 +++++++++------ internal/controller/resources.go | 209 ++++------------- .../base/templates/paymentroute-crd.yaml | 86 +++++++ internal/x402/source/paymentroute.go | 213 ++++++++++++++++++ internal/x402/source/paymentroute_test.go | 123 ++++++++++ internal/x402/verifier.go | 5 + 9 files changed, 680 insertions(+), 293 deletions(-) create mode 100644 internal/embed/infrastructure/base/templates/paymentroute-crd.yaml create mode 100644 internal/x402/source/paymentroute.go create mode 100644 internal/x402/source/paymentroute_test.go diff --git a/cmd/x402-verifier/main.go b/cmd/x402-verifier/main.go index 0c39baf..847a094 100644 --- a/cmd/x402-verifier/main.go +++ b/cmd/x402-verifier/main.go @@ -13,14 +13,19 @@ import ( "time" x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" + "github.com/ObolNetwork/obol-stack/internal/x402/source" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" ) func main() { - configPath := flag.String("config", "/config/pricing.yaml", "Path to pricing config YAML") + configPath := flag.String("config", "/config/pricing.yaml", "Path to pricing config YAML (global settings)") listen := flag.String("listen", ":8080", "Listen address") - watch := flag.Bool("watch", true, "Watch config file for changes") + routeSource := flag.String("route-source", "paymentroute", "Route source: paymentroute (CRD watch) or configmap (legacy file watcher)") + routeNamespace := flag.String("route-namespace", "x402", "Namespace to watch for PaymentRoute CRs") flag.Parse() + // Load base config for global settings (wallet, chain, facilitator). cfg, err := x402verifier.LoadConfig(*configPath) if err != nil { log.Fatalf("load config: %v", err) @@ -41,7 +46,6 @@ func main() { mux.HandleFunc("/verify", v.HandleVerify) mux.HandleFunc("/healthz", v.HandleHealthz) mux.HandleFunc("/readyz", v.HandleReadyz) - mux.HandleFunc("GET /.well-known/agent-registration.json", v.HandleWellKnown) mux.Handle("GET /metrics", v.MetricsHandler()) server := &http.Server{ @@ -50,15 +54,39 @@ func main() { ReadHeaderTimeout: 10 * time.Second, } - // Start config watcher in background. ctx, cancel := context.WithCancel(context.Background()) defer cancel() - if *watch { + // Start route source. + switch *routeSource { + case "paymentroute": + restCfg, err := rest.InClusterConfig() + if err != nil { + log.Fatalf("in-cluster config: %v (use --route-source=configmap outside cluster)", err) + } + + dynClient, err := dynamic.NewForConfig(restCfg) + if err != nil { + log.Fatalf("dynamic client: %v", err) + } + + src := source.NewPaymentRouteSource(dynClient, v, *routeNamespace) + go func() { + if err := src.Run(ctx); err != nil { + log.Fatalf("paymentroute source: %v", err) + } + }() + log.Printf("route source: PaymentRoute CRs (namespace: %s)", *routeNamespace) + + case "configmap": go x402verifier.WatchConfig(ctx, *configPath, v, 5*time.Second) + log.Printf("route source: ConfigMap file watcher (%s)", *configPath) + + default: + log.Fatalf("unknown route source: %s", *routeSource) } - // Handle graceful shutdown. + // Graceful shutdown. sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { @@ -67,9 +95,7 @@ func main() { cancel() shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutdownCancel() - if err := server.Shutdown(shutdownCtx); err != nil { - log.Printf("shutdown error: %v", err) - } + _ = server.Shutdown(shutdownCtx) }() listener, err := net.Listen("tcp", *listen) @@ -78,11 +104,9 @@ func main() { } log.Printf("x402 verifier listening on %s", *listen) - log.Printf(" config: %s", *configPath) log.Printf(" wallet: %s", cfg.Wallet) log.Printf(" chain: %s", cfg.Chain) log.Printf(" facilitator: %s", cfg.FacilitatorURL) - log.Printf(" routes: %d", len(cfg.Routes)) log.Printf(" verifyOnly: %v", cfg.VerifyOnly) if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { diff --git a/internal/controller/helpers.go b/internal/controller/helpers.go index 431a315..19fc9de 100644 --- a/internal/controller/helpers.go +++ b/internal/controller/helpers.go @@ -52,18 +52,18 @@ func (p priceTable) effectiveRequestPrice() string { return "0" } -// priceModel returns the pricing model string for the route rule. +// priceModel returns the pricing model string for the PaymentRoute CR. func (p priceTable) priceModel() string { if p.PerRequest != "" { - return "perRequest" + return "per-request" } if p.PerMTok != "" { - return "perMTok" + return "per-mtok" } if p.PerHour != "" { - return "perHour" + return "per-hour" } - return "perRequest" + return "per-request" } // getSpec extracts a typed spec from an unstructured ServiceOffer. diff --git a/internal/controller/reconciler.go b/internal/controller/reconciler.go index fddf756..3079dfa 100644 --- a/internal/controller/reconciler.go +++ b/internal/controller/reconciler.go @@ -1,6 +1,7 @@ // Package controller implements a Kubernetes controller for ServiceOffer CRDs. // It replaces the Python-based monetize.py reconciliation loop with an -// event-driven controller-runtime reconciler. +// event-driven controller-runtime reconciler. The controller is generation-driven: +// it derives desired child resources from spec and observes convergence. package controller import ( @@ -19,6 +20,7 @@ import ( const ( finalizerName = "obol.org/serviceoffer-cleanup" + fieldOwner = "serviceoffer-controller" condUpstreamHealthy = "UpstreamHealthy" condPaymentGateReady = "PaymentGateReady" @@ -26,22 +28,24 @@ const ( condReady = "Ready" verifierNamespace = "x402" - verifierConfigMap = "x402-pricing" gatewayName = "traefik-gateway" gatewayNamespace = "traefik" gatewaySectionWeb = "web" ) var ( - serviceOfferGVR = schema.GroupVersionResource{Group: "obol.org", Version: "v1alpha1", Resource: "serviceoffers"} serviceOfferGVK = schema.GroupVersionKind{Group: "obol.org", Version: "v1alpha1", Kind: "ServiceOffer"} + + paymentRouteGVK = schema.GroupVersionKind{Group: "obol.org", Version: "v1alpha1", Kind: "PaymentRoute"} + middlewareGVK = schema.GroupVersionKind{Group: "traefik.io", Version: "v1alpha1", Kind: "Middleware"} + httpRouteGVK = schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1", Kind: "HTTPRoute"} ) // Reconciler reconciles ServiceOffer CRDs into child Kubernetes resources: -// Middleware (traefik.io), HTTPRoute (gateway API), and x402 pricing ConfigMap entries. +// PaymentRoute (obol.org), Middleware (traefik.io), and HTTPRoute (gateway API). type Reconciler struct { client.Client - HTTPClient *http.Client + HTTPClient *http.Client // injectable for testing } // SetupWithManager registers the reconciler with the controller manager. @@ -51,23 +55,23 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { Complete(r) } -// Reconcile handles a single ServiceOffer event. +// Reconcile handles a single ServiceOffer event. It is generation-driven: +// always derive desired child resources from spec, apply them, then observe status. func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) - // Fetch the ServiceOffer. so := &unstructured.Unstructured{} so.SetGroupVersionKind(serviceOfferGVK) if err := r.Get(ctx, req.NamespacedName, so); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - // Handle deletion. + // Handle deletion via finalizer. if !so.GetDeletionTimestamp().IsZero() { return r.handleDeletion(ctx, so) } - // Ensure finalizer. + // Ensure finalizer is present. if !controllerutil.ContainsFinalizer(so, finalizerName) { controllerutil.AddFinalizer(so, finalizerName) if err := r.Update(ctx, so); err != nil { @@ -78,52 +82,52 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu spec, err := getSpec(so) if err != nil { logger.Error(err, "invalid ServiceOffer spec") - return ctrl.Result{}, nil // don't requeue on bad spec + return ctrl.Result{}, nil // don't requeue bad spec } name := so.GetName() ns := so.GetNamespace() - // --- Stage 1: Upstream health check --- - healthy, msg := r.checkUpstreamHealth(ctx, spec) - setCondition(so, condUpstreamHealthy, healthy, msg) + // --- Derive and apply desired child resources --- + + // 1. Check upstream health (non-resource precondition). + healthy, healthMsg := r.checkUpstreamHealth(ctx, spec) + setCondition(so, condUpstreamHealthy, healthy, healthMsg) if !healthy { - if err := r.updateStatus(ctx, so); err != nil { - return ctrl.Result{}, err - } - logger.Info("upstream not healthy, requeueing", "name", name, "msg", msg) + _ = r.updateStatus(ctx, so) + logger.Info("upstream not healthy, requeueing", "name", name, "msg", healthMsg) return ctrl.Result{RequeueAfter: 10 * time.Second}, nil } - // --- Stage 2: Payment gate (Middleware + ConfigMap pricing route) --- - if err := r.ensureMiddleware(ctx, so, name, ns); err != nil { - setCondition(so, condPaymentGateReady, false, fmt.Sprintf("middleware error: %v", err)) + // 2. Apply Middleware. + if err := r.applyMiddleware(ctx, so, name, ns); err != nil { + setCondition(so, condPaymentGateReady, false, fmt.Sprintf("middleware: %v", err)) _ = r.updateStatus(ctx, so) return ctrl.Result{}, err } - if err := r.ensurePricingRoute(ctx, so, spec, name, ns); err != nil { - setCondition(so, condPaymentGateReady, false, fmt.Sprintf("pricing error: %v", err)) - _ = r.updateStatus(ctx, so) - return ctrl.Result{}, err - } - setCondition(so, condPaymentGateReady, true, "Middleware and pricing route configured") - // --- Stage 3: HTTPRoute --- - if err := r.ensureHTTPRoute(ctx, so, spec, name, ns); err != nil { - setCondition(so, condRoutePublished, false, fmt.Sprintf("httproute error: %v", err)) + // 3. Apply PaymentRoute CR (replaces ConfigMap mutation). + if err := r.applyPaymentRoute(ctx, so, spec, name, ns); err != nil { + setCondition(so, condPaymentGateReady, false, fmt.Sprintf("payment route: %v", err)) _ = r.updateStatus(ctx, so) return ctrl.Result{}, err } + setCondition(so, condPaymentGateReady, true, "Middleware and PaymentRoute applied") + + // 4. Apply HTTPRoute. path := spec.Path if path == "" { path = "/services/" + name } + if err := r.applyHTTPRoute(ctx, so, spec, name, ns, path); err != nil { + setCondition(so, condRoutePublished, false, fmt.Sprintf("httproute: %v", err)) + _ = r.updateStatus(ctx, so) + return ctrl.Result{}, err + } setCondition(so, condRoutePublished, true, "HTTPRoute published at "+path) - // Set endpoint in status. + // --- Observe and finalize --- setNestedField(so, path, "status", "endpoint") - - // --- All conditions met --- setCondition(so, condReady, true, "All conditions satisfied") setNestedField(so, so.GetGeneration(), "status", "observedGeneration") @@ -131,37 +135,29 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return ctrl.Result{}, err } - logger.Info("ServiceOffer reconciled", "name", name, "endpoint", path) + logger.Info("reconciled", "name", name, "endpoint", path) return ctrl.Result{}, nil } -// handleDeletion removes the pricing route from the ConfigMap (ownerRefs handle the rest). +// handleDeletion runs finalizer logic. OwnerReferences handle child resource +// GC for PaymentRoute, Middleware, and HTTPRoute. The finalizer is for any +// external side effects that can't be expressed via ownerRefs. func (r *Reconciler) handleDeletion(ctx context.Context, so *unstructured.Unstructured) (ctrl.Result, error) { - logger := log.FromContext(ctx) - if controllerutil.ContainsFinalizer(so, finalizerName) { - spec, err := getSpec(so) - if err == nil { - path := spec.Path - if path == "" { - path = "/services/" + so.GetName() - } - if rmErr := r.removePricingRoute(ctx, path, so.GetName()); rmErr != nil { - logger.Error(rmErr, "failed to remove pricing route, continuing cleanup") - } - } - + // Currently no external side effects beyond owned resources. + // ERC-8004 deactivation will be added here in a future phase. controllerutil.RemoveFinalizer(so, finalizerName) if err := r.Update(ctx, so); err != nil { return ctrl.Result{}, err } } - - logger.Info("ServiceOffer deleted", "name", so.GetName()) + log.FromContext(ctx).Info("deleted", "name", so.GetName()) return ctrl.Result{}, nil } // checkUpstreamHealth probes the upstream service health endpoint. +// Any HTTP response (even 4xx/5xx) counts as reachable, matching the +// existing monetize.py behavior. func (r *Reconciler) checkUpstreamHealth(ctx context.Context, spec *offerSpec) (bool, string) { healthPath := spec.Upstream.HealthPath if healthPath == "" { @@ -187,7 +183,6 @@ func (r *Reconciler) checkUpstreamHealth(ctx context.Context, spec *offerSpec) ( } defer resp.Body.Close() - // Any response = reachable (matches monetize.py behavior). return true, fmt.Sprintf("GET %s returned %d", healthPath, resp.StatusCode) } diff --git a/internal/controller/reconciler_test.go b/internal/controller/reconciler_test.go index 129ced5..43a11d1 100644 --- a/internal/controller/reconciler_test.go +++ b/internal/controller/reconciler_test.go @@ -1,6 +1,7 @@ package controller import ( + "strings" "testing" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -30,16 +31,39 @@ func TestGetSpec_Valid(t *testing.T) { } if spec.Upstream.Service != "my-svc" { - t.Errorf("expected service my-svc, got %s", spec.Upstream.Service) + t.Errorf("service: got %s, want my-svc", spec.Upstream.Service) } if spec.Upstream.Port != 8080 { - t.Errorf("expected port 8080, got %d", spec.Upstream.Port) + t.Errorf("port: got %d, want 8080", spec.Upstream.Port) } if spec.Payment.PayTo != "0x1234" { - t.Errorf("expected payTo 0x1234, got %s", spec.Payment.PayTo) + t.Errorf("payTo: got %s, want 0x1234", spec.Payment.PayTo) } if spec.Path != "/services/test" { - t.Errorf("expected path /services/test, got %s", spec.Path) + t.Errorf("path: got %s, want /services/test", spec.Path) + } +} + +func TestGetSpec_DefaultPath(t *testing.T) { + so := makeServiceOffer("myapi", "llm", map[string]interface{}{ + "upstream": map[string]interface{}{ + "service": "ollama", + "namespace": "llm", + "port": int64(11434), + }, + "payment": map[string]interface{}{ + "network": "base-sepolia", + "payTo": "0xABC", + "price": map[string]interface{}{"perRequest": "0.01"}, + }, + }) + + spec, err := getSpec(so) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if spec.Path != "" { + t.Errorf("expected empty path (default), got %s", spec.Path) } } @@ -63,18 +87,12 @@ func TestSetCondition_NewCondition(t *testing.T) { setCondition(so, condUpstreamHealthy, true, "healthy") - conditions, _, _ := nestedSlice(so, "status", "conditions") + conditions := getConditions(t, so) if len(conditions) != 1 { t.Fatalf("expected 1 condition, got %d", len(conditions)) } - cond := conditions[0].(map[string]interface{}) - if cond["type"] != condUpstreamHealthy { - t.Errorf("expected type %s, got %s", condUpstreamHealthy, cond["type"]) - } - if cond["status"] != "True" { - t.Errorf("expected status True, got %s", cond["status"]) - } + assertCondition(t, conditions[0], condUpstreamHealthy, "True", "healthy") } func TestSetCondition_UpdateExisting(t *testing.T) { @@ -83,33 +101,43 @@ func TestSetCondition_UpdateExisting(t *testing.T) { setCondition(so, condUpstreamHealthy, false, "down") setCondition(so, condUpstreamHealthy, true, "up") - conditions, _, _ := nestedSlice(so, "status", "conditions") + conditions := getConditions(t, so) if len(conditions) != 1 { - t.Fatalf("expected 1 condition, got %d", len(conditions)) + t.Fatalf("expected 1 condition after update, got %d", len(conditions)) } - cond := conditions[0].(map[string]interface{}) - if cond["status"] != "True" { - t.Errorf("expected status True, got %s", cond["status"]) - } - if cond["message"] != "up" { - t.Errorf("expected message 'up', got %s", cond["message"]) - } + assertCondition(t, conditions[0], condUpstreamHealthy, "True", "up") } -func TestSetCondition_MultipleConditions(t *testing.T) { +func TestSetCondition_PreservesOtherConditions(t *testing.T) { so := makeServiceOffer("test", "default", nil) setCondition(so, condUpstreamHealthy, true, "ok") setCondition(so, condPaymentGateReady, true, "ok") setCondition(so, condRoutePublished, false, "pending") - conditions, _, _ := nestedSlice(so, "status", "conditions") + conditions := getConditions(t, so) if len(conditions) != 3 { t.Fatalf("expected 3 conditions, got %d", len(conditions)) } } +func TestSetCondition_PreservesLastTransitionTimeOnNoChange(t *testing.T) { + so := makeServiceOffer("test", "default", nil) + + setCondition(so, condReady, true, "first") + conds1 := getConditions(t, so) + ts1 := conds1[0].(map[string]interface{})["lastTransitionTime"] + + setCondition(so, condReady, true, "second") + conds2 := getConditions(t, so) + ts2 := conds2[0].(map[string]interface{})["lastTransitionTime"] + + if ts1 != ts2 { + t.Errorf("lastTransitionTime should not change when status unchanged") + } +} + func TestEffectiveRequestPrice(t *testing.T) { tests := []struct { name string @@ -120,13 +148,12 @@ func TestEffectiveRequestPrice(t *testing.T) { {"perMTok fallback", priceTable{PerMTok: "1.0"}, "1.0"}, {"perHour fallback", priceTable{PerHour: "0.5"}, "0.5"}, {"all empty", priceTable{}, "0"}, - {"perRequest takes precedence", priceTable{PerRequest: "0.01", PerMTok: "1.0"}, "0.01"}, + {"perRequest precedence", priceTable{PerRequest: "0.01", PerMTok: "1.0"}, "0.01"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := tt.price.effectiveRequestPrice() - if got != tt.want { + if got := tt.price.effectiveRequestPrice(); got != tt.want { t.Errorf("got %s, want %s", got, tt.want) } }) @@ -139,53 +166,78 @@ func TestPriceModel(t *testing.T) { price priceTable want string }{ - {"perRequest", priceTable{PerRequest: "0.001"}, "perRequest"}, - {"perMTok", priceTable{PerMTok: "1.0"}, "perMTok"}, - {"perHour", priceTable{PerHour: "0.5"}, "perHour"}, - {"default", priceTable{}, "perRequest"}, + {"perRequest", priceTable{PerRequest: "0.001"}, "per-request"}, + {"perMTok", priceTable{PerMTok: "1.0"}, "per-mtok"}, + {"perHour", priceTable{PerHour: "0.5"}, "per-hour"}, + {"default", priceTable{}, "per-request"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := tt.price.priceModel() - if got != tt.want { + if got := tt.price.priceModel(); got != tt.want { t.Errorf("got %s, want %s", got, tt.want) } }) } } -func TestBuildRouteEntry(t *testing.T) { - spec := &offerSpec{ - Payment: paymentSpec{ - Network: "base-sepolia", - PayTo: "0xABC", - Price: priceTable{PerRequest: "0.001"}, - }, - } +func TestSetOwnerRef(t *testing.T) { + owner := makeServiceOffer("my-offer", "llm", nil) + child := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "obol.org/v1alpha1", + "kind": "PaymentRoute", + "metadata": map[string]interface{}{"name": "test-pr", "namespace": "x402"}, + }} - entry := buildRouteEntry("/services/test/*", "0.001", "test", "default", spec) + setOwnerRef(child, owner) - if !contains(entry, `pattern: "/services/test/*"`) { - t.Error("missing pattern") + refs, ok, _ := unstructured.NestedSlice(child.Object, "metadata", "ownerReferences") + if !ok || len(refs) != 1 { + t.Fatalf("expected 1 ownerRef, got %d", len(refs)) } - if !contains(entry, `price: "0.001"`) { - t.Error("missing price") + + ref := refs[0].(map[string]interface{}) + if ref["kind"] != "ServiceOffer" { + t.Errorf("ownerRef kind: got %s, want ServiceOffer", ref["kind"]) } - if !contains(entry, `payTo: "0xABC"`) { - t.Error("missing payTo") + if ref["name"] != "my-offer" { + t.Errorf("ownerRef name: got %s, want my-offer", ref["name"]) } - if !contains(entry, `network: "base-sepolia"`) { - t.Error("missing network") + if ref["controller"] != true { + t.Error("ownerRef should be controller") } - if !contains(entry, `offerName: "test"`) { - t.Error("missing offerName") + if ref["blockOwnerDeletion"] != true { + t.Error("ownerRef should block owner deletion") + } +} + +func TestComputePhase(t *testing.T) { + so := makeServiceOffer("test", "default", nil) + + if phase := computePhase(so); phase != "Reconciling" { + t.Errorf("empty conditions: got %s, want Reconciling", phase) + } + + setCondition(so, condUpstreamHealthy, true, "ok") + if phase := computePhase(so); phase != "Reconciling" { + t.Errorf("partial conditions: got %s, want Reconciling", phase) + } + + setCondition(so, condReady, true, "all good") + if phase := computePhase(so); phase != "Ready" { + t.Errorf("ready: got %s, want Ready", phase) + } +} + +func TestFinalizerName(t *testing.T) { + if !strings.Contains(finalizerName, "obol.org") { + t.Errorf("finalizer should be in obol.org domain, got %s", finalizerName) } } // --- helpers --- -func makeServiceOffer(name, ns string, spec map[string]interface{}) *unstructured.Unstructured { //nolint:unparam +func makeServiceOffer(name, ns string, spec map[string]interface{}) *unstructured.Unstructured { so := &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "obol.org/v1alpha1", @@ -205,19 +257,25 @@ func makeServiceOffer(name, ns string, spec map[string]interface{}) *unstructure return so } -func nestedSlice(so *unstructured.Unstructured, fields ...string) ([]interface{}, bool, error) { - return unstructured.NestedSlice(so.Object, fields...) +func getConditions(t *testing.T, so *unstructured.Unstructured) []interface{} { + t.Helper() + conditions, _, _ := unstructured.NestedSlice(so.Object, "status", "conditions") + return conditions } -func contains(s, substr string) bool { - return len(s) > 0 && len(substr) > 0 && stringContains(s, substr) -} - -func stringContains(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } +func assertCondition(t *testing.T, raw interface{}, wantType, wantStatus, wantMsg string) { + t.Helper() + cond, ok := raw.(map[string]interface{}) + if !ok { + t.Fatal("condition is not a map") + } + if cond["type"] != wantType { + t.Errorf("type: got %s, want %s", cond["type"], wantType) + } + if cond["status"] != wantStatus { + t.Errorf("status: got %s, want %s", cond["status"], wantStatus) + } + if cond["message"] != wantMsg { + t.Errorf("message: got %s, want %s", cond["message"], wantMsg) } - return false } diff --git a/internal/controller/resources.go b/internal/controller/resources.go index 8cf4d5d..e7ed0f5 100644 --- a/internal/controller/resources.go +++ b/internal/controller/resources.go @@ -3,17 +3,13 @@ package controller import ( "context" "fmt" - "strings" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/log" ) -// ensureMiddleware creates or updates the Traefik ForwardAuth Middleware. -func (r *Reconciler) ensureMiddleware(ctx context.Context, so *unstructured.Unstructured, name, ns string) error { +// applyMiddleware creates or updates the Traefik ForwardAuth Middleware. +func (r *Reconciler) applyMiddleware(ctx context.Context, so *unstructured.Unstructured, name, ns string) error { mw := &unstructured.Unstructured{} mw.SetGroupVersionKind(middlewareGVK) mw.SetName("x402-" + name) @@ -21,7 +17,7 @@ func (r *Reconciler) ensureMiddleware(ctx context.Context, so *unstructured.Unst _ = unstructured.SetNestedMap(mw.Object, map[string]interface{}{ "forwardAuth": map[string]interface{}{ - "address": "http://x402-verifier.x402.svc.cluster.local:8080/verify", + "address": fmt.Sprintf("http://x402-verifier.%s.svc.cluster.local:8080/verify", verifierNamespace), "authResponseHeaders": []interface{}{ "X-Payment-Status", "X-Payment-Tx", @@ -31,24 +27,43 @@ func (r *Reconciler) ensureMiddleware(ctx context.Context, so *unstructured.Unst }, "spec") setOwnerRef(mw, so) - - existing := &unstructured.Unstructured{} - existing.SetGroupVersionKind(middlewareGVK) - err := r.Get(ctx, types.NamespacedName{Name: mw.GetName(), Namespace: ns}, existing) - if err != nil { - return r.Create(ctx, mw) - } - mw.SetResourceVersion(existing.GetResourceVersion()) - return r.Update(ctx, mw) + return r.createOrUpdate(ctx, mw) } -// ensureHTTPRoute creates or updates the Gateway API HTTPRoute. -func (r *Reconciler) ensureHTTPRoute(ctx context.Context, so *unstructured.Unstructured, spec *offerSpec, name, ns string) error { +// applyPaymentRoute creates or updates the PaymentRoute CR. +// This replaces the old ConfigMap string mutation approach. +func (r *Reconciler) applyPaymentRoute(ctx context.Context, so *unstructured.Unstructured, spec *offerSpec, name, ns string) error { path := spec.Path if path == "" { path = "/services/" + name } + pr := &unstructured.Unstructured{} + pr.SetGroupVersionKind(paymentRouteGVK) + pr.SetName(name + "-payment") + pr.SetNamespace(verifierNamespace) + + prSpec := map[string]interface{}{ + "pattern": path + "/*", + "price": spec.Payment.Price.effectiveRequestPrice(), + "payTo": spec.Payment.PayTo, + "network": spec.Payment.Network, + "description": fmt.Sprintf("ServiceOffer %s", name), + "priceModel": spec.Payment.Price.priceModel(), + } + + if spec.Payment.Price.PerMTok != "" { + prSpec["perMTok"] = spec.Payment.Price.PerMTok + prSpec["approxTokensPerRequest"] = int64(1000) + } + + _ = unstructured.SetNestedMap(pr.Object, prSpec, "spec") + setOwnerRef(pr, so) + return r.createOrUpdate(ctx, pr) +} + +// applyHTTPRoute creates or updates the Gateway API HTTPRoute. +func (r *Reconciler) applyHTTPRoute(ctx context.Context, so *unstructured.Unstructured, spec *offerSpec, name, ns, path string) error { route := &unstructured.Unstructured{} route.SetGroupVersionKind(httpRouteGVK) route.SetName("so-" + name) @@ -103,142 +118,28 @@ func (r *Reconciler) ensureHTTPRoute(ctx context.Context, so *unstructured.Unstr }, "spec") setOwnerRef(route, so) - - existing := &unstructured.Unstructured{} - existing.SetGroupVersionKind(httpRouteGVK) - err := r.Get(ctx, types.NamespacedName{Name: route.GetName(), Namespace: ns}, existing) - if err != nil { - return r.Create(ctx, route) - } - route.SetResourceVersion(existing.GetResourceVersion()) - return r.Update(ctx, route) + return r.createOrUpdate(ctx, route) } -// ensurePricingRoute adds a route entry to the x402-pricing ConfigMap. -func (r *Reconciler) ensurePricingRoute(ctx context.Context, so *unstructured.Unstructured, spec *offerSpec, name, ns string) error { - path := spec.Path - if path == "" { - path = "/services/" + name - } - pattern := path + "/*" - - cm := &unstructured.Unstructured{} - cm.SetGroupVersionKind(configMapGVK) - if err := r.Get(ctx, types.NamespacedName{Name: verifierConfigMap, Namespace: verifierNamespace}, cm); err != nil { - return fmt.Errorf("get x402-pricing ConfigMap: %w", err) - } - - data, _, _ := unstructured.NestedStringMap(cm.Object, "data") - if data == nil { - data = map[string]string{} - } - - pricingYAML := data["pricing.yaml"] - - // Check if route already exists. - if strings.Contains(pricingYAML, fmt.Sprintf("pattern: %q", pattern)) { - return nil // already present - } - - // Build route entry. - price := spec.Payment.Price.effectiveRequestPrice() - entry := buildRouteEntry(pattern, price, name, ns, spec) - - // Append to routes section. - if strings.Contains(pricingYAML, "routes: []") { - pricingYAML = strings.Replace(pricingYAML, "routes: []", "routes:\n"+entry, 1) - } else if strings.Contains(pricingYAML, "routes:") { - pricingYAML += entry - } else { - pricingYAML += "\nroutes:\n" + entry - } - - data["pricing.yaml"] = pricingYAML - _ = unstructured.SetNestedStringMap(cm.Object, data, "data") - - return r.Update(ctx, cm) -} - -// removePricingRoute removes a route entry from the x402-pricing ConfigMap. -func (r *Reconciler) removePricingRoute(ctx context.Context, path, name string) error { - logger := log.FromContext(ctx) - - cm := &unstructured.Unstructured{} - cm.SetGroupVersionKind(configMapGVK) - if err := r.Get(ctx, types.NamespacedName{Name: verifierConfigMap, Namespace: verifierNamespace}, cm); err != nil { - logger.Error(err, "failed to get x402-pricing ConfigMap for cleanup") - return err - } - - data, _, _ := unstructured.NestedStringMap(cm.Object, "data") - if data == nil { - return nil - } - - pricingYAML := data["pricing.yaml"] - pattern := path + "/*" - - // Remove the route block starting with this pattern. - lines := strings.Split(pricingYAML, "\n") - var filtered []string - skip := false - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.Contains(trimmed, fmt.Sprintf("pattern: %q", pattern)) { - skip = true - continue - } - if skip { - // Skip continuation lines (indented fields of the same route). - if strings.HasPrefix(trimmed, "- pattern:") || trimmed == "" || !strings.HasPrefix(line, " ") { - skip = false - } else { - continue - } - } - if !skip { - filtered = append(filtered, line) - } - } +// createOrUpdate performs a get-then-create-or-update for an unstructured resource. +func (r *Reconciler) createOrUpdate(ctx context.Context, obj *unstructured.Unstructured) error { + key := types.NamespacedName{Name: obj.GetName(), Namespace: obj.GetNamespace()} + existing := &unstructured.Unstructured{} + existing.SetGroupVersionKind(obj.GroupVersionKind()) - result := strings.Join(filtered, "\n") - // If no routes remain, set empty list. - if !strings.Contains(result, "- pattern:") && strings.Contains(result, "routes:") { - result = strings.Replace(result, "routes:", "routes: []", 1) + err := r.Get(ctx, key, existing) + if err != nil { + // Not found — create. + return r.Create(ctx, obj) } - data["pricing.yaml"] = result - _ = unstructured.SetNestedStringMap(cm.Object, data, "data") - - return r.Update(ctx, cm) -} - -// buildRouteEntry builds a YAML route entry for the pricing ConfigMap. -func buildRouteEntry(pattern, price, name, ns string, spec *offerSpec) string { - var b strings.Builder - fmt.Fprintf(&b, " - pattern: %q\n", pattern) - fmt.Fprintf(&b, " price: %q\n", price) - fmt.Fprintf(&b, " description: \"ServiceOffer %s\"\n", name) - if spec.Payment.PayTo != "" { - fmt.Fprintf(&b, " payTo: %q\n", spec.Payment.PayTo) - } - if spec.Payment.Network != "" { - fmt.Fprintf(&b, " network: %q\n", spec.Payment.Network) - } - fmt.Fprintf(&b, " priceModel: %q\n", spec.Payment.Price.priceModel()) - if spec.Payment.Price.PerMTok != "" { - fmt.Fprintf(&b, " perMTok: %q\n", spec.Payment.Price.PerMTok) - fmt.Fprintf(&b, " approxTokensPerRequest: 1000\n") - } - fmt.Fprintf(&b, " offerNamespace: %q\n", ns) - fmt.Fprintf(&b, " offerName: %q\n", name) - return b.String() + // Found — update (preserve resourceVersion). + obj.SetResourceVersion(existing.GetResourceVersion()) + return r.Update(ctx, obj) } // setOwnerRef sets the ServiceOffer as the controller owner of a child resource. func setOwnerRef(child, owner *unstructured.Unstructured) { - _ = controllerutil.SetControllerReference(owner, child, nil) - // controllerutil needs a scheme; since we're unstructured, set manually. refs := []interface{}{ map[string]interface{}{ "apiVersion": "obol.org/v1alpha1", @@ -251,21 +152,3 @@ func setOwnerRef(child, owner *unstructured.Unstructured) { } _ = unstructured.SetNestedSlice(child.Object, refs, "metadata", "ownerReferences") } - -var ( - middlewareGVK = schema.GroupVersionKind{ - Group: "traefik.io", - Version: "v1alpha1", - Kind: "Middleware", - } - httpRouteGVK = schema.GroupVersionKind{ - Group: "gateway.networking.k8s.io", - Version: "v1", - Kind: "HTTPRoute", - } - configMapGVK = schema.GroupVersionKind{ - Group: "", - Version: "v1", - Kind: "ConfigMap", - } -) diff --git a/internal/embed/infrastructure/base/templates/paymentroute-crd.yaml b/internal/embed/infrastructure/base/templates/paymentroute-crd.yaml new file mode 100644 index 0000000..66c7300 --- /dev/null +++ b/internal/embed/infrastructure/base/templates/paymentroute-crd.yaml @@ -0,0 +1,86 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: paymentroutes.obol.org +spec: + group: obol.org + names: + kind: PaymentRoute + listKind: PaymentRouteList + plural: paymentroutes + singular: paymentroute + shortNames: + - pr + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: + - pattern + - price + - payTo + - network + properties: + pattern: + type: string + description: "URL path pattern (e.g., /services/myapi/*)" + price: + type: string + description: "USDC price per request in human-readable decimals" + payTo: + type: string + description: "Seller wallet address (0x-prefixed)" + network: + type: string + description: "Chain name (e.g., base-sepolia)" + facilitatorURL: + type: string + description: "x402 facilitator URL" + description: + type: string + priceModel: + type: string + enum: ["per-request", "per-mtok", "per-hour"] + perMTok: + type: string + description: "Original per-MTok price if approximated" + approxTokensPerRequest: + type: integer + upstreamAuth: + type: string + description: "Authorization header for upstream (injected by verifier)" + status: + type: object + properties: + admitted: + type: boolean + description: "True when verifier has loaded this route" + lastAdmittedGeneration: + type: integer + format: int64 + additionalPrinterColumns: + - name: Pattern + type: string + jsonPath: .spec.pattern + - name: Price + type: string + jsonPath: .spec.price + - name: Network + type: string + jsonPath: .spec.network + - name: Admitted + type: boolean + jsonPath: .status.admitted + - name: Age + type: date + jsonPath: .metadata.creationTimestamp diff --git a/internal/x402/source/paymentroute.go b/internal/x402/source/paymentroute.go new file mode 100644 index 0000000..7a92752 --- /dev/null +++ b/internal/x402/source/paymentroute.go @@ -0,0 +1,213 @@ +// Package source provides config sources for the x402 verifier. +// PaymentRouteSource watches PaymentRoute CRDs and builds the verifier's +// PricingConfig from them, replacing the old ConfigMap file watcher. +package source + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + x402 "github.com/ObolNetwork/obol-stack/internal/x402" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/tools/cache" +) + +var paymentRouteGVR = schema.GroupVersionResource{ + Group: "obol.org", + Version: "v1alpha1", + Resource: "paymentroutes", +} + +// PaymentRouteSource watches PaymentRoute CRs and rebuilds the verifier +// config whenever routes change. It replaces the file-based WatchConfig. +type PaymentRouteSource struct { + verifier *x402.Verifier + client dynamic.Interface + namespace string // watch namespace, "" for all namespaces + + mu sync.RWMutex + routes map[string]x402.RouteRule // key: CR name +} + +// NewPaymentRouteSource creates a source that watches PaymentRoute CRs. +func NewPaymentRouteSource(client dynamic.Interface, verifier *x402.Verifier, namespace string) *PaymentRouteSource { + return &PaymentRouteSource{ + verifier: verifier, + client: client, + namespace: namespace, + routes: make(map[string]x402.RouteRule), + } +} + +// Run starts the informer and blocks until ctx is cancelled. +func (s *PaymentRouteSource) Run(ctx context.Context) error { + factory := dynamicinformer.NewFilteredDynamicSharedInformerFactory( + s.client, 30*time.Second, s.namespace, nil, + ) + + informer := factory.ForResource(paymentRouteGVR).Informer() + + _, err := informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { s.handleEvent(ctx, obj) }, + UpdateFunc: func(_, obj interface{}) { s.handleEvent(ctx, obj) }, + DeleteFunc: func(obj interface{}) { s.handleDelete(ctx, obj) }, + }) + if err != nil { + return fmt.Errorf("add event handler: %w", err) + } + + log.Printf("paymentroute-source: watching PaymentRoute CRs in namespace %q", s.namespace) + informer.Run(ctx.Done()) + return nil +} + +func (s *PaymentRouteSource) handleEvent(ctx context.Context, obj interface{}) { + u, ok := obj.(*unstructured.Unstructured) + if !ok { + return + } + + rule, err := paymentRouteToRule(u) + if err != nil { + log.Printf("paymentroute-source: convert %s: %v", u.GetName(), err) + return + } + + s.mu.Lock() + s.routes[u.GetName()] = rule + s.mu.Unlock() + + s.rebuildConfig() + s.markAdmitted(ctx, u) +} + +func (s *PaymentRouteSource) handleDelete(ctx context.Context, obj interface{}) { + u, ok := obj.(*unstructured.Unstructured) + if !ok { + // Handle DeletedFinalStateUnknown. + tombstone, ok := obj.(cache.DeletedFinalStateUnknown) + if !ok { + return + } + u, ok = tombstone.Obj.(*unstructured.Unstructured) + if !ok { + return + } + } + + s.mu.Lock() + delete(s.routes, u.GetName()) + s.mu.Unlock() + + s.rebuildConfig() +} + +func (s *PaymentRouteSource) rebuildConfig() { + s.mu.RLock() + routes := make([]x402.RouteRule, 0, len(s.routes)) + for _, r := range s.routes { + routes = append(routes, r) + } + s.mu.RUnlock() + + // Rebuild config preserving global settings from the current config. + current := s.verifier.Config() + cfg := &x402.PricingConfig{ + Wallet: current.Wallet, + Chain: current.Chain, + FacilitatorURL: current.FacilitatorURL, + VerifyOnly: current.VerifyOnly, + Routes: routes, + } + + if err := s.verifier.Reload(cfg); err != nil { + log.Printf("paymentroute-source: reload failed: %v", err) + return + } + + log.Printf("paymentroute-source: loaded %d routes from PaymentRoute CRs", len(routes)) +} + +func (s *PaymentRouteSource) markAdmitted(ctx context.Context, u *unstructured.Unstructured) { + patch := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "obol.org/v1alpha1", + "kind": "PaymentRoute", + "metadata": map[string]interface{}{ + "name": u.GetName(), + "namespace": u.GetNamespace(), + }, + "status": map[string]interface{}{ + "admitted": true, + "lastAdmittedGeneration": u.GetGeneration(), + }, + }, + } + + _, err := s.client.Resource(paymentRouteGVR).Namespace(u.GetNamespace()). + UpdateStatus(ctx, patch, metav1.UpdateOptions{}) + if err != nil { + log.Printf("paymentroute-source: mark admitted %s: %v", u.GetName(), err) + } +} + +func paymentRouteToRule(u *unstructured.Unstructured) (x402.RouteRule, error) { + spec, ok, _ := unstructured.NestedMap(u.Object, "spec") + if !ok { + return x402.RouteRule{}, fmt.Errorf("missing spec") + } + + pattern, _, _ := unstructured.NestedString(spec, "pattern") + price, _, _ := unstructured.NestedString(spec, "price") + payTo, _, _ := unstructured.NestedString(spec, "payTo") + network, _, _ := unstructured.NestedString(spec, "network") + facilitatorURL, _, _ := unstructured.NestedString(spec, "facilitatorURL") + description, _, _ := unstructured.NestedString(spec, "description") + priceModel, _, _ := unstructured.NestedString(spec, "priceModel") + perMTok, _, _ := unstructured.NestedString(spec, "perMTok") + approxTokens, _, _ := unstructured.NestedInt64(spec, "approxTokensPerRequest") + upstreamAuth, _, _ := unstructured.NestedString(spec, "upstreamAuth") + + if pattern == "" || price == "" { + return x402.RouteRule{}, fmt.Errorf("pattern and price are required") + } + + // Derive offer identity from ownerReferences. + var offerName, offerNS string + refs, _, _ := unstructured.NestedSlice(u.Object, "metadata", "ownerReferences") + for _, ref := range refs { + r, ok := ref.(map[string]interface{}) + if !ok { + continue + } + if kind, _, _ := unstructured.NestedString(r, "kind"); kind == "ServiceOffer" { + offerName, _, _ = unstructured.NestedString(r, "name") + break + } + } + offerNS = u.GetNamespace() + + _ = facilitatorURL // stored globally on verifier, not per-route + + return x402.RouteRule{ + Pattern: pattern, + Price: price, + Description: description, + PayTo: payTo, + Network: network, + UpstreamAuth: upstreamAuth, + PriceModel: priceModel, + PerMTok: perMTok, + ApproxTokensPerRequest: int(approxTokens), + OfferNamespace: offerNS, + OfferName: offerName, + }, nil +} diff --git a/internal/x402/source/paymentroute_test.go b/internal/x402/source/paymentroute_test.go new file mode 100644 index 0000000..4b65452 --- /dev/null +++ b/internal/x402/source/paymentroute_test.go @@ -0,0 +1,123 @@ +package source + +import ( + "testing" + + x402 "github.com/ObolNetwork/obol-stack/internal/x402" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestPaymentRouteToRule(t *testing.T) { + u := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "obol.org/v1alpha1", + "kind": "PaymentRoute", + "metadata": map[string]interface{}{ + "name": "myapi-payment", + "namespace": "x402", + "ownerReferences": []interface{}{ + map[string]interface{}{ + "apiVersion": "obol.org/v1alpha1", + "kind": "ServiceOffer", + "name": "myapi", + "uid": "abc-123", + }, + }, + }, + "spec": map[string]interface{}{ + "pattern": "/services/myapi/*", + "price": "0.001", + "payTo": "0xABC", + "network": "base-sepolia", + "priceModel": "per-request", + }, + }, + } + + rule, err := paymentRouteToRule(u) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if rule.Pattern != "/services/myapi/*" { + t.Errorf("pattern: got %s, want /services/myapi/*", rule.Pattern) + } + if rule.Price != "0.001" { + t.Errorf("price: got %s, want 0.001", rule.Price) + } + if rule.PayTo != "0xABC" { + t.Errorf("payTo: got %s, want 0xABC", rule.PayTo) + } + if rule.Network != "base-sepolia" { + t.Errorf("network: got %s, want base-sepolia", rule.Network) + } + if rule.OfferName != "myapi" { + t.Errorf("offerName: got %s, want myapi", rule.OfferName) + } +} + +func TestPaymentRouteToRule_MissingPattern(t *testing.T) { + u := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "obol.org/v1alpha1", + "kind": "PaymentRoute", + "metadata": map[string]interface{}{"name": "bad", "namespace": "x402"}, + "spec": map[string]interface{}{ + "price": "0.001", + "payTo": "0x123", + }, + }, + } + + _, err := paymentRouteToRule(u) + if err == nil { + t.Fatal("expected error for missing pattern") + } +} + +func TestPaymentRouteToRule_WithPerMTok(t *testing.T) { + u := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "obol.org/v1alpha1", + "kind": "PaymentRoute", + "metadata": map[string]interface{}{"name": "mtok-test", "namespace": "x402"}, + "spec": map[string]interface{}{ + "pattern": "/services/inference/*", + "price": "0.001", + "payTo": "0x123", + "network": "base-sepolia", + "priceModel": "per-mtok", + "perMTok": "1.0", + "approxTokensPerRequest": int64(1000), + }, + }, + } + + rule, err := paymentRouteToRule(u) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if rule.PerMTok != "1.0" { + t.Errorf("perMTok: got %s, want 1.0", rule.PerMTok) + } + if rule.ApproxTokensPerRequest != 1000 { + t.Errorf("approxTokens: got %d, want 1000", rule.ApproxTokensPerRequest) + } +} + +func TestPaymentRouteSourceRebuild(t *testing.T) { + // Test that the routes map builds correctly. + s := &PaymentRouteSource{ + routes: map[string]x402.RouteRule{ + "a": {Pattern: "/a/*", Price: "0.01"}, + "b": {Pattern: "/b/*", Price: "0.02"}, + }, + } + + s.mu.RLock() + if len(s.routes) != 2 { + t.Errorf("expected 2 routes, got %d", len(s.routes)) + } + s.mu.RUnlock() +} diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index 208a335..d579625 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -33,6 +33,11 @@ func NewVerifier(cfg *PricingConfig) (*Verifier, error) { return v, nil } +// Config returns the current pricing configuration (thread-safe). +func (v *Verifier) Config() *PricingConfig { + return v.config.Load() +} + // Reload atomically swaps the pricing configuration. func (v *Verifier) Reload(cfg *PricingConfig) error { return v.load(cfg) From b977d023a32ec2909023f166bc984d364124e344 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Sun, 29 Mar 2026 05:30:01 +0200 Subject: [PATCH 3/3] chore: delete monetize.py and update CLAUDE.md for controller architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete monetize.py (1927 lines) — replaced by serviceoffer-controller - Delete watcher.go (57 lines) — replaced by PaymentRoute informer - Update CLAUDE.md: document PaymentRoute CRD, serviceoffer-controller, verifier PaymentRoute watch. Remove references to monetize.py and ConfigMap-based pricing. Refs: #296 --- CLAUDE.md | 8 +- cmd/x402-verifier/main.go | 43 +- .../embed/skills/sell/scripts/monetize.py | 1927 ----------------- internal/x402/watcher.go | 57 - 4 files changed, 21 insertions(+), 2014 deletions(-) delete mode 100644 internal/embed/skills/sell/scripts/monetize.py delete mode 100644 internal/x402/watcher.go diff --git a/CLAUDE.md b/CLAUDE.md index e0ed19a..0ad09a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ Components: eRPC (`erpc` ns), Frontend (`obol-frontend` ns), Cloudflared (`traef Payment-gated access to cluster services via x402 (HTTP 402 micropayments, USDC on Base/Base Sepolia, Traefik ForwardAuth). -**Sell-side flow**: `obol sell http` → creates ServiceOffer CR → agent reconciles 6 stages: ModelReady → UpstreamHealthy → PaymentGateReady (x402 Middleware + pricing route) → RoutePublished (HTTPRoute) → Registered (ERC-8004 on-chain) → Ready. Traefik routes `/services//*` through ForwardAuth to upstream. +**Sell-side flow**: `obol sell http` → creates ServiceOffer CR → `serviceoffer-controller` (controller-runtime, own Deployment in `obol-system` ns) derives child resources: Middleware (traefik.io ForwardAuth), PaymentRoute CR (obol.org), HTTPRoute (gateway API). Status conditions: UpstreamHealthy → PaymentGateReady → RoutePublished → Ready with `observedGeneration`. Finalizer ensures cleanup on deletion. **Buy-side flow**: `buy.py probe` sees 402 pricing → `buy.py buy` pre-signs ERC-3009 auths into ConfigMaps → LiteLLM serves static `paid/` aliases through the in-pod `x402-buyer` sidecar → each paid request spends one auth and forwards to the remote seller. @@ -76,9 +76,11 @@ Payment-gated access to cluster services via x402 (HTTP 402 micropayments, USDC **ServiceOffer CRD** (`obol.org`): Spec fields — `type` (inference|fine-tuning), `model{name,runtime}`, `upstream{service,ns,port,healthPath}`, `payment{scheme,network,payTo,price{perRequest,perMTok,perHour}}`, `path`, `registration{enabled,name,description,image}`. In phase 1, `perMTok` is accepted but enforced as `perRequest = perMTok / 1000`. -**x402-verifier** (`x402` ns): ForwardAuth middleware. No match → pass through. Match + no payment → 402. Match + payment → verify with facilitator. Config in `x402-pricing` ConfigMap: `wallet`, `chain`, `facilitatorURL`, `verifyOnly`, `routes[]{pattern, price, description, priceModel, perMTok, approxTokensPerRequest, offerNamespace, offerName}`. Exposes `/metrics` and is scraped via `ServiceMonitor`. +**PaymentRoute CRD** (`obol.org`): One CR per monetized route, owned by ServiceOffer. Replaces the shared `x402-pricing` ConfigMap. Spec: `pattern`, `price`, `payTo`, `network`, `priceModel`, `perMTok`, `approxTokensPerRequest`, `upstreamAuth`. Status: `admitted` (set by verifier when route is loaded). -**Agent reconciler** (`internal/embed/skills/monetize/scripts/monetize.py`): Watches ServiceOffer CRs, creates Middleware (`traefik.io`), HTTPRoute, pricing route in ConfigMap, registration resources (ConfigMap + httpd + HTTPRoute at `/.well-known/`). All with ownerReferences for auto-GC. +**x402-verifier** (`x402` ns): ForwardAuth middleware. Watches PaymentRoute CRs via dynamic informer (replaces ConfigMap file polling). No match → pass through. Match + no payment → 402. Match + payment → verify with facilitator. Global config (wallet, chain, facilitatorURL) from ConfigMap; per-route config from PaymentRoute CRs. Exposes `/metrics` and is scraped via `ServiceMonitor`. Separate Deployment from controller (different failure domain, scales on QPS). + +**serviceoffer-controller** (`obol-system` ns): controller-runtime operator that reconciles ServiceOffer CRDs. Generation-driven: derives Middleware, PaymentRoute, HTTPRoute from spec, observes convergence. Finalizer for cleanup. Replaces `monetize.py` (deleted). **ERC-8004**: On-chain registration on Base Sepolia Identity Registry (`0xEA0fE4FCF9E3017a24d9Db6e0e39B552c8648B9D`). NFT mint via remote-signer wallet, publishes `/.well-known/agent-registration.json`. diff --git a/cmd/x402-verifier/main.go b/cmd/x402-verifier/main.go index 847a094..f2f2bef 100644 --- a/cmd/x402-verifier/main.go +++ b/cmd/x402-verifier/main.go @@ -21,7 +21,6 @@ import ( func main() { configPath := flag.String("config", "/config/pricing.yaml", "Path to pricing config YAML (global settings)") listen := flag.String("listen", ":8080", "Listen address") - routeSource := flag.String("route-source", "paymentroute", "Route source: paymentroute (CRD watch) or configmap (legacy file watcher)") routeNamespace := flag.String("route-namespace", "x402", "Namespace to watch for PaymentRoute CRs") flag.Parse() @@ -57,35 +56,25 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Start route source. - switch *routeSource { - case "paymentroute": - restCfg, err := rest.InClusterConfig() - if err != nil { - log.Fatalf("in-cluster config: %v (use --route-source=configmap outside cluster)", err) - } - - dynClient, err := dynamic.NewForConfig(restCfg) - if err != nil { - log.Fatalf("dynamic client: %v", err) - } - - src := source.NewPaymentRouteSource(dynClient, v, *routeNamespace) - go func() { - if err := src.Run(ctx); err != nil { - log.Fatalf("paymentroute source: %v", err) - } - }() - log.Printf("route source: PaymentRoute CRs (namespace: %s)", *routeNamespace) - - case "configmap": - go x402verifier.WatchConfig(ctx, *configPath, v, 5*time.Second) - log.Printf("route source: ConfigMap file watcher (%s)", *configPath) + // Start PaymentRoute informer. + restCfg, err := rest.InClusterConfig() + if err != nil { + log.Fatalf("in-cluster config: %v", err) + } - default: - log.Fatalf("unknown route source: %s", *routeSource) + dynClient, err := dynamic.NewForConfig(restCfg) + if err != nil { + log.Fatalf("dynamic client: %v", err) } + src := source.NewPaymentRouteSource(dynClient, v, *routeNamespace) + go func() { + if err := src.Run(ctx); err != nil { + log.Fatalf("paymentroute source: %v", err) + } + }() + log.Printf("route source: PaymentRoute CRs (namespace: %s)", *routeNamespace) + // Graceful shutdown. sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) diff --git a/internal/embed/skills/sell/scripts/monetize.py b/internal/embed/skills/sell/scripts/monetize.py deleted file mode 100644 index fd4e545..0000000 --- a/internal/embed/skills/sell/scripts/monetize.py +++ /dev/null @@ -1,1927 +0,0 @@ -#!/usr/bin/env python3 -"""Manage ServiceOffer CRDs for x402 payment-gated compute monetization. - -Reconciles ServiceOffer custom resources through a staged pipeline: - ModelReady → UpstreamHealthy → PaymentGateReady → RoutePublished → Registered → Ready - -Schema alignment: - - payment.* fields align with x402 PaymentRequirements (V2): payTo, network, scheme - - registration.* fields align with ERC-8004 AgentRegistration: name, description, services - -Usage: - python3 monetize.py [args] - -Commands: - list List all ServiceOffers across namespaces - status --namespace Show conditions for one offer - create [flags] Create a new ServiceOffer CR - delete --namespace Delete an offer (cascades owned resources) - process --namespace Reconcile a single offer - process --all Reconcile all non-Ready offers -""" - -import argparse -import base64 -import json -import os -import re -import sys -import time -import urllib.request -import urllib.error -from decimal import Decimal, InvalidOperation - -# Import shared Kubernetes helpers from the obol-stack skill. -SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -KUBE_SCRIPTS = os.path.join(os.path.dirname(SKILL_DIR), "obol-stack", "scripts") -sys.path.insert(0, KUBE_SCRIPTS) -from kube import load_sa, make_ssl_context, api_get, api_post, api_patch, api_delete # noqa: E402 - -CRD_GROUP = "obol.org" -CRD_VERSION = "v1alpha1" -CRD_PLURAL = "serviceoffers" - -# --------------------------------------------------------------------------- -# Input validation — prevents YAML injection via f-string interpolation. -# All values are validated before being used in YAML string construction. -# --------------------------------------------------------------------------- - -_ROUTE_PATTERN_RE = re.compile(r"^/[a-zA-Z0-9_./*-]+$") -_PRICE_RE = re.compile(r"^\d+(\.\d+)?$") -_ADDRESS_RE = re.compile(r"^0x[a-fA-F0-9]{40}$") -_NETWORK_RE = re.compile(r"^[a-z0-9-]+$") -APPROX_TOKENS_PER_REQUEST = Decimal("1000") - - -def _validate_route_pattern(pattern): - """Validate route pattern is safe for YAML interpolation.""" - if not pattern or not _ROUTE_PATTERN_RE.match(pattern): - raise ValueError(f"invalid route pattern: {pattern!r}") - return pattern - - -def _validate_price(price): - """Validate price is a numeric string safe for YAML interpolation.""" - if not price or not _PRICE_RE.match(str(price)): - raise ValueError(f"invalid price: {price!r}") - return str(price) - - -def _validate_address(addr): - """Validate Ethereum address if non-empty.""" - if addr and not _ADDRESS_RE.match(addr): - raise ValueError(f"invalid Ethereum address: {addr!r}") - return addr - - -def _validate_network(network): - """Validate network name if non-empty.""" - if network and not _NETWORK_RE.match(network): - raise ValueError(f"invalid network name: {network!r}") - return network - - -# --------------------------------------------------------------------------- -# ERC-8004 constants -# --------------------------------------------------------------------------- - -IDENTITY_REGISTRY = "0x8004A818BFB912233c491871b3d84c89A494BD9e" -BASE_SEPOLIA_CHAIN_ID = 84532 - -# keccak256("register(string)")[:4] -REGISTER_SELECTOR = "f2c298be" - -# keccak256("setMetadata(uint256,string,bytes)")[:4] -SET_METADATA_SELECTOR = "ce1b815f" - -# keccak256("Registered(uint256,string,address)") -REGISTERED_TOPIC = "0xca52e62c367d81bb2e328eb795f7c7ba24afb478408a26c0e201d155c449bc4a" - -SIGNER_URL = os.environ.get("REMOTE_SIGNER_URL", "http://remote-signer:9000") -ERPC_URL = os.environ.get("ERPC_URL", "http://erpc.erpc.svc.cluster.local:4000/rpc") - -CONDITION_TYPES = [ - "ModelReady", - "UpstreamHealthy", - "PaymentGateReady", - "RoutePublished", - "Registered", - "Ready", -] - - -# --------------------------------------------------------------------------- -# Condition helpers -# --------------------------------------------------------------------------- - -def get_condition(conditions, cond_type): - """Return the condition dict for a given type, or None.""" - for c in conditions or []: - if c.get("type") == cond_type: - return c - return None - - -def is_condition_true(conditions, cond_type): - """Check if a condition is True.""" - c = get_condition(conditions, cond_type) - return c is not None and c.get("status") == "True" - - -def set_condition(ns, name, cond_type, status, reason, message, token, ssl_ctx): - """Patch a single condition on a ServiceOffer's status subresource.""" - path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{CRD_PLURAL}/{name}/status" - - # Read current status to preserve existing conditions. - obj = api_get( - f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{CRD_PLURAL}/{name}", - token, - ssl_ctx, - ) - conditions = obj.get("status", {}).get("conditions", []) - - now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - new_cond = { - "type": cond_type, - "status": status, - "reason": reason, - "message": message, - "lastTransitionTime": now, - } - - # Upsert the condition. - updated = False - for i, c in enumerate(conditions): - if c.get("type") == cond_type: - # Only update lastTransitionTime if status actually changed. - if c.get("status") != status: - conditions[i] = new_cond - else: - conditions[i]["reason"] = reason - conditions[i]["message"] = message - updated = True - break - if not updated: - conditions.append(new_cond) - - patch_body = {"status": {"conditions": conditions}} - api_patch(path, patch_body, token, ssl_ctx, patch_type="merge") - - -def set_endpoint(ns, name, endpoint, token, ssl_ctx): - """Set status.endpoint on a ServiceOffer.""" - path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{CRD_PLURAL}/{name}/status" - patch_body = {"status": {"endpoint": endpoint}} - api_patch(path, patch_body, token, ssl_ctx, patch_type="merge") - - -def set_status_field(ns, name, field, value, token, ssl_ctx): - """Set a status field on a ServiceOffer.""" - path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{CRD_PLURAL}/{name}/status" - patch_body = {"status": {field: value}} - api_patch(path, patch_body, token, ssl_ctx, patch_type="merge") - - -# --------------------------------------------------------------------------- -# Spec accessors (aligned with new schema) -# --------------------------------------------------------------------------- - -def get_payment(spec): - """Return the payment section (x402-aligned field names).""" - return spec.get("payment", {}) - - -def get_price_table(spec): - """Return the price table from the payment section.""" - return get_payment(spec).get("price", {}) - - -def get_effective_price(spec): - """Return the effective per-request price for x402 gating.""" - price = get_price_table(spec) - if price.get("perRequest"): - return price["perRequest"] - if price.get("perMTok"): - return _approximate_request_price(price["perMTok"]) - return price.get("perHour") or "0" - - -def _approximate_request_price(per_mtok): - """Approximate a per-request price from a per-MTok price.""" - try: - value = Decimal(str(per_mtok).strip()) - except InvalidOperation as exc: - raise ValueError(f"invalid perMTok price: {per_mtok!r}") from exc - return _decimal_to_string(value / APPROX_TOKENS_PER_REQUEST) - - -def _decimal_to_string(value): - """Format a Decimal without exponent notation or trailing zeros.""" - normalized = value.normalize() - text = format(normalized, "f") - if "." in text: - text = text.rstrip("0").rstrip(".") - return text or "0" - - -def describe_price(spec): - """Return a human-readable description of the active pricing model.""" - price = get_price_table(spec) - if price.get("perRequest"): - return f"{price['perRequest']} USDC/request" - if price.get("perMTok"): - return ( - f"{get_effective_price(spec)} USDC/request " - f"(approx from {price['perMTok']} USDC/MTok @ {int(APPROX_TOKENS_PER_REQUEST)} tok/request)" - ) - if price.get("perHour"): - return f"{price['perHour']} USDC/hour" - return "0 USDC/request" - - -def get_pay_to(spec): - """Return the payTo wallet address.""" - return get_payment(spec).get("payTo", "") - - -def get_network(spec): - """Return the payment network.""" - return get_payment(spec).get("network", "") - - -# --------------------------------------------------------------------------- -# ERC-8004 on-chain registration helpers -# --------------------------------------------------------------------------- - -def _rpc(method, params=None, network="base-sepolia"): - """JSON-RPC call to eRPC for Base Sepolia.""" - url = f"{ERPC_URL}/{network}" - payload = json.dumps({ - "jsonrpc": "2.0", - "method": method, - "params": params or [], - "id": 1, - }).encode() - req = urllib.request.Request( - url, data=payload, method="POST", - headers={"Content-Type": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=30) as resp: - result = json.loads(resp.read()) - if "error" in result: - raise RuntimeError(f"RPC error: {result['error']}") - return result.get("result") - - -def _remote_signer_get(path): - """GET request to the remote-signer.""" - url = f"{SIGNER_URL}{path}" - req = urllib.request.Request(url, method="GET") - with urllib.request.urlopen(req, timeout=10) as resp: - return json.loads(resp.read()) - - -def _remote_signer_post(path, data): - """POST JSON to the remote-signer.""" - url = f"{SIGNER_URL}{path}" - payload = json.dumps(data).encode() - req = urllib.request.Request( - url, data=payload, method="POST", - headers={"Content-Type": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=30) as resp: - return json.loads(resp.read()) - - -def _abi_encode_string(s): - """ABI-encode a single string parameter for a Solidity function call. - - Layout: - [32 bytes] offset to string data (0x20) - [32 bytes] string length - [N*32 bytes] UTF-8 string data, zero-padded to 32-byte boundary - """ - encoded = s.encode("utf-8") - offset = (32).to_bytes(32, "big") - length = len(encoded).to_bytes(32, "big") - padded_len = ((len(encoded) + 31) // 32) * 32 - data = encoded.ljust(padded_len, b'\x00') - return offset + length + data - - -def _get_signing_address(): - """Get the first signing address from the remote-signer.""" - data = _remote_signer_get("/api/v1/keys") - keys = data.get("keys", []) - if not keys: - raise RuntimeError("No signing keys available in remote-signer") - return keys[0] - - -def _register_on_chain(agent_uri): - """Register on ERC-8004 Identity Registry via remote-signer + eRPC. - - Calls register(string agentURI) on the Identity Registry contract. - Returns (agent_id: int, tx_hash: str). - """ - from_addr = _get_signing_address() - print(f" Signing address: {from_addr}") - - # Build calldata: selector + abi_encode_string(agent_uri) - calldata = bytes.fromhex(REGISTER_SELECTOR) + _abi_encode_string(agent_uri) - calldata_hex = "0x" + calldata.hex() - - # Get nonce. - nonce_hex = _rpc("eth_getTransactionCount", [from_addr, "pending"]) - nonce = int(nonce_hex, 16) - - # Get gas price. - base_fee_hex = _rpc("eth_gasPrice") - base_fee = int(base_fee_hex, 16) - try: - priority_hex = _rpc("eth_maxPriorityFeePerGas") - max_priority = int(priority_hex, 16) - except (RuntimeError, urllib.error.URLError): - max_priority = 1_000_000_000 # 1 gwei fallback - max_fee = base_fee * 2 + max_priority - - # Estimate gas. - tx_obj = {"from": from_addr, "to": IDENTITY_REGISTRY, "data": calldata_hex} - gas_hex = _rpc("eth_estimateGas", [tx_obj]) - gas_limit = int(int(gas_hex, 16) * 1.3) # 30% buffer for contract calls - - # Sign via remote-signer. - tx_req = { - "chain_id": BASE_SEPOLIA_CHAIN_ID, - "to": IDENTITY_REGISTRY, - "nonce": nonce, - "gas_limit": gas_limit, - "max_fee_per_gas": max_fee, - "max_priority_fee_per_gas": max_priority, - "value": "0x0", - "data": calldata_hex, - } - result = _remote_signer_post(f"/api/v1/sign/{from_addr}/transaction", tx_req) - signed_tx = result.get("signed_transaction", "") - if not signed_tx: - raise RuntimeError("Remote-signer returned empty signed transaction") - - # Broadcast. - print(f" Broadcasting registration tx to base-sepolia...") - tx_hash = _rpc("eth_sendRawTransaction", [signed_tx]) - print(f" Tx hash: {tx_hash}") - - # Wait for receipt. - for _ in range(60): - receipt = _rpc("eth_getTransactionReceipt", [tx_hash]) - if receipt is not None: - status = int(receipt.get("status", "0x0"), 16) - if status != 1: - raise RuntimeError(f"Registration tx reverted (tx: {tx_hash})") - # Parse Registered event to extract agentId. - agent_id = _parse_registered_event(receipt) - print(f" Agent ID: {agent_id}") - return agent_id, tx_hash - time.sleep(2) - - raise RuntimeError(f"Timeout waiting for receipt (tx: {tx_hash})") - - -def _parse_registered_event(receipt): - """Extract agentId from the Registered event in the transaction receipt. - - Event: Registered(uint256 indexed agentId, string agentURI, address indexed owner) - Topics: [event_sig, agentId_padded, owner_padded] - """ - for log in receipt.get("logs", []): - topics = log.get("topics", []) - if len(topics) >= 2 and topics[0] == REGISTERED_TOPIC: - return int(topics[1], 16) - - raise RuntimeError("Registered event not found in transaction receipt") - - -def _abi_encode_uint256(n): - """ABI-encode a uint256 as 32 bytes.""" - return n.to_bytes(32, byteorder="big") - - -def _abi_encode_bytes(data): - """ABI-encode a bytes value (offset + length + padded data).""" - length = len(data) - padded = data + b"\x00" * (32 - length % 32) if length % 32 != 0 else data - return length.to_bytes(32, byteorder="big") + padded - - -def _set_metadata_on_chain(agent_id, key, value_bytes): - """Call setMetadata(uint256, string, bytes) on the Identity Registry. - - Sets indexed on-chain metadata that buyers can filter via MetadataSet events. - Uses the same remote-signer + eRPC pattern as _register_on_chain. - """ - from_addr = _get_signing_address() - - # ABI-encode setMetadata(uint256 agentId, string metadataKey, bytes metadataValue) - # Layout: selector + agentId(32) + offset_key(32) + offset_value(32) + key_data + value_data - agent_id_enc = _abi_encode_uint256(agent_id) - key_enc = _abi_encode_string(key) - value_enc = _abi_encode_bytes(value_bytes) - - # Dynamic offsets: key starts at 3*32=96, value starts at 96+len(key_enc) - offset_key = (96).to_bytes(32, byteorder="big") - offset_value = (96 + len(key_enc)).to_bytes(32, byteorder="big") - - calldata = ( - bytes.fromhex(SET_METADATA_SELECTOR) - + agent_id_enc - + offset_key - + offset_value - + key_enc - + value_enc - ) - calldata_hex = "0x" + calldata.hex() - - nonce_hex = _rpc("eth_getTransactionCount", [from_addr, "pending"]) - nonce = int(nonce_hex, 16) - - base_fee = int(_rpc("eth_gasPrice"), 16) - try: - max_priority = int(_rpc("eth_maxPriorityFeePerGas"), 16) - except (RuntimeError, urllib.error.URLError): - max_priority = 1_000_000_000 - max_fee = base_fee * 2 + max_priority - - tx_obj = {"from": from_addr, "to": IDENTITY_REGISTRY, "data": calldata_hex} - gas_limit = int(int(_rpc("eth_estimateGas", [tx_obj]), 16) * 1.3) - - tx_req = { - "chain_id": BASE_SEPOLIA_CHAIN_ID, - "to": IDENTITY_REGISTRY, - "nonce": nonce, - "gas_limit": gas_limit, - "max_fee_per_gas": max_fee, - "max_priority_fee_per_gas": max_priority, - "value": "0x0", - "data": calldata_hex, - } - result = _remote_signer_post(f"/api/v1/sign/{from_addr}/transaction", tx_req) - signed_tx = result.get("signed_transaction", "") - if not signed_tx: - raise RuntimeError("Remote-signer returned empty signed transaction") - - tx_hash = _rpc("eth_sendRawTransaction", [signed_tx]) - - # Wait for receipt (short timeout — metadata is non-critical). - for _ in range(30): - receipt = _rpc("eth_getTransactionReceipt", [tx_hash]) - if receipt is not None: - status = int(receipt.get("status", "0x0"), 16) - if status != 1: - print(f" Warning: setMetadata tx reverted (tx: {tx_hash})") - return - return - time.sleep(2) - print(f" Warning: setMetadata receipt timeout (tx: {tx_hash})") - - -# --------------------------------------------------------------------------- -# Reconciliation stages -# --------------------------------------------------------------------------- - -def _ollama_base_url(spec, ns): - """Return the Ollama HTTP base URL from upstream spec.""" - upstream = spec.get("upstream", {}) - svc = upstream.get("service", "ollama") - svc_ns = upstream.get("namespace", ns) - port = upstream.get("port", 11434) - return f"http://{svc}.{svc_ns}.svc.cluster.local:{port}" - - -def _ollama_model_exists(base_url, model_name): - """Check if a model is already available in Ollama via /api/tags.""" - try: - req = urllib.request.Request(f"{base_url}/api/tags") - with urllib.request.urlopen(req, timeout=10) as resp: - data = json.loads(resp.read()) - for m in data.get("models", []): - if m.get("name", "") == model_name: - return True - except (urllib.error.URLError, urllib.error.HTTPError, OSError): - pass - return False - - -def stage_model_ready(spec, ns, name, token, ssl_ctx): - """Check model availability via Ollama API. Pull only if not cached.""" - model_spec = spec.get("model") - if not model_spec: - set_condition(ns, name, "ModelReady", "True", "NoModel", "No model specified, skipping pull", token, ssl_ctx) - return True - - runtime = model_spec.get("runtime", "ollama") - model_name = model_spec.get("name", "") - - if runtime != "ollama": - set_condition(ns, name, "ModelReady", "True", "UnsupportedRuntime", f"Runtime {runtime} does not require pull", token, ssl_ctx) - return True - - base_url = _ollama_base_url(spec, ns) - - # Fast path: check if model is already available (avoids slow /api/pull). - print(f" Checking if model {model_name} is available...") - if _ollama_model_exists(base_url, model_name): - print(f" Model {model_name} already available") - set_condition(ns, name, "ModelReady", "True", "Available", f"Model {model_name} already available", token, ssl_ctx) - return True - - # Model not found — trigger a pull. - pull_url = f"{base_url}/api/pull" - print(f" Pulling model {model_name} via {pull_url}...") - body = json.dumps({"name": model_name, "stream": False}).encode() - req = urllib.request.Request( - pull_url, - data=body, - method="POST", - headers={"Content-Type": "application/json"}, - ) - try: - with urllib.request.urlopen(req, timeout=600) as resp: - result = json.loads(resp.read()) - status_text = result.get("status", "success") - print(f" Model pull complete: {status_text}") - except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e: - msg = str(e)[:200] - print(f" Model pull failed: {msg}", file=sys.stderr) - set_condition(ns, name, "ModelReady", "False", "PullFailed", msg, token, ssl_ctx) - return False - - set_condition(ns, name, "ModelReady", "True", "Pulled", f"Model {model_name} pulled successfully", token, ssl_ctx) - return True - - -def stage_upstream_healthy(spec, ns, name, token, ssl_ctx): - """Health-check the upstream service.""" - upstream = spec.get("upstream", {}) - svc = upstream.get("service", "ollama") - svc_ns = upstream.get("namespace", ns) - port = upstream.get("port", 11434) - health_path = upstream.get("healthPath", "/") - - model_spec = spec.get("model", {}) - model_name = model_spec.get("name", "") - - health_url = f"http://{svc}.{svc_ns}.svc.cluster.local:{port}{health_path}" - print(f" Health-checking {health_url}...") - - if health_path == "/api/generate" and model_name: - body = json.dumps({"model": model_name, "prompt": "ping", "stream": False}).encode() - req = urllib.request.Request( - health_url, - data=body, - method="POST", - headers={"Content-Type": "application/json"}, - ) - else: - req = urllib.request.Request(health_url) - - # Retry transient connection failures (pod starting, DNS propagation). - max_attempts = 3 - backoff = 2 # seconds - last_err = None - for attempt in range(1, max_attempts + 1): - try: - with urllib.request.urlopen(req, timeout=30) as resp: - resp.read() - print(f" Upstream healthy (HTTP {resp.status})") - last_err = None - break - except urllib.error.HTTPError as e: - # An HTTP error (400, 405, etc.) still proves the upstream is reachable - # and accepting connections — only connection failures mean "unhealthy". - print(f" Upstream reachable (HTTP {e.code} — acceptable for health check)") - last_err = None - break - except (urllib.error.URLError, OSError) as e: - last_err = str(e)[:200] - if attempt < max_attempts: - print(f" Health-check attempt {attempt}/{max_attempts} failed: {last_err}, retrying in {backoff}s...") - time.sleep(backoff) - else: - print(f" Health-check failed after {max_attempts} attempts: {last_err}", file=sys.stderr) - - if last_err: - set_condition(ns, name, "UpstreamHealthy", "False", "Unhealthy", last_err, token, ssl_ctx) - return False - - set_condition(ns, name, "UpstreamHealthy", "True", "Healthy", "Upstream responded successfully", token, ssl_ctx) - return True - - -def stage_payment_gate(spec, ns, name, token, ssl_ctx): - """Create a Traefik ForwardAuth Middleware and add x402 pricing route.""" - middleware_name = f"x402-{name}" - - # Build the Middleware resource. - middleware = { - "apiVersion": "traefik.io/v1alpha1", - "kind": "Middleware", - "metadata": { - "name": middleware_name, - "namespace": ns, - "ownerReferences": [ - { - "apiVersion": f"{CRD_GROUP}/{CRD_VERSION}", - "kind": "ServiceOffer", - "name": name, - "uid": "", # Filled below. - "blockOwnerDeletion": True, - "controller": True, - } - ], - }, - "spec": { - "forwardAuth": { - "address": "http://x402-verifier.x402.svc.cluster.local:8080/verify", - "authResponseHeaders": ["X-Payment-Status", "X-Payment-Tx", "Authorization"], - }, - }, - } - - # Get the ServiceOffer UID for the OwnerReference. - so = api_get( - f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{CRD_PLURAL}/{name}", - token, - ssl_ctx, - ) - uid = so.get("metadata", {}).get("uid", "") - middleware["metadata"]["ownerReferences"][0]["uid"] = uid - - mw_path = f"/apis/traefik.io/v1alpha1/namespaces/{ns}/middlewares" - - # Check if middleware already exists. - try: - existing = api_get(f"{mw_path}/{middleware_name}", token, ssl_ctx, quiet=True) - if existing: - print(f" Middleware {middleware_name} already exists, updating...") - api_patch(f"{mw_path}/{middleware_name}", middleware, token, ssl_ctx, patch_type="merge") - except SystemExit: - # api_get calls sys.exit on 404 — create instead. - print(f" Creating Middleware {middleware_name}...") - api_post(mw_path, middleware, token, ssl_ctx) - - # Add pricing route to x402-verifier ConfigMap so requests are actually gated. - # Without this, the verifier passes through all requests (200 for unmatched routes). - _add_pricing_route(spec, ns, name, token, ssl_ctx) - - set_condition(ns, name, "PaymentGateReady", "True", "Created", f"Middleware {middleware_name} created with pricing route", token, ssl_ctx) - return True - - -def _read_upstream_auth(spec, token, ssl_ctx): - """Read the LiteLLM master key from the cluster and return a Bearer token. - - Returns "Bearer " or empty string if the secret is not available. - """ - upstream_ns = spec.get("upstream", {}).get("namespace", "llm") - secret_path = f"/api/v1/namespaces/{upstream_ns}/secrets/litellm-secrets" - try: - secret = api_get(secret_path, token, ssl_ctx, quiet=True) - encoded = secret.get("data", {}).get("LITELLM_MASTER_KEY", "") - if encoded: - key = base64.b64decode(encoded).decode("utf-8").strip() - if key: - return f"Bearer {key}" - except (SystemExit, Exception) as e: - print(f" Note: could not read LiteLLM master key: {e}") - return "" - - -def _add_pricing_route(spec, ns, name, token, ssl_ctx): - """Add a pricing route to the x402-verifier ConfigMap for this offer. - - Uses simple string manipulation for YAML to avoid a PyYAML dependency. - The pricing.yaml format is simple enough (flat keys + routes array) to - handle without a full parser. - - Now includes per-route payTo and network fields aligned with x402. - """ - url_path = spec.get("path", f"/services/{name}") - price = _validate_price(get_effective_price(spec)) - price_table = get_price_table(spec) - pay_to = _validate_address(get_pay_to(spec)) - network = _validate_network(get_network(spec)) - offer_ns = ns - - route_pattern = _validate_route_pattern(f"{url_path}/*") - - # Read current x402-pricing ConfigMap. - cm_path = "/api/v1/namespaces/x402/configmaps/x402-pricing" - try: - cm = api_get(cm_path, token, ssl_ctx, quiet=True) - except SystemExit: - print(f" Warning: x402-pricing ConfigMap not found, skipping pricing route") - return - - pricing_yaml_str = cm.get("data", {}).get("pricing.yaml", "") - if not pricing_yaml_str: - print(f" Warning: x402-pricing ConfigMap has no pricing.yaml key") - return - - # Check if route already exists. - if route_pattern in pricing_yaml_str: - print(f" Pricing route {route_pattern} already exists") - return - - # Read upstream auth token so the x402-verifier can inject Authorization. - upstream_auth = _read_upstream_auth(spec, token, ssl_ctx) - - # Detect indentation of existing routes. - indent = "" - for line in pricing_yaml_str.splitlines(): - stripped = line.lstrip() - if stripped.startswith("- pattern:"): - indent = line[: len(line) - len(stripped)] - break - - # Build the new route entry in YAML format. - route_entry = ( - f'{indent}- pattern: "{route_pattern}"\n' - f'{indent} price: "{price}"\n' - f'{indent} description: "ServiceOffer {name}"\n' - ) - if pay_to: - route_entry += f'{indent} payTo: "{pay_to}"\n' - if network: - route_entry += f'{indent} network: "{network}"\n' - if upstream_auth: - route_entry += f'{indent} upstreamAuth: "{upstream_auth}"\n' - if price_table.get("perMTok"): - route_entry += f'{indent} priceModel: "perMTok"\n' - route_entry += f'{indent} perMTok: "{price_table["perMTok"]}"\n' - route_entry += ( - f"{indent} approxTokensPerRequest: {int(APPROX_TOKENS_PER_REQUEST)}\n" - ) - elif price_table.get("perRequest"): - route_entry += f'{indent} priceModel: "perRequest"\n' - elif price_table.get("perHour"): - route_entry += f'{indent} priceModel: "perHour"\n' - if offer_ns: - route_entry += f'{indent} offerNamespace: "{offer_ns}"\n' - route_entry += f'{indent} offerName: "{name}"\n' - - # Append route to existing routes section or create it. - if "routes:" in pricing_yaml_str: - # Check if routes is currently empty (routes: []). - if "routes: []" in pricing_yaml_str: - pricing_yaml_str = pricing_yaml_str.replace( - "routes: []", - f"routes:\n{route_entry}", - ) - else: - # Append after last route entry (before any trailing newlines). - pricing_yaml_str = pricing_yaml_str.rstrip() + "\n" + route_entry - else: - pricing_yaml_str = pricing_yaml_str.rstrip() + f"\nroutes:\n{route_entry}" - - patch_body = {"data": {"pricing.yaml": pricing_yaml_str}} - api_patch(cm_path, patch_body, token, ssl_ctx, patch_type="merge") - print(f" Added pricing route: {route_pattern} → {describe_price(spec)} (payTo={pay_to or 'global'})") - - -def stage_route_published(spec, ns, name, token, ssl_ctx): - """Create a Gateway API HTTPRoute with ForwardAuth middleware.""" - route_name = f"so-{name}" - middleware_name = f"x402-{name}" - - upstream = spec.get("upstream", {}) - svc = upstream.get("service", "ollama") - svc_ns = upstream.get("namespace", ns) - port = upstream.get("port", 11434) - url_path = spec.get("path", f"/services/{name}") - - # Build the HTTPRoute resource. - # Use ExtensionRef filter (not traefik.io/middleware annotation) because - # Traefik's Gateway API provider ignores annotations — only ExtensionRef - # works for attaching middleware to HTTPRoutes. - httproute = { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": { - "name": route_name, - "namespace": ns, - "ownerReferences": [ - { - "apiVersion": f"{CRD_GROUP}/{CRD_VERSION}", - "kind": "ServiceOffer", - "name": name, - "uid": "", # Filled below. - "blockOwnerDeletion": True, - "controller": True, - } - ], - }, - "spec": { - "parentRefs": [ - { - "name": "traefik-gateway", - "namespace": "traefik", - "sectionName": "web", - } - ], - "rules": [ - { - "matches": [ - { - "path": { - "type": "PathPrefix", - "value": url_path, - } - } - ], - "filters": [ - { - "type": "ExtensionRef", - "extensionRef": { - "group": "traefik.io", - "kind": "Middleware", - "name": middleware_name, - }, - }, - { - "type": "URLRewrite", - "urlRewrite": { - "path": { - "type": "ReplacePrefixMatch", - "replacePrefixMatch": "/", - }, - }, - }, - ], - "backendRefs": [ - { - "name": svc, - "namespace": svc_ns, - "port": port, - } - ], - } - ], - }, - } - - # Get UID for OwnerReference. - so = api_get( - f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{CRD_PLURAL}/{name}", - token, - ssl_ctx, - ) - uid = so.get("metadata", {}).get("uid", "") - httproute["metadata"]["ownerReferences"][0]["uid"] = uid - - route_path = f"/apis/gateway.networking.k8s.io/v1/namespaces/{ns}/httproutes" - - # Check if route already exists. - try: - existing = api_get(f"{route_path}/{route_name}", token, ssl_ctx, quiet=True) - if existing: - print(f" HTTPRoute {route_name} already exists, updating...") - api_patch(f"{route_path}/{route_name}", httproute, token, ssl_ctx, patch_type="merge") - except SystemExit: - print(f" Creating HTTPRoute {route_name}...") - api_post(route_path, httproute, token, ssl_ctx) - - endpoint = url_path - set_endpoint(ns, name, endpoint, token, ssl_ctx) - set_condition(ns, name, "RoutePublished", "True", "Created", f"HTTPRoute {route_name} published at {url_path}", token, ssl_ctx) - return True - - -def stage_registered(spec, ns, name, token, ssl_ctx): - """Register on ERC-8004 Identity Registry if registration.enabled is true. - - Uses the agent's remote-signer wallet to mint an agent NFT on Base Sepolia. - The wallet must be funded with ETH for gas on Base Sepolia (chain 84532). - - Flow: - 1. Check if already registered (status.agentId set) → skip - 2. Get signing address from remote-signer - 3. Build agentURI from AGENT_BASE_URL + spec.path - 4. ABI-encode register(agentURI) → calldata - 5. Sign + broadcast via remote-signer + eRPC/base-sepolia - 6. Parse receipt → extract agentId - 7. Patch CRD status: agentId, registrationTxHash - 8. Set Registered condition to True - """ - registration = spec.get("registration", {}) - if not registration.get("enabled", False): - set_condition(ns, name, "Registered", "True", "Skipped", "Registration not requested", token, ssl_ctx) - return True - - # Check if already registered. - so_path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{CRD_PLURAL}/{name}" - obj = api_get(so_path, token, ssl_ctx) - existing_agent_id = obj.get("status", {}).get("agentId", "") - if existing_agent_id: - print(f" Already registered as agent {existing_agent_id}") - set_condition(ns, name, "Registered", "True", "AlreadyRegistered", - f"Agent {existing_agent_id} on base-sepolia", token, ssl_ctx) - return True - - # Build the agentURI. - base_url = os.environ.get("AGENT_BASE_URL", "http://obol.stack:8080") - url_path = spec.get("path", f"/services/{name}") - agent_uri = f"{base_url}/.well-known/agent-registration.json" - - # Publish the registration JSON immediately so `.well-known` is available - # for discovery even before the on-chain NFT mint completes (or if it fails). - _publish_registration_json(spec, ns, name, "", "", token, ssl_ctx) - - print(f" Registering on ERC-8004 (Base Sepolia)...") - print(f" Registry: {IDENTITY_REGISTRY}") - print(f" Agent URI: {agent_uri}") - - try: - agent_id, tx_hash = _register_on_chain(agent_uri) - except urllib.error.URLError as e: - reason = str(e.reason) if hasattr(e, 'reason') else str(e) - if "remote-signer" in reason.lower() or "Connection refused" in reason: - msg = f"Off-chain only (remote-signer unavailable): {reason[:80]}" - else: - msg = f"Off-chain only (RPC error): {reason[:80]}" - print(f" {msg}", file=sys.stderr) - set_condition(ns, name, "Registered", "True", "OffChainOnly", msg, token, ssl_ctx) - return True - except RuntimeError as e: - msg = str(e)[:200] - if "insufficient funds" in msg.lower() or "gas" in msg.lower(): - reason = f"Off-chain only (wallet not funded): {msg[:80]}" - elif "reverted" in msg.lower(): - reason = f"Off-chain only (tx reverted): {msg[:80]}" - else: - reason = f"Off-chain only: {msg[:80]}" - print(f" {reason}", file=sys.stderr) - set_condition(ns, name, "Registered", "True", "OffChainOnly", reason, token, ssl_ctx) - return True - except Exception as e: - msg = f"Off-chain only (unexpected): {str(e)[:120]}" - print(f" {msg}", file=sys.stderr) - set_condition(ns, name, "Registered", "True", "OffChainOnly", msg, token, ssl_ctx) - return True - - # Patch CRD status with on-chain identity. - set_status_field(ns, name, "agentId", str(agent_id), token, ssl_ctx) - set_status_field(ns, name, "registrationTxHash", tx_hash, token, ssl_ctx) - set_condition(ns, name, "Registered", "True", "Registered", - f"Agent {agent_id} on base-sepolia (tx: {tx_hash[:18]}...)", token, ssl_ctx) - print(f" Registered as agent {agent_id} (tx: {tx_hash})") - - # Set on-chain metadata for indexed discovery (MetadataSet events). - # Buyers can filter agents by these keys without fetching every registration JSON. - offer_type = spec.get("type", "http") - try: - print(f" Setting on-chain metadata: x402.supported=true, service.type={offer_type}") - _set_metadata_on_chain(agent_id, "x402.supported", b"\x01") - _set_metadata_on_chain(agent_id, "service.type", offer_type.encode("utf-8")) - except Exception as e: - print(f" Warning: on-chain metadata failed (non-blocking): {e}") - - # Publish the ERC-8004 registration JSON (agent-managed resources). - _publish_registration_json(spec, ns, name, agent_id, tx_hash, token, ssl_ctx) - return True - - -def _publish_registration_json(spec, ns, name, agent_id, tx_hash, token, ssl_ctx): - """Publish the ERC-8004 AgentRegistration document. - - Creates four agent-managed resources (all with ownerReferences for GC): - 1. ConfigMap so--registration — the JSON document - 2. Deployment so--registration — busybox httpd serving the ConfigMap - 3. Service so--registration — ClusterIP targeting the deployment - 4. HTTPRoute so--wellknown — routes /.well-known/agent-registration.json - - On ServiceOffer deletion, K8s garbage collection tears everything down. - - NOTE: ERC-8004 allows multiple services in a single registration.json. - Currently each ServiceOffer creates its own registration document. When - multiple offers share one agent identity, this should evolve to aggregate - all offers' services into a single /.well-known/agent-registration.json. - """ - registration = spec.get("registration", {}) - base_url = os.environ.get("AGENT_BASE_URL", "http://obol.stack:8080") - url_path = spec.get("path", f"/services/{name}") - - # ── 1. Build the registration JSON ───────────────────────────────────── - # ERC-8004 REQUIRED fields: type, name, description, image, services, - # x402Support, active, registrations. All are always emitted. - # Build a richer description from spec fields. - offer_type = spec.get("type", "http") - price_info = get_effective_price(spec) - model_info = spec.get("model", {}) - default_desc = f"x402 payment-gated {offer_type} service: {name}" - if model_info.get("name"): - default_desc = f"{model_info['name']} inference via x402 micropayments ({price_info} USDC/request)" - - # OASF skills and domains for machine-readable capability discovery. - # Defaults based on service type; overridden by spec.registration.skills/domains. - default_skills = { - "inference": ["natural_language_processing/text_generation/chat_completion"], - } - default_domains = { - "inference": ["technology/artificial_intelligence"], - } - skills = registration.get("skills", default_skills.get(offer_type, [])) - domains = registration.get("domains", default_domains.get(offer_type, [])) - - doc = { - "type": "https://eips.ethereum.org/EIPS/eip-8004#registration-v1", - "name": registration.get("name", name), - "description": registration.get("description", default_desc), - "image": registration.get("image", f"{base_url}/agent-icon.png"), - "x402Support": True, - "active": True, - "services": [ - { - "name": "web", - "endpoint": f"{base_url}{url_path}", - }, - ], - "registrations": [ - { - "agentId": int(agent_id) if agent_id else 0, - "agentRegistry": f"eip155:{BASE_SEPOLIA_CHAIN_ID}:{IDENTITY_REGISTRY}", - } - ] if agent_id else [], - "supportedTrust": registration.get("supportedTrust", []), - } - - # Add OASF service entry for structured capability discovery. - if skills or domains: - oasf_entry = {"name": "OASF", "version": "0.8"} - if skills: - oasf_entry["skills"] = skills - if domains: - oasf_entry["domains"] = domains - doc["services"].append(oasf_entry) - - if registration.get("services"): - for svc in registration["services"]: - if svc.get("endpoint"): - doc["services"].append(svc) - - doc_json = json.dumps(doc, indent=2) - - # ── Get ServiceOffer UID for ownerReferences ─────────────────────────── - so_path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{CRD_PLURAL}/{name}" - so = api_get(so_path, token, ssl_ctx) - uid = so.get("metadata", {}).get("uid", "") - owner_ref = { - "apiVersion": f"{CRD_GROUP}/{CRD_VERSION}", - "kind": "ServiceOffer", - "name": name, - "uid": uid, - "blockOwnerDeletion": True, - "controller": True, - } - - cm_name = f"so-{name}-registration" - deploy_name = f"so-{name}-registration" - svc_name = f"so-{name}-registration" - route_name = f"so-{name}-wellknown" - labels = {"app": deploy_name, "obol.org/serviceoffer": name} - - # ── 2. ConfigMap ─────────────────────────────────────────────────────── - configmap = { - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { - "name": cm_name, - "namespace": ns, - "ownerReferences": [owner_ref], - }, - "data": { - "agent-registration.json": doc_json, - }, - } - _apply_resource(f"/api/v1/namespaces/{ns}/configmaps", cm_name, configmap, token, ssl_ctx) - - # ── 3. Deployment (busybox httpd) ────────────────────────────────────── - deployment = { - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": { - "name": deploy_name, - "namespace": ns, - "ownerReferences": [owner_ref], - "labels": labels, - }, - "spec": { - "replicas": 1, - "selector": {"matchLabels": labels}, - "template": { - "metadata": {"labels": labels}, - "spec": { - "containers": [ - { - "name": "httpd", - "image": "busybox:1.36", - "command": ["httpd", "-f", "-p", "8080", "-h", "/www"], - "ports": [{"containerPort": 8080}], - "volumeMounts": [ - { - "name": "registration", - "mountPath": "/www/.well-known", - "readOnly": True, - } - ], - "resources": { - "requests": {"cpu": "5m", "memory": "8Mi"}, - "limits": {"cpu": "50m", "memory": "32Mi"}, - }, - } - ], - "volumes": [ - { - "name": "registration", - "configMap": { - "name": cm_name, - "items": [ - { - "key": "agent-registration.json", - "path": "agent-registration.json", - } - ], - }, - } - ], - }, - }, - }, - } - _apply_resource(f"/apis/apps/v1/namespaces/{ns}/deployments", deploy_name, deployment, token, ssl_ctx) - - # ── 4. Service ───────────────────────────────────────────────────────── - service = { - "apiVersion": "v1", - "kind": "Service", - "metadata": { - "name": svc_name, - "namespace": ns, - "ownerReferences": [owner_ref], - "labels": labels, - }, - "spec": { - "type": "ClusterIP", - "selector": labels, - "ports": [ - {"port": 8080, "targetPort": 8080, "protocol": "TCP"}, - ], - }, - } - _apply_resource(f"/api/v1/namespaces/{ns}/services", svc_name, service, token, ssl_ctx) - - # ── 5. HTTPRoute (no ForwardAuth — registration is public) ───────────── - httproute = { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": { - "name": route_name, - "namespace": ns, - "ownerReferences": [owner_ref], - }, - "spec": { - "parentRefs": [ - { - "name": "traefik-gateway", - "namespace": "traefik", - "sectionName": "web", - } - ], - "rules": [ - { - "matches": [ - { - "path": { - "type": "Exact", - "value": "/.well-known/agent-registration.json", - } - } - ], - "backendRefs": [ - { - "name": svc_name, - "namespace": ns, - "port": 8080, - } - ], - } - ], - }, - } - _apply_resource( - f"/apis/gateway.networking.k8s.io/v1/namespaces/{ns}/httproutes", - route_name, httproute, token, ssl_ctx, - ) - - print(f" Published registration at /.well-known/agent-registration.json") - - -# --------------------------------------------------------------------------- -# /skill.md — aggregate agent-optimized service catalog -# --------------------------------------------------------------------------- - -def _build_skill_md(items, base_url): - """Build /skill.md content from all Ready ServiceOffer items.""" - ready = [] - for item in items: - conditions = item.get("status", {}).get("conditions", []) - if is_condition_true(conditions, "Ready"): - ready.append(item) - - # Resolve agent name from the first offer's registration, or fallback. - agent_name = "Obol Stack" - if ready: - reg = ready[0].get("spec", {}).get("registration", {}) - if reg.get("name"): - agent_name = reg["name"] - - lines = [ - f"# {agent_name} — x402 Service Catalog\n", - "", - "> This document lists all payment-gated services on this node.", - "> Payment uses the [x402 protocol](https://www.x402.org/) with USDC stablecoin.", - "> For machine-readable agent identity, see [/.well-known/agent-registration.json](/.well-known/agent-registration.json).", - "", - ] - - if not ready: - lines.append("**No services currently available.**\n") - return "\n".join(lines) - - # ── Summary table ───────────────────────────────────────────────────── - lines.append("## Services\n") - lines.append("| Service | Type | Model | Price | Endpoint |") - lines.append("|---------|------|-------|-------|----------|") - for item in ready: - spec = item.get("spec", {}) - name = item["metadata"]["name"] - offer_type = spec.get("type", "http") - model_name = spec.get("model", {}).get("name", "—") - path = spec.get("path", f"/services/{name}") - price_desc = describe_price(spec) - lines.append(f"| [{name}](#{name}) | {offer_type} | {model_name} | {price_desc} | `{base_url}{path}` |") - lines.append("") - - # ── How to pay ──────────────────────────────────────────────────────── - lines.append("## How to Pay (x402 Protocol)\n") - lines.append("1. **Send a normal HTTP request** to the service endpoint") - lines.append("2. **Receive HTTP 402** with `X-Payment` response header containing JSON pricing:") - lines.append(" ```json") - lines.append(' {"x402Version":1,"schemes":[{"scheme":"exact","network":"...","maxAmountRequired":"...","payTo":"0x...","extra":{"name":"USDC","version":"2"}}]}') - lines.append(" ```") - lines.append("3. **Sign an ERC-3009 `transferWithAuthorization`** for USDC on the specified network:") - lines.append(" - `from`: your wallet address") - lines.append(" - `to`: the `payTo` address from the 402 response") - lines.append(" - `value`: the `maxAmountRequired` (in smallest units, 6 decimals)") - lines.append(" - `validAfter`: 0") - lines.append(" - `validBefore`: current timestamp + timeout") - lines.append(" - `nonce`: random 32 bytes") - lines.append("4. **Retry the original request** with `X-Payment` header containing your signed authorization") - lines.append("5. **Receive 200** with the actual service response") - lines.append("") - lines.append("### Quick Example (curl)\n") - lines.append("```bash") - lines.append("# Step 1: Probe for pricing") - first_spec = ready[0].get("spec", {}) - first_path = first_spec.get("path", f"/services/{ready[0]['metadata']['name']}") - lines.append(f'curl -s -o /dev/null -w "%{{http_code}}" {base_url}{first_path}/v1/chat/completions') - lines.append("# Returns: 402") - lines.append("") - lines.append("# Step 2: Get pricing details") - lines.append(f'curl -sI {base_url}{first_path}/v1/chat/completions | grep X-Payment') - lines.append("```") - lines.append("") - lines.append("For programmatic payment, use [x402-go](https://github.com/coinbase/x402/tree/main/go), [x402-js](https://github.com/coinbase/x402/tree/main/typescript), or sign ERC-3009 directly with ethers/viem/web3.py.") - lines.append("") - - # ── Per-service details ─────────────────────────────────────────────── - lines.append("## Service Details\n") - for item in ready: - spec = item.get("spec", {}) - name = item["metadata"]["name"] - offer_type = spec.get("type", "http") - model_name = spec.get("model", {}).get("name") - path = spec.get("path", f"/services/{name}") - registration = spec.get("registration", {}) - default_desc = f"x402 payment-gated {offer_type} service" - if model_name: - default_desc = f"{model_name} inference via x402 micropayments" - - lines.append(f"### {name}\n") - lines.append(f"- **Endpoint**: `{base_url}{path}`") - lines.append(f"- **Type**: {offer_type}") - if model_name: - lines.append(f"- **Model**: {model_name}") - lines.append(f"- **Price**: {describe_price(spec)}") - lines.append(f"- **Pay To**: `{get_pay_to(spec)}`") - lines.append(f"- **Network**: {get_network(spec)}") - lines.append(f"- **Description**: {registration.get('description', default_desc)}") - if offer_type == "inference" and model_name: - lines.append(f"\n**OpenAI-compatible endpoint**: `POST {base_url}{path}/v1/chat/completions`") - lines.append("```json") - lines.append('{') - lines.append(f' "model": "{model_name}",') - lines.append(' "messages": [{"role": "user", "content": "Hello"}]') - lines.append('}') - lines.append("```") - lines.append("") - - # ── Reference ───────────────────────────────────────────────────────── - lines.append("## USDC Contract Addresses\n") - lines.append("| Network | Address |") - lines.append("|---------|---------|") - lines.append("| Base Sepolia | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` |") - lines.append("| Base Mainnet | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` |") - lines.append("") - lines.append("## Links\n") - lines.append(f"- [Agent Registration](/.well-known/agent-registration.json)") - lines.append("- [x402 Protocol](https://www.x402.org/)") - lines.append("- [ERC-3009 (transferWithAuthorization)](https://eips.ethereum.org/EIPS/eip-3009)") - lines.append("- [ERC-8004 (Agent Identity)](https://eips.ethereum.org/EIPS/eip-8004)") - lines.append("") - - return "\n".join(lines) - - -def _publish_skill_md(items, token, ssl_ctx): - """Publish the /skill.md aggregate endpoint. - - Creates four resources (no ownerReferences — aggregate, not per-offer): - 1. ConfigMap obol-skill-md — markdown content + httpd.conf - 2. Deployment obol-skill-md — busybox httpd serving the ConfigMap - 3. Service obol-skill-md — ClusterIP targeting the deployment - 4. HTTPRoute obol-skill-md-route — routes /skill.md to the Service - """ - import hashlib - - base_url = os.environ.get("AGENT_BASE_URL", "http://obol.stack:8080") - _, agent_ns = load_sa() - content = _build_skill_md(items, base_url) - content_hash = hashlib.md5(content.encode()).hexdigest()[:8] - - cm_name = "obol-skill-md" - deploy_name = "obol-skill-md" - svc_name = "obol-skill-md" - route_name = "obol-skill-md-route" - labels = {"app": deploy_name, "obol.org/managed-by": "monetize"} - - # ── 1. ConfigMap ────────────────────────────────────────────────────── - configmap = { - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": {"name": cm_name, "namespace": agent_ns, "labels": labels}, - "data": { - "skill.md": content, - "httpd.conf": ".md:text/markdown\n", - }, - } - _apply_resource(f"/api/v1/namespaces/{agent_ns}/configmaps", cm_name, configmap, token, ssl_ctx) - - # ── 2. Deployment (busybox httpd) ───────────────────────────────────── - deployment = { - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": {"name": deploy_name, "namespace": agent_ns, "labels": labels}, - "spec": { - "replicas": 1, - "selector": {"matchLabels": labels}, - "template": { - "metadata": { - "labels": labels, - "annotations": {"obol.org/content-hash": content_hash}, - }, - "spec": { - "containers": [ - { - "name": "httpd", - "image": "busybox:1.36", - "command": ["httpd", "-f", "-p", "8080", "-h", "/www"], - "ports": [{"containerPort": 8080}], - "volumeMounts": [ - {"name": "content", "mountPath": "/www", "readOnly": True}, - {"name": "httpdconf", "mountPath": "/etc/httpd.conf", "subPath": "httpd.conf", "readOnly": True}, - ], - "resources": { - "requests": {"cpu": "5m", "memory": "8Mi"}, - "limits": {"cpu": "50m", "memory": "32Mi"}, - }, - } - ], - "volumes": [ - { - "name": "content", - "configMap": { - "name": cm_name, - "items": [{"key": "skill.md", "path": "skill.md"}], - }, - }, - { - "name": "httpdconf", - "configMap": { - "name": cm_name, - "items": [{"key": "httpd.conf", "path": "httpd.conf"}], - }, - }, - ], - }, - }, - }, - } - _apply_resource(f"/apis/apps/v1/namespaces/{agent_ns}/deployments", deploy_name, deployment, token, ssl_ctx) - - # ── 3. Service ──────────────────────────────────────────────────────── - service = { - "apiVersion": "v1", - "kind": "Service", - "metadata": {"name": svc_name, "namespace": agent_ns, "labels": labels}, - "spec": { - "type": "ClusterIP", - "selector": labels, - "ports": [{"port": 8080, "targetPort": 8080, "protocol": "TCP"}], - }, - } - _apply_resource(f"/api/v1/namespaces/{agent_ns}/services", svc_name, service, token, ssl_ctx) - - # ── 4. HTTPRoute (public, no ForwardAuth) ───────────────────────────── - httproute = { - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": {"name": route_name, "namespace": agent_ns}, - "spec": { - "parentRefs": [ - {"name": "traefik-gateway", "namespace": "traefik", "sectionName": "web"} - ], - "rules": [ - { - "matches": [{"path": {"type": "Exact", "value": "/skill.md"}}], - "backendRefs": [{"name": svc_name, "namespace": agent_ns, "port": 8080}], - } - ], - }, - } - _apply_resource( - f"/apis/gateway.networking.k8s.io/v1/namespaces/{agent_ns}/httproutes", - route_name, httproute, token, ssl_ctx, - ) - - ready_count = sum(1 for i in items if is_condition_true(i.get("status", {}).get("conditions", []), "Ready")) - print(f" Published /skill.md ({ready_count} service(s))") - - -def _apply_resource(collection_path, name, resource, token, ssl_ctx): - """Create-or-update a Kubernetes resource (idempotent). - - Uses a direct HTTP GET to distinguish 404 (create) from other errors - (permission denied, server error) rather than catching SystemExit from - api_get which treats all failures as 404. - """ - api_server = os.environ.get("KUBERNETES_SERVICE_HOST", "kubernetes.default.svc") - api_port = os.environ.get("KUBERNETES_SERVICE_PORT", "443") - url = f"https://{api_server}:{api_port}{collection_path}/{name}" - req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) - try: - urllib.request.urlopen(req, context=ssl_ctx, timeout=15) - # Exists — patch it. - api_patch(f"{collection_path}/{name}", resource, token, ssl_ctx, patch_type="merge") - except urllib.error.HTTPError as e: - if e.code == 404: - api_post(collection_path, resource, token, ssl_ctx) - else: - body = e.read().decode() if e.fp else "" - print(f" Failed to check {name}: HTTP {e.code}: {body[:200]}", file=sys.stderr) - raise RuntimeError(f"K8s API error {e.code} for {name}") from e - - -def reconcile(ns, name, token, ssl_ctx): - """Reconcile a single ServiceOffer through all stages.""" - path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{CRD_PLURAL}/{name}" - obj = api_get(path, token, ssl_ctx) - - spec = obj.get("spec", {}) - conditions = obj.get("status", {}).get("conditions", []) - - print(f"\nReconciling {ns}/{name}...") - - # Stage 1: Model ready - if not is_condition_true(conditions, "ModelReady"): - if not stage_model_ready(spec, ns, name, token, ssl_ctx): - return False - # Refresh conditions after update. - obj = api_get(path, token, ssl_ctx) - conditions = obj.get("status", {}).get("conditions", []) - - # Stage 2: Upstream healthy - if not is_condition_true(conditions, "UpstreamHealthy"): - if not stage_upstream_healthy(spec, ns, name, token, ssl_ctx): - return False - obj = api_get(path, token, ssl_ctx) - conditions = obj.get("status", {}).get("conditions", []) - - # Stage 3: Payment gate - if not is_condition_true(conditions, "PaymentGateReady"): - if not stage_payment_gate(spec, ns, name, token, ssl_ctx): - return False - obj = api_get(path, token, ssl_ctx) - conditions = obj.get("status", {}).get("conditions", []) - - # Stage 4: Route published - if not is_condition_true(conditions, "RoutePublished"): - if not stage_route_published(spec, ns, name, token, ssl_ctx): - return False - obj = api_get(path, token, ssl_ctx) - conditions = obj.get("status", {}).get("conditions", []) - - # Stage 5: Registration - if not is_condition_true(conditions, "Registered"): - stage_registered(spec, ns, name, token, ssl_ctx) - - # Stage 6: Set Ready - set_condition(ns, name, "Ready", "True", "Reconciled", "All stages complete", token, ssl_ctx) - print(f" ServiceOffer {ns}/{name} is Ready") - return True - - -# --------------------------------------------------------------------------- -# CLI commands -# --------------------------------------------------------------------------- - -def cmd_list(token, ssl_ctx): - """List all ServiceOffers across namespaces.""" - path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/{CRD_PLURAL}" - data = api_get(path, token, ssl_ctx) - items = data.get("items", []) - - if not items: - print("No ServiceOffers found.") - return - - print(f"{'NAMESPACE':<25} {'NAME':<25} {'TYPE':<14} {'MODEL':<20} {'PRICE':<12} {'READY':<8}") - print("-" * 105) - for item in items: - ns = item["metadata"].get("namespace", "?") - item_name = item["metadata"].get("name", "?") - wtype = item.get("spec", {}).get("type", "inference") - model = item.get("spec", {}).get("model", {}).get("name", "-") - price_label = describe_price(item.get("spec", {})) - conditions = item.get("status", {}).get("conditions", []) - ready = "False" - for c in conditions: - if c.get("type") == "Ready": - ready = c.get("status", "False") - break - print(f"{ns:<25} {item_name:<25} {wtype:<14} {model:<20} {price_label:<12} {ready:<8}") - - -def cmd_status(ns, name, token, ssl_ctx): - """Show conditions for a single ServiceOffer.""" - path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{CRD_PLURAL}/{name}" - obj = api_get(path, token, ssl_ctx) - - spec = obj.get("spec", {}) - status = obj.get("status", {}) - conditions = status.get("conditions", []) - payment = get_payment(spec) - - print(f"ServiceOffer: {ns}/{name}") - print(f" Type: {spec.get('type', 'inference')}") - print(f" Model: {spec.get('model', {}).get('name', '-')}") - print(f" Upstream: {spec.get('upstream', {}).get('service', '-')}.{spec.get('upstream', {}).get('namespace', '-')}:{spec.get('upstream', {}).get('port', '-')}") - print(f" Price: {describe_price(spec)}") - print(f" PayTo: {payment.get('payTo', '-')}") - print(f" Network: {payment.get('network', '-')}") - print(f" Path: {spec.get('path', f'/services/{name}')}") - print(f" Endpoint: {status.get('endpoint', '-')}") - if status.get("agentId"): - print(f" Agent ID: {status['agentId']}") - if status.get("registrationTxHash"): - print(f" Reg Tx: {status['registrationTxHash']}") - print() - - if not conditions: - print(" No conditions set (pending reconciliation)") - return - - print(f" {'CONDITION':<22} {'STATUS':<10} {'REASON':<20} {'MESSAGE'}") - print(" " + "-" * 80) - for ct in CONDITION_TYPES: - c = get_condition(conditions, ct) - if c: - print(f" {ct:<22} {c.get('status', '?'):<10} {c.get('reason', '?'):<20} {c.get('message', '')[:50]}") - else: - print(f" {ct:<22} {'?':<10} {'Pending':<20} {'Not yet evaluated'}") - - -def cmd_create(args, token, ns, ssl_ctx): - """Create a new ServiceOffer CR.""" - offer_name = args.name - target_ns = args.namespace or ns - - # Build price table. - price = {} - if args.per_request: - price["perRequest"] = args.per_request - if args.per_mtok: - price["perMTok"] = args.per_mtok - if args.per_hour: - price["perHour"] = args.per_hour - - if not price: - print("Error: at least one price required: --per-request, --per-mtok, or --per-hour", file=sys.stderr) - sys.exit(1) - - spec = { - "type": args.type, - "upstream": { - "service": args.upstream, - "namespace": target_ns, - "port": args.port, - }, - "payment": { - "scheme": "exact", - "network": args.network, - "payTo": args.pay_to, - "maxTimeoutSeconds": 300, - "price": price, - }, - } - - if args.model: - spec["model"] = { - "name": args.model, - "runtime": args.runtime, - } - - if args.path: - spec["path"] = args.path - - if args.register: - registration = {"enabled": True} - if args.register_name: - registration["name"] = args.register_name - if args.register_description: - registration["description"] = args.register_description - spec["registration"] = registration - - body = { - "apiVersion": f"{CRD_GROUP}/{CRD_VERSION}", - "kind": "ServiceOffer", - "metadata": { - "name": offer_name, - "namespace": target_ns, - }, - "spec": spec, - } - - path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{target_ns}/{CRD_PLURAL}" - result = api_post(path, body, token, ssl_ctx) - print(f"ServiceOffer {target_ns}/{offer_name} created") - return result - - -def cmd_delete(ns, name, token, ssl_ctx): - """Delete a ServiceOffer CR and remove its pricing route.""" - # Read the offer to get the path before deleting. - so_path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{CRD_PLURAL}/{name}" - try: - so = api_get(so_path, token, ssl_ctx, quiet=True) - url_path = so.get("spec", {}).get("path", f"/services/{name}") - _remove_pricing_route(url_path, name, token, ssl_ctx) - except SystemExit: - pass # Offer may already be gone. - - api_delete(so_path, token, ssl_ctx) - print(f"ServiceOffer {ns}/{name} deleted") - - -def _remove_pricing_route(url_path, name, token, ssl_ctx): - """Remove a pricing route from the x402-verifier ConfigMap.""" - route_pattern = f"{url_path}/*" - - cm_path = "/api/v1/namespaces/x402/configmaps/x402-pricing" - try: - cm = api_get(cm_path, token, ssl_ctx, quiet=True) - except SystemExit: - return - - pricing_yaml_str = cm.get("data", {}).get("pricing.yaml", "") - if route_pattern not in pricing_yaml_str: - return - - # Remove the route entry. Routes now have variable line counts - # (pattern, price, description, optional payTo, optional network). - lines = pricing_yaml_str.split("\n") - filtered = [] - skip = False - for line in lines: - if f'pattern: "{route_pattern}"' in line: - skip = True - continue - if skip: - stripped = line.strip() - # Stop skipping when we hit the next route entry or a non-indented line. - if stripped.startswith("- ") or ( - stripped - and not stripped.startswith("price:") - and not stripped.startswith("description:") - and not stripped.startswith("payTo:") - and not stripped.startswith("network:") - and not stripped.startswith("upstreamAuth:") - and not stripped.startswith("priceModel:") - and not stripped.startswith("perMTok:") - and not stripped.startswith("approxTokensPerRequest:") - and not stripped.startswith("offerNamespace:") - and not stripped.startswith("offerName:") - ): - skip = False - filtered.append(line) - # Skip continuation lines of the removed route. - continue - filtered.append(line) - - updated = "\n".join(filtered) - - # If routes section is now empty, replace with routes: []. - remaining_routes = [l for l in filtered if l.strip().startswith("- pattern:")] - if not remaining_routes and "routes:" in updated: - # Replace "routes:\n" with "routes: []" - idx = updated.find("routes:") - end = updated.find("\n", idx) - if end != -1: - updated = updated[:idx] + "routes: []" + updated[end:] - else: - updated = updated[:idx] + "routes: []" - - patch_body = {"data": {"pricing.yaml": updated}} - api_patch(cm_path, patch_body, token, ssl_ctx, patch_type="merge") - print(f" Removed pricing route: {route_pattern}") - - -def cmd_process(ns, name, all_offers, quick, token, ssl_ctx): - """Reconcile one or all ServiceOffers. - - --quick: single-line summary for heartbeat efficiency. - READY: 3/3 offers - RECONCILED: my-qwen (PaymentGateReady→RoutePublished) - PENDING: my-qwen stuck at UpstreamHealthy - """ - if all_offers: - path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/{CRD_PLURAL}" - data = api_get(path, token, ssl_ctx) - items = data.get("items", []) - - if not items: - print("READY: 0/0 offers" if quick else "HEARTBEAT_OK: No ServiceOffers found") - try: - _publish_skill_md([], token, ssl_ctx) - except Exception as e: - print(f" Warning: skill.md publish failed: {e}", file=sys.stderr) - return - - pending = [] - for item in items: - conditions = item.get("status", {}).get("conditions", []) - if not is_condition_true(conditions, "Ready"): - pending.append(item) - - if not pending: - print(f"READY: {len(items)}/{len(items)} offers" if quick else "HEARTBEAT_OK: All offers are Ready") - try: - _publish_skill_md(items, token, ssl_ctx) - except Exception as e: - print(f" Warning: skill.md publish failed: {e}", file=sys.stderr) - return - - if quick: - # In quick mode, reconcile silently and report a compact summary. - results = [] - for item in pending: - item_ns = item["metadata"]["namespace"] - item_name = item["metadata"]["name"] - try: - reconcile(item_ns, item_name, token, ssl_ctx) - # Re-read conditions after reconciliation. - so_path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{item_ns}/{CRD_PLURAL}/{item_name}" - obj = api_get(so_path, token, ssl_ctx) - conds = obj.get("status", {}).get("conditions", []) - last = next((c for c in reversed(conds) if c.get("status") == "True"), None) - stage = last["type"] if last else "Unknown" - if is_condition_true(conds, "Ready"): - results.append(f"{item_name} (Ready)") - else: - results.append(f"{item_name} ({stage})") - except Exception as e: - results.append(f"{item_name} (error: {str(e)[:40]})") - ready_count = len(items) - len(pending) + sum(1 for r in results if "Ready" in r) - print(f"RECONCILED: {ready_count}/{len(items)} ready — {', '.join(results)}") - else: - print(f"Processing {len(pending)} pending offer(s)...") - for item in pending: - item_ns = item["metadata"]["namespace"] - item_name = item["metadata"]["name"] - try: - reconcile(item_ns, item_name, token, ssl_ctx) - except Exception as e: - print(f" Error reconciling {item_ns}/{item_name}: {e}", file=sys.stderr) - - # Regenerate /skill.md from current state of all offers. - try: - all_path = f"/apis/{CRD_GROUP}/{CRD_VERSION}/{CRD_PLURAL}" - all_data = api_get(all_path, token, ssl_ctx) - _publish_skill_md(all_data.get("items", []), token, ssl_ctx) - except Exception as e: - print(f" Warning: skill.md publish failed: {e}", file=sys.stderr) - else: - if not ns or not name: - print("Error: --namespace and name are required (or use --all)", file=sys.stderr) - sys.exit(1) - reconcile(ns, name, token, ssl_ctx) - - -# --------------------------------------------------------------------------- -# CLI entrypoint -# --------------------------------------------------------------------------- - -def main(): - parser = argparse.ArgumentParser( - description="Manage ServiceOffer CRDs for x402 payment-gated compute monetization", - ) - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - # list - subparsers.add_parser("list", help="List all ServiceOffers across namespaces") - - # status - sp_status = subparsers.add_parser("status", help="Show conditions for one offer") - sp_status.add_argument("name", help="ServiceOffer name") - sp_status.add_argument("--namespace", required=True, help="Namespace") - - # create - sp_create = subparsers.add_parser("create", help="Create a new ServiceOffer CR") - sp_create.add_argument("name", help="ServiceOffer name") - sp_create.add_argument("--type", default="http", choices=["inference", "fine-tuning", "http"], help="Service type (default: http)") - sp_create.add_argument("--model", help="Model name (e.g. qwen3.5:35b)") - sp_create.add_argument("--runtime", default="ollama", help="Model runtime (default: ollama)") - sp_create.add_argument("--upstream", required=True, help="Upstream service name") - sp_create.add_argument("--namespace", help="Target namespace") - sp_create.add_argument("--port", type=int, default=11434, help="Upstream port (default: 11434)") - sp_create.add_argument("--per-request", help="Per-request price in USDC") - sp_create.add_argument("--per-mtok", help="Per-million-tokens price in USDC (inference)") - sp_create.add_argument("--per-hour", help="Per-compute-hour price in USDC (fine-tuning)") - sp_create.add_argument("--network", required=True, help="Payment chain (e.g. base-sepolia)") - sp_create.add_argument("--pay-to", required=True, help="USDC recipient wallet address (x402: payTo)") - sp_create.add_argument("--path", help="URL path prefix (default: /services/)") - sp_create.add_argument("--register", action="store_true", help="Register on ERC-8004") - sp_create.add_argument("--register-name", help="Agent name for ERC-8004") - sp_create.add_argument("--register-description", help="Agent description for ERC-8004") - - # delete - sp_delete = subparsers.add_parser("delete", help="Delete a ServiceOffer CR") - sp_delete.add_argument("name", help="ServiceOffer name") - sp_delete.add_argument("--namespace", required=True, help="Namespace") - - # process - sp_process = subparsers.add_parser("process", help="Reconcile ServiceOffer(s)") - sp_process.add_argument("name", nargs="?", help="ServiceOffer name (or use --all)") - sp_process.add_argument("--namespace", help="Namespace") - sp_process.add_argument("--all", dest="all_offers", action="store_true", help="Process all non-Ready offers") - sp_process.add_argument("--quick", action="store_true", help="Single-line summary output (for heartbeat efficiency)") - - args = parser.parse_args() - - if not args.command: - parser.print_help() - sys.exit(1) - - token, default_ns = load_sa() - ssl_ctx = make_ssl_context() - - if args.command == "list": - cmd_list(token, ssl_ctx) - elif args.command == "status": - cmd_status(args.namespace, args.name, token, ssl_ctx) - elif args.command == "create": - cmd_create(args, token, default_ns, ssl_ctx) - elif args.command == "delete": - cmd_delete(args.namespace, args.name, token, ssl_ctx) - elif args.command == "process": - cmd_process( - getattr(args, "namespace", None), - getattr(args, "name", None), - getattr(args, "all_offers", False), - getattr(args, "quick", False), - token, - ssl_ctx, - ) - - -if __name__ == "__main__": - main() diff --git a/internal/x402/watcher.go b/internal/x402/watcher.go deleted file mode 100644 index 3b1c038..0000000 --- a/internal/x402/watcher.go +++ /dev/null @@ -1,57 +0,0 @@ -package x402 - -import ( - "context" - "log" - "os" - "time" -) - -// WatchConfig polls a YAML config file for changes and reloads the Verifier -// when the file is modified. It checks the file's modification time every -// interval. This handles ConfigMap volume mount updates (kubelet symlink swaps) -// without requiring fsnotify. -// -// WatchConfig blocks until the context is cancelled. -func WatchConfig(ctx context.Context, path string, v *Verifier, interval time.Duration) { - if interval <= 0 { - interval = 5 * time.Second - } - - var lastMod time.Time - - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - info, err := os.Stat(path) - if err != nil { - log.Printf("x402-watcher: stat %s: %v", path, err) - continue - } - - mod := info.ModTime() - if mod.Equal(lastMod) { - continue - } - lastMod = mod - - cfg, err := LoadConfig(path) - if err != nil { - log.Printf("x402-watcher: reload failed: %v", err) - continue - } - - if err := v.Reload(cfg); err != nil { - log.Printf("x402-watcher: apply config failed: %v", err) - continue - } - - log.Printf("x402-watcher: config reloaded (%d routes)", len(cfg.Routes)) - } - } -}