From 82d4473d27a6ed1506d06219e1d4a8e8e4c60d8e Mon Sep 17 00:00:00 2001 From: qu0b Date: Fri, 12 Jun 2026 12:42:18 +0000 Subject: [PATCH 01/14] feat(devnet): run Kurtosis Ethereum devnets via the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `panda devnet up/ls/inspect/down` command group for spinning up multi-client Ethereum devnets as Kurtosis enclaves on Kubernetes. Follows panda's thin-CLI/server split: the CLI dispatches devnet.* operations to the local server, which holds the Kurtosis engine connection and drives the ethpandaops ethereum-package via the Kurtosis Go SDK. The caller's identity is derived server-side (authOwnerID), never client-sent, so a future cloud-proxy rail can gate enclave creation on it. Configuration uses one cluster at a time via a top-level `cluster:` block (name + kubeconfig_context), switchable between a local and a cloud Kubernetes cluster by editing that block. Storage class / enclave size are intentionally NOT in panda config — they are engine-level Kurtosis settings fixed at engine start, so they live in kurtosis-config.yml. An optional docker_cache (e.g. docker.ethquokkaops.io) routes all package images through a pull-through cache, avoiding Docker Hub rate limits on multi-node clusters. Validated against a local k3s cluster: full up/ls/inspect/down lifecycle (~160s up, ~47s down, zero stale namespaces/PVs/PVCs), plus unit tests and a live integration test for the server handler path. Deferred (Phase 2): a cloud rail behind the proxy, which holds the cloud kubeconfig and gates enclave creation on GitHub-org membership. Co-Authored-By: Claude Opus 4.8 --- config.example.yaml | 23 ++ docs/devnet.md | 109 +++++++++ go.mod | 58 ++++- go.sum | 351 +++++++++++++++++++++++++-- pkg/cli/devnet.go | 351 +++++++++++++++++++++++++++ pkg/config/config.go | 2 + pkg/config/devnet.go | 32 +++ pkg/devnet/cluster.go | 131 ++++++++++ pkg/devnet/cluster_test.go | 127 ++++++++++ pkg/devnet/devnet.go | 258 ++++++++++++++++++++ pkg/devnet/devnet_test.go | 57 +++++ pkg/server/builder.go | 2 + pkg/server/operations_devnet.go | 234 ++++++++++++++++++ pkg/server/operations_devnet_test.go | 85 +++++++ pkg/server/operations_dispatch.go | 1 + pkg/server/server.go | 6 + 16 files changed, 1797 insertions(+), 30 deletions(-) create mode 100644 docs/devnet.md create mode 100644 pkg/cli/devnet.go create mode 100644 pkg/config/devnet.go create mode 100644 pkg/devnet/cluster.go create mode 100644 pkg/devnet/cluster_test.go create mode 100644 pkg/devnet/devnet.go create mode 100644 pkg/devnet/devnet_test.go create mode 100644 pkg/server/operations_devnet.go create mode 100644 pkg/server/operations_devnet_test.go diff --git a/config.example.yaml b/config.example.yaml index cbe6a490..f72e8435 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -86,3 +86,26 @@ proxies: observability: metrics_enabled: true metrics_port: 2490 # in-container bind port; docker-compose publishes it on the host via MCP_METRICS_PORT + +# Kubernetes cluster used by `panda devnet` (optional — only needed to run +# devnets). panda uses ONE cluster at a time; switch between a local and a cloud +# cluster by editing this block (or pointing panda at another config file). +# +# The cluster's engine-level settings (storage class, enclave size) live in +# Kurtosis's own config (~/.config/kurtosis/kurtosis-config.yml), since Kurtosis +# fixes them when that cluster's engine starts. panda only selects the cluster. +# +# --- LOCAL rail (e.g. a local k3s named "bruno") --- +# cluster: +# name: bruno # a key under kurtosis-clusters in kurtosis-config.yml +# kubeconfig_context: bruno # kube context Kurtosis connects through +# +# --- CLOUD rail --- +# cluster: +# name: cloud +# kubeconfig_context: ethpandaops-cloud + +# devnet configuration (optional). +# devnet: +# package: github.com/ethpandaops/ethereum-package +# docker_cache: docker.ethquokkaops.io # pull-through cache; avoids Docker Hub rate limits diff --git a/docs/devnet.md b/docs/devnet.md new file mode 100644 index 00000000..795bc13d --- /dev/null +++ b/docs/devnet.md @@ -0,0 +1,109 @@ +# Devnets on Kubernetes (`panda devnet`) + +`panda devnet` spins up multi-client Ethereum devnets as Kurtosis enclaves on a +Kubernetes cluster. The CLI dispatches operations to the local panda server, +which holds the Kurtosis engine connection and drives the +[ethereum-package](https://github.com/ethpandaops/ethereum-package). + +``` +panda CLI ──HTTP──▶ panda server (local) ──Kurtosis SDK──▶ Kurtosis engine ──▶ k8s +``` + +## Two rails, one switch + +panda uses **one cluster at a time**. You keep a local rail (a local k3s/k8s for +fast iteration) and a cloud rail, and switch by editing the top-level `cluster:` +block in the panda config — or by pointing panda at a different config file. + +```yaml +cluster: + name: bruno # the Kurtosis cluster to use + kubeconfig_context: bruno # kube context Kurtosis connects through + +devnet: + package: github.com/ethpandaops/ethereum-package + docker_cache: docker.ethquokkaops.io # avoids Docker Hub rate limits +``` + +Switch to the cloud rail: + +```yaml +cluster: + name: cloud + kubeconfig_context: ethpandaops-cloud +``` + +On each devnet operation the server: + +1. selects `kubeconfig_context` as the current kube context (so Kurtosis targets + the right cluster), and +2. activates the Kurtosis cluster named `name`. + +If you switch clusters while an engine is already running, restart it +(`kurtosis engine restart`) so it rebinds to the new context. + +## Why there's no `storage_class` in panda config + +The storage class (and enclave size) are **engine-level** settings: Kurtosis +fixes them when the engine starts and the SDK can't override them per run. They +therefore live in Kurtosis's own config, once per cluster, not in panda. A +cluster usually has several storage classes and the *binding mode* matters — +`WaitForFirstConsumer` classes (e.g. `local-path`, EBS `gp3`, GKE +`pd-balanced`) work, while `Immediate`-binding classes (e.g. `longhorn`) break +Kurtosis's pod-scheduling wait. Pick a `WaitForFirstConsumer` class. + +## One-time per-cluster setup (Kurtosis side) + +Each cluster needs a Kurtosis cluster entry (`~/.config/kurtosis/kurtosis-config.yml`) +and a reachable engine. For a local k3s named `bruno`: + +```yaml +# ~/.config/kurtosis/kurtosis-config.yml +kurtosis-clusters: + docker: + type: "docker" + bruno: + type: "kubernetes" + config: + kubernetes-cluster-name: "bruno" + storage-class: "local-path" # a WaitForFirstConsumer class on this cluster + enclave-size-in-megabytes: 1024 +``` + +Then start the engine and a gateway (the gateway exposes the in-cluster engine +on `localhost:9710`, where the SDK connects): + +```bash +kurtosis cluster set bruno +kurtosis engine start +kurtosis gateway # keep running in a separate terminal +``` + +Add a `cloud` entry the same way for the cloud rail. + +## Usage + +```bash +panda devnet up my-devnet --args ./network_params.yaml +panda devnet ls +panda devnet inspect my-devnet +panda devnet down my-devnet # or: panda devnet down --all +``` + +Debug a running devnet with the `kurtosis` CLI directly — the server already +points it at the right backend: + +```bash +kurtosis enclave inspect my-devnet +kurtosis service logs my-devnet el-1-geth-lighthouse -f +``` + +## Roadmap: cloud rail behind the proxy + +Today both rails run the Kurtosis client in the local server. Because the local +server runs on a user's own host, it can't be the authorization boundary for a +shared cloud cluster. So the cloud rail will move behind the cloud **proxy**, +which holds the cloud kubeconfig and gates enclave creation on GitHub-org +membership; enclaves are owner-stamped and filtered per authenticated user. The +operation contract already derives the caller's identity server-side, so this is +an additive change — the CLI and the `cluster:` switch stay the same. diff --git a/go.mod b/go.mod index 7c43b2fb..00ef78cf 100644 --- a/go.mod +++ b/go.mod @@ -11,11 +11,12 @@ require ( github.com/ethpandaops/forky v1.0.2 github.com/ethpandaops/tracoor v0.0.32 github.com/gdamore/tcell/v2 v2.13.10 - github.com/getkin/kin-openapi v0.134.0 + github.com/getkin/kin-openapi v0.138.0 github.com/go-chi/chi/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5 + github.com/kurtosis-tech/kurtosis/api/golang v1.19.0 github.com/mark3labs/mcp-go v0.54.1 github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 @@ -28,17 +29,24 @@ require ( github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.42.0 golang.org/x/time v0.15.0 - google.golang.org/protobuf v1.36.11 + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 + k8s.io/client-go v0.36.2 ) require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/STARRY-S/zip v0.2.3 // indirect + github.com/adrg/xdg v0.5.3 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bodgit/plumbing v1.3.0 // indirect + github.com/bodgit/sevenzip v1.6.1 // indirect + github.com/bodgit/windows v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -49,9 +57,11 @@ require ( github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/docker/go-connections v0.7.0 // indirect + github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/go-faster/errors v0.7.1 // indirect @@ -64,19 +74,31 @@ require ( github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/go-test/deep v1.1.0 // indirect + github.com/go-yaml/yaml v2.1.0+incompatible // indirect github.com/gofiber/fiber/v3 v3.1.0 // indirect github.com/gofiber/schema v1.7.0 // indirect github.com/gofiber/utils/v2 v2.0.2 // indirect github.com/google/jsonschema-go v0.4.2 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/pgzip v1.2.6 // indirect + github.com/kurtosis-tech/kurtosis-portal/api/golang v0.0.0-20230818182330-1a86869414d2 // indirect + github.com/kurtosis-tech/kurtosis/contexts-config-store v0.0.0-20230818184218-f4e3e773463b // indirect + github.com/kurtosis-tech/kurtosis/grpc-file-transfer/golang v0.0.0-20230803130419-099ee7a4e3dc // indirect + github.com/kurtosis-tech/kurtosis/path-compression v0.0.0-20260325155815-f36ae687d73d // indirect + github.com/kurtosis-tech/stacktrace v0.0.0-20211028211901-1c67a77b5409 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.9.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mholt/archives v0.1.5 // indirect + github.com/mikelolasagasti/xz v1.0.1 // indirect + github.com/minio/minlz v1.0.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/patternmatcher v0.6.1 // indirect @@ -84,16 +106,20 @@ require ( github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/oapi-codegen/runtime v1.3.1 // indirect - github.com/oasdiff/yaml v0.0.1 // indirect - github.com/oasdiff/yaml3 v0.0.1 // indirect + github.com/nwaples/rardecode/v2 v2.2.0 // indirect + github.com/oapi-codegen/runtime v1.4.1 // indirect + github.com/oasdiff/yaml v0.0.9 // indirect + github.com/oasdiff/yaml3 v0.0.12 // indirect github.com/ogen-go/ogen v1.20.3 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -104,14 +130,18 @@ require ( github.com/segmentio/asm v1.2.1 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/shopspring/decimal v1.4.0 // indirect + github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/tinylib/msgp v1.6.3 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect github.com/woodsbury/decimal128 v1.4.0 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -125,6 +155,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect + go4.org v0.0.0-20230225012048-214862532bf5 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect golang.org/x/mod v0.35.0 // indirect @@ -137,10 +168,19 @@ require ( golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.44.0 // indirect golang.org/x/vuln v1.3.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/grpc v1.81.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/apimachinery v0.36.2 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // 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 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) tool golang.org/x/vuln/cmd/govulncheck diff --git a/go.sum b/go.sum index 98dfd5cf..211be99a 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,37 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.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/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= +github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= @@ -14,14 +39,25 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= +github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= +github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= +github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= +github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= +github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 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/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -38,6 +74,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -48,8 +85,15 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= +github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= +github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/ethpandaops/cartographoor v0.0.0-20260601034537-1072505afa69 h1:uEp8rQZ9DFP+uRs9hxC3HQvSfu0KH4bCB5TXAfVWVTQ= github.com/ethpandaops/cartographoor v0.0.0-20260601034537-1072505afa69/go.mod h1:By1UZThVBtMHckTIPE4TuRWacAYLmoHaOpnhdcmADS0= github.com/ethpandaops/cbt v0.1.6 h1:yKPcO8NnhfZEe9tqgCrsDUMWsRTW/U/19bMX3t4TmHc= @@ -70,8 +114,8 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA= github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= -github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU= -github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE= +github.com/getkin/kin-openapi v0.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4= +github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= @@ -82,6 +126,8 @@ github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -94,12 +140,18 @@ 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.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +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.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o= +github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= @@ -108,33 +160,91 @@ github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QL github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +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-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5 h1:jP1RStw811EvUDzsUQ9oESqw2e4RqCjSAD9qIL8eMns= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5/go.mod h1:WXNBZ64q3+ZUemCMXD9kYnr56H7CgZxDBHCVwstfl3s= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid v1.2.0 h1:NMpwD2G9JSFOE1/TJjGSo5zG7Yb2bTe7eq1jH+irmeE= +github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kurtosis-tech/kurtosis-portal/api/golang v0.0.0-20230818182330-1a86869414d2 h1:izciXrFyFR+ihJ7nLTOkoIX5GzBPIp8gVKlw94gIc98= +github.com/kurtosis-tech/kurtosis-portal/api/golang v0.0.0-20230818182330-1a86869414d2/go.mod h1:bWSMQK3WHVTGHX9CjxPAb/LtzcmfOxID2wdzakSWQxo= +github.com/kurtosis-tech/kurtosis/api/golang v1.19.0 h1:rNPJa6zC+7nhHeaGKqjSgprqzuaJBlowhUAyCMN5StU= +github.com/kurtosis-tech/kurtosis/api/golang v1.19.0/go.mod h1:Jkaa4Bs2H0/9LCryo+Rw0bwre8ylDDrnI/x7q0bjafo= +github.com/kurtosis-tech/kurtosis/contexts-config-store v0.0.0-20230818184218-f4e3e773463b h1:hMoIM99QKcYQqsnK4AF7Lovi9ZD9ac6lZLZ5D/jx2x8= +github.com/kurtosis-tech/kurtosis/contexts-config-store v0.0.0-20230818184218-f4e3e773463b/go.mod h1:4pFdrRwDz5R+Fov2ZuTaPhAVgjA2jhGh1Izf832sX7A= +github.com/kurtosis-tech/kurtosis/grpc-file-transfer/golang v0.0.0-20230803130419-099ee7a4e3dc h1:7IlEpSehmWcNXOFpNP24Cu5HQI3af7GCBQw//m+LnvQ= +github.com/kurtosis-tech/kurtosis/grpc-file-transfer/golang v0.0.0-20230803130419-099ee7a4e3dc/go.mod h1:TOWMQgvAJH/NiWWERGXg/plT9lS7aFcXFxCa0M5sfHo= +github.com/kurtosis-tech/kurtosis/path-compression v0.0.0-20260325155815-f36ae687d73d h1:ezaN718K5Z/lYYCi8LaLpfkK5uIEfhCp+Cja6sZdlM4= +github.com/kurtosis-tech/kurtosis/path-compression v0.0.0-20260325155815-f36ae687d73d/go.mod h1:AkQIlVGU8KzHY8fJuHjsCoQn5MCzlu/d7b5tg7CYN7U= +github.com/kurtosis-tech/stacktrace v0.0.0-20211028211901-1c67a77b5409 h1:YQTATifMUwZEtZYb0LVA7DK2pj8s71iY8rzweuUQ5+g= +github.com/kurtosis-tech/stacktrace v0.0.0-20211028211901-1c67a77b5409/go.mod h1:y5weVs5d9wXXHcDA1awRxkIhhHC1xxYJN8a7aXnE6S8= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -149,8 +259,14 @@ github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldV github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= 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-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= +github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= +github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= +github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= +github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= +github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -169,16 +285,26 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +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/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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= 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/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g= -github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= -github.com/oasdiff/yaml v0.0.1 h1:dPrn0F2PJ7HdzHPndJkArvB2Fw0cwgFdVUKCEkoFuds= -github.com/oasdiff/yaml v0.0.1/go.mod h1:r8bgVgpWT5iIN/AgP0GljFvB6CicK+yL1nIAbm+8/QQ= -github.com/oasdiff/yaml3 v0.0.1 h1:kReOSraQLTxuuGNX9aNeJ7tcsvUB2MS+iupdUrWe4Z0= -github.com/oasdiff/yaml3 v0.0.1/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= +github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo= +github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= +github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= +github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M= +github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/ogen-go/ogen v1.20.3 h1:1tvJuJE0BnQ7Nukd6ykiTOP0ucfL0yrAjHUg3S1DCQk= github.com/ogen-go/ogen v1.20.3/go.mod h1:sJ1pJVp4S1RcSZlYIiMLo0QSMSt2pls4zfrc+hNKnzk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -189,6 +315,8 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -196,6 +324,7 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 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.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 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.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= @@ -208,9 +337,11 @@ github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= @@ -223,6 +354,8 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= +github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -234,9 +367,16 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= 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.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +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= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= @@ -249,6 +389,9 @@ github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9R github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= @@ -266,6 +409,10 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= @@ -290,32 +437,97 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= +go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +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-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -324,7 +536,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260421165255-392afab6f40e h1:OXgN37M6hqjaAvb7CJK9vJ+7Z/6lvIm5bXho5poo/Wk= @@ -334,17 +545,44 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= @@ -356,24 +594,95 @@ golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGN golang.org/x/vuln v1.3.0 h1:hZYzR8uRhYhDSX88d+40TWbKAVw7BIvRWm26rtEn8jw= golang.org/x/vuln v1.3.0/go.mod h1:MIY2PaR1y52stzZM3uHBboUAdVJvSVMl5nP3OQrwQaE= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= -google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +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/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= +k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= +k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= +k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= +k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= +k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +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 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/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/pkg/cli/devnet.go b/pkg/cli/devnet.go new file mode 100644 index 00000000..8f4e9ce3 --- /dev/null +++ b/pkg/cli/devnet.go @@ -0,0 +1,351 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/ethpandaops/panda/pkg/devnet" + "github.com/ethpandaops/panda/pkg/operations" +) + +var ( + devnetArgsFile string + devnetPackage string + devnetAlwaysPull bool + devnetDryRun bool + devnetDockerCache string + devnetDownAll bool +) + +var devnetCmd = &cobra.Command{ + GroupID: groupWorkflow, + Use: "devnet", + Short: "Spin up Kurtosis Ethereum devnets", + Long: `Spin up and manage multi-client Ethereum devnets as Kurtosis enclaves. + +The panda server drives a Kurtosis engine (Docker or Kubernetes backend) to run +the ethpandaops ethereum-package; the CLI dispatches devnet operations to it, so +the server is what holds the cluster connection. The backend, package, and an +optional pull-through image cache are configured server-side under "devnet:": + + devnet: + cluster: bruno # Kurtosis backend (docker | ) + package: github.com/ethpandaops/ethereum-package + docker_cache: docker.ethquokkaops.io # avoids Docker Hub rate limits + +Debug a running devnet with the kurtosis CLI directly (the server already points +it at the right backend), e.g. ` + "`kurtosis service logs -f`" + `. + +Examples: + panda devnet up my-devnet --args ./network_params.yaml + panda devnet ls + panda devnet inspect my-devnet + panda devnet down my-devnet`, +} + +func init() { + rootCmd.AddCommand(devnetCmd) + + devnetCmd.AddCommand( + devnetUpCmd, + devnetLsCmd, + devnetInspectCmd, + devnetDownCmd, + ) + + devnetUpCmd.Flags().StringVar(&devnetArgsFile, "args", "", + "path to an ethereum-package args file (YAML or JSON); '-' reads stdin") + devnetUpCmd.Flags().StringVar(&devnetPackage, "package", "", + "Kurtosis package to run (overrides devnet.package in server config)") + devnetUpCmd.Flags().BoolVar(&devnetAlwaysPull, "always-pull", false, + "always re-pull images (use for devnet branches with mutable tags)") + devnetUpCmd.Flags().BoolVar(&devnetDryRun, "dry-run", false, + "validate and plan the run without applying it") + devnetUpCmd.Flags().StringVar(&devnetDockerCache, "docker-cache", "", + "pull-through registry cache host for all images (overrides devnet.docker_cache)") + _ = devnetUpCmd.MarkFlagFilename("args", "yaml", "yml", "json") + + devnetDownCmd.Flags().BoolVar(&devnetDownAll, "all", false, + "destroy every devnet enclave") + + devnetInspectCmd.ValidArgsFunction = completeEnclaveNames + devnetDownCmd.ValidArgsFunction = completeEnclaveNames +} + +var devnetUpCmd = &cobra.Command{ + Use: "up [enclave-name]", + Short: "Create an enclave and launch a devnet", + Long: `Create a Kurtosis enclave and run the ethereum-package in it. + +If no enclave name is given, Kurtosis generates one. Package configuration is +read from the file passed with --args (the ethereum-package network_params +format); without it the package defaults are used.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + serializedArgs, err := readArgsFile(devnetArgsFile) + if err != nil { + return err + } + + opArgs := map[string]any{} + if len(args) == 1 { + opArgs["name"] = args[0] + } + if serializedArgs != "" { + opArgs["args"] = serializedArgs + } + if cmd.Flags().Changed("package") { + opArgs["package"] = devnetPackage + } + if cmd.Flags().Changed("docker-cache") { + opArgs["docker_cache"] = devnetDockerCache + } + if devnetAlwaysPull { + opArgs["always_pull"] = true + } + if devnetDryRun { + opArgs["dry_run"] = true + } + + out := cmd.OutOrStdout() + fmt.Fprintln(out, "Launching devnet (this can take a few minutes)…") + + resp, err := runServerOperation("devnet.up", opArgs) + if err != nil { + return err + } + + var result struct { + Enclave string `json:"enclave"` + Output string `json:"output"` + Error string `json:"error"` + } + if err := decodeOperationData(resp, &result); err != nil { + return err + } + + if result.Output != "" { + fmt.Fprint(out, result.Output) + } + + if result.Error != "" { + if result.Enclave != "" { + fmt.Fprintf(out, "\nEnclave %q left in place; remove it with: panda devnet down %s\n", result.Enclave, result.Enclave) + } + return fmt.Errorf("%s", result.Error) + } + + fmt.Fprintf(out, "\nDevnet %q is up.\n", result.Enclave) + fmt.Fprintf(out, " inspect: panda devnet inspect %s\n", result.Enclave) + fmt.Fprintf(out, " logs: kurtosis service logs %s -f\n", result.Enclave) + fmt.Fprintf(out, " destroy: panda devnet down %s\n", result.Enclave) + + return nil + }, +} + +var devnetLsCmd = &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List devnet enclaves", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + enclaves, err := fetchEnclaves() + if err != nil { + return err + } + + if isJSON() { + return printJSON(enclaves) + } + + if len(enclaves) == 0 { + fmt.Println("No devnets found.") + return nil + } + + rows := make([][]string, 0, len(enclaves)) + for _, e := range enclaves { + rows = append(rows, []string{ + e.Name, + e.Status, + e.APIContainer, + shortUUID(e.UUID), + formatCreated(e.CreationTime), + }) + } + printTable([]string{"NAME", "STATUS", "API CONTAINER", "UUID", "CREATED"}, rows) + + return nil + }, +} + +var devnetInspectCmd = &cobra.Command{ + Use: "inspect ", + Short: "Show details for a devnet enclave", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + resp, err := runServerOperation("devnet.inspect", map[string]any{"enclave": args[0]}) + if err != nil { + return err + } + + var enclave devnet.Enclave + if err := decodeOperationData(resp, &enclave); err != nil { + return err + } + + if isJSON() { + return printJSON(enclave) + } + + printTable( + []string{"FIELD", "VALUE"}, + [][]string{ + {"Name", enclave.Name}, + {"UUID", enclave.UUID}, + {"Status", enclave.Status}, + {"API container", enclave.APIContainer}, + {"Created", formatCreated(enclave.CreationTime)}, + }, + ) + + return nil + }, +} + +var devnetDownCmd = &cobra.Command{ + Use: "down [enclave]", + Aliases: []string{"rm", "destroy"}, + Short: "Destroy a devnet enclave (or all with --all)", + Long: `Destroy a devnet enclave, tearing down its namespace, pods and volumes. + +Pass an enclave name to destroy one, or --all to prune every devnet — useful for +reclaiming cluster resources when no devnets are needed anymore.`, + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + opArgs := map[string]any{} + switch { + case devnetDownAll: + if len(args) != 0 { + return fmt.Errorf("--all takes no enclave name") + } + opArgs["all"] = true + case len(args) == 1: + opArgs["enclave"] = args[0] + default: + return fmt.Errorf("requires an enclave name, or --all to destroy every devnet") + } + + resp, err := runServerOperation("devnet.down", opArgs) + if err != nil { + return err + } + + var result struct { + Destroyed []string `json:"destroyed"` + } + if err := decodeOperationData(resp, &result); err != nil { + return err + } + + if len(result.Destroyed) == 0 { + fmt.Println("No devnets to destroy.") + return nil + } + for _, name := range result.Destroyed { + fmt.Printf("Destroyed devnet %q.\n", name) + } + + return nil + }, +} + +// decodeOperationData re-decodes the generic operation Data payload into a typed +// target via a JSON round-trip. +func decodeOperationData(resp *operations.Response, target any) error { + raw, err := json.Marshal(resp.Data) + if err != nil { + return fmt.Errorf("encoding response data: %w", err) + } + if err := json.Unmarshal(raw, target); err != nil { + return fmt.Errorf("decoding response data: %w", err) + } + + return nil +} + +// fetchEnclaves lists enclaves via the server. +func fetchEnclaves() ([]devnet.Enclave, error) { + resp, err := runServerOperation("devnet.ls", map[string]any{}) + if err != nil { + return nil, err + } + + var enclaves []devnet.Enclave + if err := decodeOperationData(resp, &enclaves); err != nil { + return nil, err + } + + return enclaves, nil +} + +// readArgsFile reads ethereum-package args from path. An empty path means no +// args (package defaults); "-" reads from stdin. +func readArgsFile(path string) (string, error) { + if path == "" { + return "", nil + } + + var ( + data []byte + err error + ) + if path == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(path) + } + if err != nil { + return "", fmt.Errorf("reading args file %q: %w", path, err) + } + + return string(data), nil +} + +// completeEnclaveNames provides shell completion of existing enclave names. +func completeEnclaveNames(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + enclaves, err := fetchEnclaves() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + names := make([]string, 0, len(enclaves)) + for _, e := range enclaves { + names = append(names, e.Name) + } + + return names, cobra.ShellCompDirectiveNoFileComp +} + +func shortUUID(uuid string) string { + const shortLen = 12 + if len(uuid) <= shortLen { + return uuid + } + + return uuid[:shortLen] +} + +func formatCreated(t time.Time) string { + if t.IsZero() { + return "-" + } + + return t.Local().Format("2006-01-02 15:04:05") +} diff --git a/pkg/config/config.go b/pkg/config/config.go index b4d89876..46543ba7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -40,6 +40,8 @@ type Config struct { Storage StorageConfig `yaml:"storage"` Observability ObservabilityConfig `yaml:"observability"` ConsensusSpecs ConsensusSpecsConfig `yaml:"consensus_specs,omitempty"` + Cluster ClusterConfig `yaml:"cluster,omitempty"` + Devnet DevnetConfig `yaml:"devnet,omitempty"` path string `yaml:"-"` } diff --git a/pkg/config/devnet.go b/pkg/config/devnet.go new file mode 100644 index 00000000..3afd8a50 --- /dev/null +++ b/pkg/config/devnet.go @@ -0,0 +1,32 @@ +package config + +// ClusterConfig identifies the single Kubernetes cluster panda uses. The +// cluster's engine-level settings (storage class, enclave size) live in +// Kurtosis's own config — they are fixed when that cluster's engine starts, so +// panda only needs to know which cluster to target. Switch between a local and +// a cloud cluster by editing this block (or pointing panda at another config). +type ClusterConfig struct { + // Name is the Kurtosis cluster to activate — a key under "kurtosis-clusters" + // in ~/.config/kurtosis/kurtosis-config.yml. Empty leaves Kurtosis's current + // selection untouched. + Name string `yaml:"name,omitempty"` + + // KubeconfigContext is the kubeconfig context Kurtosis connects through. + // Empty uses the kubeconfig's current context. + KubeconfigContext string `yaml:"kubeconfig_context,omitempty"` +} + +// DevnetConfig is the `devnet:` section of the panda config. It configures the +// `panda devnet` commands. The target cluster lives in the top-level `cluster:` +// block (ClusterConfig), since clusters are a shared resource, not devnet-only. +type DevnetConfig struct { + // Package overrides the ethereum-package reference to run. Empty uses the + // built-in default (github.com/ethpandaops/ethereum-package). + Package string `yaml:"package,omitempty"` + + // DockerCache is a pull-through registry cache host (e.g. + // "docker.ethquokkaops.io"). When set, every package image is routed + // through it, which avoids Docker Hub rate limits — especially important on + // a multi-node Kubernetes backend. Empty disables it. + DockerCache string `yaml:"docker_cache,omitempty"` +} diff --git a/pkg/devnet/cluster.go b/pkg/devnet/cluster.go new file mode 100644 index 00000000..2d81aa4d --- /dev/null +++ b/pkg/devnet/cluster.go @@ -0,0 +1,131 @@ +package devnet + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "k8s.io/client-go/tools/clientcmd" +) + +// kurtosisClusterSettingPath returns the path to Kurtosis's cluster-setting +// file, which holds the name of the currently selected cluster. Kurtosis stores +// it under its XDG data dir as a plain one-line cluster name. +func kurtosisClusterSettingPath() string { + base := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")) + if base == "" { + if home, err := os.UserHomeDir(); err == nil && home != "" { + base = filepath.Join(home, ".local", "share") + } else { + base = filepath.Join(".local", "share") + } + } + + return filepath.Join(base, "kurtosis", "cluster-setting") +} + +// ActiveCluster returns the Kurtosis cluster currently selected, or "" if none +// has been selected yet. +func ActiveCluster() (string, error) { + data, err := os.ReadFile(kurtosisClusterSettingPath()) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + + return "", fmt.Errorf("reading Kurtosis cluster setting: %w", err) + } + + return strings.TrimSpace(string(data)), nil +} + +// EnsureCluster makes Kurtosis target the cluster named by target, which selects +// the backend (Docker vs Kubernetes). An empty target is a no-op. +// +// If no cluster has been selected yet it writes the setting. If the same cluster +// is already selected it does nothing. If a *different* cluster is already +// selected it refuses to switch silently — a running engine is bound to that +// cluster, so switching needs an engine restart — and returns actionable +// guidance instead. +func EnsureCluster(target string, out io.Writer) error { + if target == "" { + return nil + } + + active, err := ActiveCluster() + if err != nil { + return err + } + + switch active { + case target: + fmt.Fprintf(out, "Kurtosis cluster: %s\n", target) + + return nil + case "": + if err := writeClusterSetting(target); err != nil { + return err + } + fmt.Fprintf(out, "Set Kurtosis cluster to %q.\n", target) + + return nil + default: + return fmt.Errorf( + "panda is configured for Kurtosis cluster %q but Kurtosis is currently set to %q.\n"+ + "Switch with: kurtosis cluster set %s && kurtosis engine restart\n"+ + "(for a Kubernetes backend, also keep `kurtosis gateway` running in another terminal)", + target, active, target, + ) + } +} + +// EnsureKubeContext selects the given kubeconfig context as the current one, so +// Kurtosis (which connects through the kubeconfig's current context) targets the +// intended cluster. An empty context is a no-op. +// +// Note: a running engine/gateway is bound to the context that was current when +// it started; changing the context here takes effect on the next engine start. +func EnsureKubeContext(contextName string, out io.Writer) error { + if contextName == "" { + return nil + } + + rules := clientcmd.NewDefaultClientConfigLoadingRules() + cfg, err := rules.Load() + if err != nil { + return fmt.Errorf("loading kubeconfig: %w", err) + } + + if _, ok := cfg.Contexts[contextName]; !ok { + return fmt.Errorf("kubeconfig has no context %q", contextName) + } + + if cfg.CurrentContext == contextName { + return nil + } + + cfg.CurrentContext = contextName + if err := clientcmd.ModifyConfig(rules, *cfg, true); err != nil { + return fmt.Errorf("selecting kubeconfig context %q: %w", contextName, err) + } + + fmt.Fprintf(out, "Selected kubeconfig context %q (restart the engine if it was running on another).\n", contextName) + + return nil +} + +func writeClusterSetting(name string) error { + path := kurtosisClusterSettingPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("creating Kurtosis data dir: %w", err) + } + + if err := os.WriteFile(path, []byte(name), 0o644); err != nil { + return fmt.Errorf("writing Kurtosis cluster setting: %w", err) + } + + return nil +} diff --git a/pkg/devnet/cluster_test.go b/pkg/devnet/cluster_test.go new file mode 100644 index 00000000..743bd0db --- /dev/null +++ b/pkg/devnet/cluster_test.go @@ -0,0 +1,127 @@ +package devnet + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/client-go/tools/clientcmd" +) + +func TestEnsureCluster(t *testing.T) { + t.Run("empty target is a no-op", func(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + var out bytes.Buffer + require.NoError(t, EnsureCluster("", &out)) + assert.Empty(t, out.String()) + }) + + t.Run("writes the setting when none exists", func(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_DATA_HOME", dir) + + var out bytes.Buffer + require.NoError(t, EnsureCluster("bruno", &out)) + + data, err := os.ReadFile(filepath.Join(dir, "kurtosis", "cluster-setting")) + require.NoError(t, err) + assert.Equal(t, "bruno", string(data)) + assert.Contains(t, out.String(), "Set Kurtosis cluster") + }) + + t.Run("accepts a matching setting", func(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_DATA_HOME", dir) + require.NoError(t, EnsureCluster("bruno", &bytes.Buffer{})) + + var out bytes.Buffer + require.NoError(t, EnsureCluster("bruno", &out)) + assert.Contains(t, out.String(), "Kurtosis cluster: bruno") + }) + + t.Run("refuses to switch a different active cluster", func(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_DATA_HOME", dir) + require.NoError(t, EnsureCluster("docker", &bytes.Buffer{})) + + err := EnsureCluster("bruno", &bytes.Buffer{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "kurtosis cluster set bruno") + }) +} + +func TestEnsureKubeContext(t *testing.T) { + writeKubeconfig := func(t *testing.T, current string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "kubeconfig") + content := `apiVersion: v1 +kind: Config +current-context: ` + current + ` +clusters: +- name: c-a + cluster: {server: https://a.example} +- name: c-b + cluster: {server: https://b.example} +contexts: +- name: ctx-a + context: {cluster: c-a, user: u} +- name: ctx-b + context: {cluster: c-b, user: u} +users: +- name: u + user: {token: t} +` + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + return path + } + + currentContext := func(t *testing.T, path string) string { + t.Helper() + cfg, err := clientcmd.LoadFromFile(path) + require.NoError(t, err) + + return cfg.CurrentContext + } + + t.Run("empty context is a no-op", func(t *testing.T) { + path := writeKubeconfig(t, "ctx-a") + t.Setenv("KUBECONFIG", path) + + require.NoError(t, EnsureKubeContext("", &bytes.Buffer{})) + assert.Equal(t, "ctx-a", currentContext(t, path)) + }) + + t.Run("switches the current context", func(t *testing.T) { + path := writeKubeconfig(t, "ctx-a") + t.Setenv("KUBECONFIG", path) + + var out bytes.Buffer + require.NoError(t, EnsureKubeContext("ctx-b", &out)) + assert.Equal(t, "ctx-b", currentContext(t, path)) + assert.Contains(t, out.String(), "ctx-b") + }) + + t.Run("already-current context is a quiet no-op", func(t *testing.T) { + path := writeKubeconfig(t, "ctx-b") + t.Setenv("KUBECONFIG", path) + + var out bytes.Buffer + require.NoError(t, EnsureKubeContext("ctx-b", &out)) + assert.Equal(t, "ctx-b", currentContext(t, path)) + assert.Empty(t, out.String()) + }) + + t.Run("errors on an unknown context", func(t *testing.T) { + path := writeKubeconfig(t, "ctx-a") + t.Setenv("KUBECONFIG", path) + + err := EnsureKubeContext("ctx-missing", &bytes.Buffer{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no context") + }) +} diff --git a/pkg/devnet/devnet.go b/pkg/devnet/devnet.go new file mode 100644 index 00000000..b37ba225 --- /dev/null +++ b/pkg/devnet/devnet.go @@ -0,0 +1,258 @@ +// Package devnet drives Kurtosis enclaves running the ethpandaops +// ethereum-package, so client developers can spin up multi-client devnets +// with a single command. +// +// It talks to a Kurtosis engine over the engine's gRPC API via the Kurtosis +// Go SDK. On a Kubernetes backend the engine runs in-cluster and is reached +// on localhost through `kurtosis gateway`; on a Docker backend it is the +// local engine. Either way the connection point is the same. +package devnet + +import ( + "context" + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/kurtosis-tech/kurtosis/api/golang/core/kurtosis_core_rpc_api_bindings" + "github.com/kurtosis-tech/kurtosis/api/golang/core/lib/starlark_run_config" + "github.com/kurtosis-tech/kurtosis/api/golang/engine/kurtosis_engine_rpc_api_bindings" + "github.com/kurtosis-tech/kurtosis/api/golang/engine/lib/kurtosis_context" + "gopkg.in/yaml.v3" +) + +// DefaultPackage is the Kurtosis package launched by `panda devnet up` when no +// other package is specified. +const DefaultPackage = "github.com/ethpandaops/ethereum-package" + +// Client wraps a connection to a Kurtosis engine. +type Client struct { + kurtosis *kurtosis_context.KurtosisContext +} + +// NewClient connects to the local Kurtosis engine. On a Kubernetes backend the +// engine is exposed on localhost by `kurtosis gateway`, which must be running. +func NewClient() (*Client, error) { + kurtosisCtx, err := kurtosis_context.NewKurtosisContextFromLocalEngine() + if err != nil { + return nil, fmt.Errorf("connecting to the Kurtosis engine: %w\n"+ + "Is the engine running (`kurtosis engine status`)? On a Kubernetes backend, "+ + "`kurtosis gateway` must also be running in another terminal", err) + } + + return &Client{kurtosis: kurtosisCtx}, nil +} + +// UpOptions configures a devnet launch. +type UpOptions struct { + // EnclaveName is the enclave to create. Empty lets Kurtosis generate one. + EnclaveName string + // Package overrides the package to run. Empty uses DefaultPackage. + Package string + // SerializedArgs is the package args as JSON or YAML. Empty means "{}". + SerializedArgs string + // AlwaysPull forces re-pulling images (ImageDownloadMode=always). Use for + // devnet branches whose tags are mutable. + AlwaysPull bool + // DryRun validates and plans the run without applying it. + DryRun bool + // DockerCacheURL routes every package image through a pull-through registry + // cache at this host (e.g. "docker.ethquokkaops.io") by injecting + // docker_cache_params into the args. Empty disables it. An explicit + // docker_cache_params already present in the args is left untouched. + DockerCacheURL string +} + +// Up creates an enclave and runs the package in it, streaming progress to out. +// It returns the (possibly generated) enclave name even on failure, so callers +// can surface or clean up the partial enclave. +func (c *Client) Up(ctx context.Context, opts UpOptions, out io.Writer) (string, error) { + pkg := opts.Package + if pkg == "" { + pkg = DefaultPackage + } + + // Build the args before creating the enclave so a bad config fails fast + // without leaving a dangling enclave behind. + args := strings.TrimSpace(opts.SerializedArgs) + if args == "" { + args = "{}" + } + if opts.DockerCacheURL != "" { + merged, err := injectDockerCache(args, opts.DockerCacheURL) + if err != nil { + return opts.EnclaveName, err + } + args = merged + } + + enclaveCtx, err := c.kurtosis.CreateEnclave(ctx, opts.EnclaveName) + if err != nil { + return opts.EnclaveName, fmt.Errorf("creating enclave: %w", err) + } + enclaveName := enclaveCtx.GetEnclaveName() + + downloadMode := kurtosis_core_rpc_api_bindings.ImageDownloadMode_missing + if opts.AlwaysPull { + downloadMode = kurtosis_core_rpc_api_bindings.ImageDownloadMode_always + } + + runConfig := starlark_run_config.NewRunStarlarkConfig( + starlark_run_config.WithSerializedParams(args), + starlark_run_config.WithImageDownloadMode(downloadMode), + starlark_run_config.WithDryRun(opts.DryRun), + ) + + lineChan, cancelFunc, err := enclaveCtx.RunStarlarkRemotePackage(ctx, pkg, runConfig) + if err != nil { + return enclaveName, fmt.Errorf("starting package %q: %w", pkg, err) + } + defer cancelFunc() + + if err := streamRun(out, lineChan); err != nil { + return enclaveName, err + } + + return enclaveName, nil +} + +// streamRun consumes the Starlark response stream, printing human-readable +// progress and returning the first error encountered (or a generic error if +// the run finishes unsuccessfully without an explicit error line). +func streamRun(out io.Writer, lineChan <-chan *kurtosis_core_rpc_api_bindings.StarlarkRunResponseLine) error { + var runErr error + + for line := range lineChan { + switch { + case line.GetInfo() != nil: + fmt.Fprintln(out, line.GetInfo().GetInfoMessage()) + case line.GetWarning() != nil: + fmt.Fprintf(out, "WARN: %s\n", line.GetWarning().GetWarningMessage()) + case line.GetInstructionResult() != nil: + if result := strings.TrimSpace(line.GetInstructionResult().GetSerializedInstructionResult()); result != "" { + fmt.Fprintln(out, result) + } + case line.GetError() != nil: + if runErr == nil { + runErr = starlarkError(line.GetError()) + } + case line.GetRunFinishedEvent() != nil: + if !line.GetRunFinishedEvent().GetIsRunSuccessful() && runErr == nil { + runErr = fmt.Errorf("package run finished unsuccessfully") + } + } + } + + return runErr +} + +func starlarkError(e *kurtosis_core_rpc_api_bindings.StarlarkError) error { + switch { + case e.GetInterpretationError() != nil: + return fmt.Errorf("interpretation error: %s", e.GetInterpretationError().GetErrorMessage()) + case e.GetValidationError() != nil: + return fmt.Errorf("validation error: %s", e.GetValidationError().GetErrorMessage()) + case e.GetExecutionError() != nil: + return fmt.Errorf("execution error: %s", e.GetExecutionError().GetErrorMessage()) + default: + return fmt.Errorf("unknown Starlark error") + } +} + +// injectDockerCache merges docker_cache_params into the serialized package args +// so every image is pulled through the cache host. The input may be JSON or +// YAML (JSON is valid YAML); the output is YAML. An explicit docker_cache_params +// already present in the args is preserved. +func injectDockerCache(serializedArgs, cacheURL string) (string, error) { + params := map[string]interface{}{} + if err := yaml.Unmarshal([]byte(serializedArgs), ¶ms); err != nil { + return "", fmt.Errorf("parsing args to inject docker cache: %w", err) + } + + if _, ok := params["docker_cache_params"]; !ok { + params["docker_cache_params"] = map[string]interface{}{ + "enabled": true, + "url": cacheURL, + } + } + + out, err := yaml.Marshal(params) + if err != nil { + return "", fmt.Errorf("serializing args with docker cache: %w", err) + } + + return string(out), nil +} + +// Enclave is a flattened view of a Kurtosis enclave for listing and inspection. +type Enclave struct { + Name string `json:"name"` + UUID string `json:"uuid"` + Status string `json:"status"` + APIContainer string `json:"api_container"` + CreationTime time.Time `json:"creation_time"` +} + +// List returns all enclaves known to the engine, newest first. +func (c *Client) List(ctx context.Context) ([]Enclave, error) { + enclaves, err := c.kurtosis.GetEnclaves(ctx) + if err != nil { + return nil, fmt.Errorf("listing enclaves: %w", err) + } + + var out []Enclave + for _, info := range enclaves.GetEnclavesByUuid() { + out = append(out, toEnclave(info)) + } + + sort.Slice(out, func(i, j int) bool { + return out[i].CreationTime.After(out[j].CreationTime) + }) + + return out, nil +} + +// Inspect returns a single enclave by name or UUID. +func (c *Client) Inspect(ctx context.Context, identifier string) (Enclave, error) { + info, err := c.kurtosis.GetEnclave(ctx, identifier) + if err != nil { + return Enclave{}, fmt.Errorf("inspecting enclave %q: %w", identifier, err) + } + + return toEnclave(info), nil +} + +// Down destroys an enclave by name or UUID, tearing down its namespace/pods. +func (c *Client) Down(ctx context.Context, identifier string) error { + if err := c.kurtosis.DestroyEnclave(ctx, identifier); err != nil { + return fmt.Errorf("destroying enclave %q: %w", identifier, err) + } + + return nil +} + +func toEnclave(info *kurtosis_engine_rpc_api_bindings.EnclaveInfo) Enclave { + e := Enclave{ + Name: info.GetName(), + UUID: info.GetEnclaveUuid(), + Status: cleanStatus(info.GetContainersStatus().String()), + APIContainer: cleanStatus(info.GetApiContainerStatus().String()), + } + if t := info.GetCreationTime(); t != nil { + e.CreationTime = t.AsTime() + } + + return e +} + +// cleanStatus strips the proto enum type prefix (e.g. +// "EnclaveContainersStatus_RUNNING" -> "RUNNING"). +func cleanStatus(s string) string { + if i := strings.IndexByte(s, '_'); i >= 0 { + return s[i+1:] + } + + return s +} diff --git a/pkg/devnet/devnet_test.go b/pkg/devnet/devnet_test.go new file mode 100644 index 00000000..facc384f --- /dev/null +++ b/pkg/devnet/devnet_test.go @@ -0,0 +1,57 @@ +package devnet + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestInjectDockerCache(t *testing.T) { + t.Run("injects into YAML args", func(t *testing.T) { + in := "participants:\n - el_type: geth\n" + + out, err := injectDockerCache(in, "docker.ethquokkaops.io") + require.NoError(t, err) + + var got map[string]interface{} + require.NoError(t, yaml.Unmarshal([]byte(out), &got)) + + assert.Contains(t, got, "participants") + cache, ok := got["docker_cache_params"].(map[string]interface{}) + require.True(t, ok, "docker_cache_params should be a map") + assert.Equal(t, true, cache["enabled"]) + assert.Equal(t, "docker.ethquokkaops.io", cache["url"]) + }) + + t.Run("injects into JSON args", func(t *testing.T) { + out, err := injectDockerCache(`{"participants": []}`, "cache.example") + require.NoError(t, err) + + var got map[string]interface{} + require.NoError(t, yaml.Unmarshal([]byte(out), &got)) + + cache, ok := got["docker_cache_params"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "cache.example", cache["url"]) + }) + + t.Run("preserves an explicit docker_cache_params", func(t *testing.T) { + in := "docker_cache_params:\n enabled: true\n url: mine.example\n" + + out, err := injectDockerCache(in, "docker.ethquokkaops.io") + require.NoError(t, err) + + var got map[string]interface{} + require.NoError(t, yaml.Unmarshal([]byte(out), &got)) + + cache := got["docker_cache_params"].(map[string]interface{}) + assert.Equal(t, "mine.example", cache["url"], "user value must win") + }) + + t.Run("rejects malformed args", func(t *testing.T) { + _, err := injectDockerCache("\t not: : valid", "cache.example") + assert.Error(t, err) + }) +} diff --git a/pkg/server/builder.go b/pkg/server/builder.go index 73ebe02f..70c17f97 100644 --- a/pkg/server/builder.go +++ b/pkg/server/builder.go @@ -169,6 +169,8 @@ func (b *Builder) Build(ctx context.Context) (Service, error) { return NewService( b.log, b.cfg.Server, + b.cfg.Cluster, + b.cfg.Devnet, toolReg, resourceReg, searchSvc, diff --git a/pkg/server/operations_devnet.go b/pkg/server/operations_devnet.go new file mode 100644 index 00000000..f8b2e782 --- /dev/null +++ b/pkg/server/operations_devnet.go @@ -0,0 +1,234 @@ +package server + +import ( + "bytes" + "net/http" + + "github.com/ethpandaops/panda/pkg/devnet" + "github.com/ethpandaops/panda/pkg/operations" +) + +// handleDevnetOperation dispatches devnet.* operations. Unlike the datasource +// operations these run locally on the server (which holds the Kurtosis engine +// connection) rather than being proxied upstream. +// +// The caller's identity is derived server-side from the authenticated request +// (authOwnerID), never from client-supplied args — so when this moves behind +// the cloud proxy in production, enclave ownership and authorization are +// enforced on identity the client cannot forge. +func (s *service) handleDevnetOperation(operationID string, w http.ResponseWriter, r *http.Request) bool { + switch operationID { + case "devnet.up": + s.handleDevnetUp(w, r) + case "devnet.ls": + s.handleDevnetLs(w, r) + case "devnet.inspect": + s.handleDevnetInspect(w, r) + case "devnet.down": + s.handleDevnetDown(w, r) + default: + return false + } + + return true +} + +// devnetClient connects to the local Kurtosis engine after pointing Kurtosis at +// the configured cluster: it selects the kubeconfig context (so Kurtosis targets +// the right cluster) and activates the Kurtosis cluster. The connection is lazy +// (per operation): these are infrequent, long-running commands, so a fresh gRPC +// handshake is cheap relative to the work. +func (s *service) devnetClient(out *bytes.Buffer) (*devnet.Client, error) { + if err := devnet.EnsureKubeContext(s.clusterCfg.KubeconfigContext, out); err != nil { + return nil, err + } + + if err := devnet.EnsureCluster(s.clusterCfg.Name, out); err != nil { + return nil, err + } + + return devnet.NewClient() +} + +func (s *service) handleDevnetUp(w http.ResponseWriter, r *http.Request) { + req, err := decodeOperationRequest(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + owner := authOwnerID(r) + + pkg := optionalStringArg(req.Args, "package") + if pkg == "" { + pkg = s.devnetCfg.Package + } + + // docker_cache: an explicit (even empty) arg overrides the configured + // default; otherwise fall back to config. + dockerCache := s.devnetCfg.DockerCache + if _, ok := req.Args["docker_cache"]; ok { + dockerCache = optionalStringArg(req.Args, "docker_cache") + } + + alwaysPull, err := optionalBoolArg(req.Args, "always_pull") + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + dryRun, err := optionalBoolArg(req.Args, "dry_run") + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + var out bytes.Buffer + client, err := s.devnetClient(&out) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + + s.log.WithField("owner", owner).WithField("enclave", optionalStringArg(req.Args, "name")). + Info("devnet up requested") + + enclaveName, runErr := client.Up(r.Context(), devnet.UpOptions{ + EnclaveName: optionalStringArg(req.Args, "name"), + Package: pkg, + SerializedArgs: optionalStringArg(req.Args, "args"), + AlwaysPull: boolOrFalse(alwaysPull), + DryRun: boolOrFalse(dryRun), + DockerCacheURL: dockerCache, + }, &out) + + data := map[string]any{ + "enclave": enclaveName, + "output": out.String(), + } + if runErr != nil { + data["error"] = runErr.Error() + writeOperationResponse(s.log, w, http.StatusOK, operations.Response{ + Kind: "devnet.up", + Data: data, + Meta: map[string]any{"success": false}, + }) + return + } + + writeOperationResponse(s.log, w, http.StatusOK, operations.Response{ + Kind: "devnet.up", + Data: data, + Meta: map[string]any{"success": true}, + }) +} + +func (s *service) handleDevnetLs(w http.ResponseWriter, r *http.Request) { + var out bytes.Buffer + client, err := s.devnetClient(&out) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + + enclaves, err := client.List(r.Context()) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + + writeOperationResponse(s.log, w, http.StatusOK, operations.Response{ + Kind: "devnet.ls", + Data: enclaves, + }) +} + +func (s *service) handleDevnetInspect(w http.ResponseWriter, r *http.Request) { + req, err := decodeOperationRequest(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + enclave, err := requiredStringArg(req.Args, "enclave") + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + var out bytes.Buffer + client, err := s.devnetClient(&out) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + + info, err := client.Inspect(r.Context(), enclave) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + + writeOperationResponse(s.log, w, http.StatusOK, operations.Response{ + Kind: "devnet.inspect", + Data: info, + }) +} + +func (s *service) handleDevnetDown(w http.ResponseWriter, r *http.Request) { + req, err := decodeOperationRequest(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + all, err := optionalBoolArg(req.Args, "all") + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + var out bytes.Buffer + client, err := s.devnetClient(&out) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + + var targets []string + if boolOrFalse(all) { + enclaves, listErr := client.List(r.Context()) + if listErr != nil { + writeAPIError(w, http.StatusBadGateway, listErr.Error()) + return + } + for _, e := range enclaves { + targets = append(targets, e.Name) + } + } else { + enclave, reqErr := requiredStringArg(req.Args, "enclave") + if reqErr != nil { + writeAPIError(w, http.StatusBadRequest, "enclave is required, or pass all=true") + return + } + targets = []string{enclave} + } + + destroyed := make([]string, 0, len(targets)) + for _, name := range targets { + if err := client.Down(r.Context(), name); err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + destroyed = append(destroyed, name) + } + + writeOperationResponse(s.log, w, http.StatusOK, operations.Response{ + Kind: "devnet.down", + Data: map[string]any{"destroyed": destroyed}, + }) +} + +func boolOrFalse(b *bool) bool { + return b != nil && *b +} diff --git a/pkg/server/operations_devnet_test.go b/pkg/server/operations_devnet_test.go new file mode 100644 index 00000000..24bf977e --- /dev/null +++ b/pkg/server/operations_devnet_test.go @@ -0,0 +1,85 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/panda/pkg/config" + "github.com/ethpandaops/panda/pkg/devnet" +) + +func newDevnetTestService() *service { + log := logrus.New() + log.SetOutput(testWriter{}) + + return &service{ + log: log, + devnetCfg: config.DevnetConfig{}, + } +} + +// testWriter discards log output during tests. +type testWriter struct{} + +func (testWriter) Write(p []byte) (int, error) { return len(p), nil } + +func TestHandleDevnetOperation_UnknownIsNotHandled(t *testing.T) { + s := newDevnetTestService() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/operations/devnet.bogus", strings.NewReader(`{"args":{}}`)) + + handled := s.handleDevnetOperation("devnet.bogus", w, r) + assert.False(t, handled, "unknown devnet operation must fall through to the 404 path") +} + +func TestHandleDevnetOperation_DownRequiresTarget(t *testing.T) { + s := newDevnetTestService() + w := httptest.NewRecorder() + // No enclave and no all=true — must be a 400 before any engine call. + r := httptest.NewRequest(http.MethodPost, "/api/v1/operations/devnet.down", strings.NewReader(`{"args":{}}`)) + + handled := s.handleDevnetOperation("devnet.down", w, r) + require.True(t, handled) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestHandleDevnetOperation_InspectRequiresEnclave(t *testing.T) { + s := newDevnetTestService() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/operations/devnet.inspect", strings.NewReader(`{"args":{}}`)) + + handled := s.handleDevnetOperation("devnet.inspect", w, r) + require.True(t, handled) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// TestHandleDevnetLs_Integration exercises the full handler path against a live +// Kurtosis engine (decode → connect → list → JSON response). It skips when no +// engine/gateway is reachable, so it is safe in CI but validates locally. +func TestHandleDevnetLs_Integration(t *testing.T) { + s := newDevnetTestService() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/operations/devnet.ls", strings.NewReader(`{"args":{}}`)) + + require.True(t, s.handleDevnetOperation("devnet.ls", w, r)) + + if w.Code != http.StatusOK { + t.Skipf("Kurtosis engine not reachable (status %d) — skipping integration check: %s", w.Code, strings.TrimSpace(w.Body.String())) + } + + var resp struct { + Kind string `json:"kind"` + Data []devnet.Enclave `json:"data"` + Meta map[string]any `json:"meta"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "devnet.ls", resp.Kind) + // Data may be empty (no enclaves) — the point is it decoded as the enclave list. +} diff --git a/pkg/server/operations_dispatch.go b/pkg/server/operations_dispatch.go index d831d721..cf773467 100644 --- a/pkg/server/operations_dispatch.go +++ b/pkg/server/operations_dispatch.go @@ -21,6 +21,7 @@ func (s *service) dispatchOperation(operationID string, w http.ResponseWriter, r s.handleTracoorOperation, s.handleSpecsOperation, s.handleBlockArchiveOperation, + s.handleDevnetOperation, } { if handler(operationID, w, r) { return true diff --git a/pkg/server/server.go b/pkg/server/server.go index 7950d7dc..07a60311 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -43,6 +43,8 @@ type Service interface { type service struct { log logrus.FieldLogger cfg config.ServerConfig + clusterCfg config.ClusterConfig + devnetCfg config.DevnetConfig toolRegistry tool.Registry resourceRegistry resource.Registry searchService *searchsvc.Service @@ -72,6 +74,8 @@ type service struct { func NewService( log logrus.FieldLogger, cfg config.ServerConfig, + clusterCfg config.ClusterConfig, + devnetCfg config.DevnetConfig, toolRegistry tool.Registry, resourceRegistry resource.Registry, searchSvc *searchsvc.Service, @@ -88,6 +92,8 @@ func NewService( return &service{ log: log.WithField("component", "server"), cfg: cfg, + clusterCfg: clusterCfg, + devnetCfg: devnetCfg, toolRegistry: toolRegistry, resourceRegistry: resourceRegistry, searchService: searchSvc, From 834ae3038b65d5a8ec45c09b3133b9979141cb87 Mon Sep 17 00:00:00 2001 From: qu0b Date: Fri, 12 Jun 2026 13:12:05 +0000 Subject: [PATCH 02/14] test(devnet): add guarded live lifecycle test against a real engine TestDevnetLifecycle_Live drives a real up -> ls -> inspect -> down through the server handlers against a live Kurtosis engine on the configured cluster. It is heavyweight (spins up a devnet) so it is skipped unless PANDA_DEVNET_LIVE=1. Verified green against the local bruno k3s cluster (215s, zero stale namespaces/PVs/PVCs after teardown). Co-Authored-By: Claude Opus 4.8 --- pkg/server/operations_devnet_test.go | 79 ++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/pkg/server/operations_devnet_test.go b/pkg/server/operations_devnet_test.go index 24bf977e..4eeb75e4 100644 --- a/pkg/server/operations_devnet_test.go +++ b/pkg/server/operations_devnet_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "strings" "testing" @@ -83,3 +84,81 @@ func TestHandleDevnetLs_Integration(t *testing.T) { assert.Equal(t, "devnet.ls", resp.Kind) // Data may be empty (no enclaves) — the point is it decoded as the enclave list. } + +// callDevnet invokes a devnet operation handler with the given args and returns +// the recorder. +func callDevnet(s *service, op string, args map[string]any) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]any{"args": args}) + r := httptest.NewRequest(http.MethodPost, "/api/v1/operations/"+op, strings.NewReader(string(body))) + w := httptest.NewRecorder() + s.handleDevnetOperation(op, w, r) + + return w +} + +// TestDevnetLifecycle_Live runs a real up → ls → inspect → down against a live +// Kurtosis engine on the configured cluster. It is heavyweight (spins up a +// devnet, ~minutes) so it only runs when PANDA_DEVNET_LIVE=1. +// +// PANDA_DEVNET_LIVE=1 go test ./pkg/server/ -run TestDevnetLifecycle_Live -timeout 600s -v +func TestDevnetLifecycle_Live(t *testing.T) { + if os.Getenv("PANDA_DEVNET_LIVE") == "" { + t.Skip("set PANDA_DEVNET_LIVE=1 to run the live devnet lifecycle test") + } + + s := newDevnetTestService() + s.clusterCfg = config.ClusterConfig{Name: "bruno", KubeconfigContext: "bruno"} + s.devnetCfg = config.DevnetConfig{DockerCache: "docker.ethquokkaops.io"} + + const enclave = "panda-live-test" + params := "participants:\n - el_type: geth\n cl_type: lighthouse\n count: 1\nadditional_services: []\n" + + // Best-effort cleanup of any prior run, and on exit. + _ = callDevnet(s, "devnet.down", map[string]any{"enclave": enclave}) + t.Cleanup(func() { _ = callDevnet(s, "devnet.down", map[string]any{"enclave": enclave}) }) + + t.Log("up (real devnet, this takes a few minutes)…") + w := callDevnet(s, "devnet.up", map[string]any{"name": enclave, "args": params}) + require.Equal(t, http.StatusOK, w.Code, "up failed: %s", w.Body.String()) + + var up struct { + Data struct { + Enclave string `json:"enclave"` + Error string `json:"error"` + } `json:"data"` + Meta map[string]any `json:"meta"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &up)) + require.Empty(t, up.Data.Error, "up returned a run error") + require.Equal(t, enclave, up.Data.Enclave) + assert.Equal(t, true, up.Meta["success"]) + + // ls — the enclave should be listed. + w = callDevnet(s, "devnet.ls", map[string]any{}) + require.Equal(t, http.StatusOK, w.Code) + var ls struct { + Data []devnet.Enclave `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &ls)) + found := false + for _, e := range ls.Data { + if e.Name == enclave { + found = true + } + } + assert.True(t, found, "enclave not found in ls") + + // inspect. + w = callDevnet(s, "devnet.inspect", map[string]any{"enclave": enclave}) + require.Equal(t, http.StatusOK, w.Code) + var insp struct { + Data devnet.Enclave `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &insp)) + assert.Equal(t, enclave, insp.Data.Name) + + // down. + w = callDevnet(s, "devnet.down", map[string]any{"enclave": enclave}) + require.Equal(t, http.StatusOK, w.Code, "down failed: %s", w.Body.String()) + t.Log("lifecycle ok: up → ls → inspect → down") +} From f2cbbffeeccebe3a650309f55b4919ff6ae5e7c7 Mon Sep 17 00:00:00 2001 From: qu0b Date: Fri, 12 Jun 2026 13:44:51 +0000 Subject: [PATCH 03/14] =?UTF-8?q?feat(server):=20lean=20dev=20mode=20?= =?UTF-8?q?=E2=80=94=20run=20without=20the=20sandbox=20or=20cloud=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let the panda server boot for local development against features that don't need the production infrastructure (e.g. `panda devnet`). Previously the server hard-required a code-execution sandbox and a reachable credential proxy at startup, so a developer couldn't run it locally without that whole stack. Now four production-only dependencies degrade gracefully: - sandbox.backend: none -> no-op sandbox; code execution disabled (the sandbox is for running Python, not devnets). sandbox.image no longer required. - proxy.optional: true -> an unreachable proxy at startup is a warning, not fatal; datasource features wait for background refresh. - cartographoor startup failure is non-fatal (best-effort network metadata). - semantic search degrades to disabled when the proxy embedding is unavailable (the search service already guards nil indices). Production behaviour is unchanged: without proxy.optional the proxy and search remain fatal, and the default sandbox backend is still docker. Adds config.dev.yaml — a lean local-dev config (no sandbox, proxy optional, bruno cluster) for running `panda server` + `panda devnet` directly. Verified: the server boots with no sandbox/proxy and a full CLI up/ls/inspect/down against the local bruno k3s cluster works end to end with zero stale resources. Co-Authored-By: Claude Opus 4.8 --- config.dev.yaml | 31 +++++++++++++++++++++++++++++ pkg/app/app.go | 21 +++++++++++++------- pkg/config/config.go | 10 +++++++++- pkg/sandbox/noop.go | 44 ++++++++++++++++++++++++++++++++++++++++++ pkg/sandbox/sandbox.go | 7 +++++++ pkg/server/builder.go | 13 +++++++++++-- 6 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 config.dev.yaml create mode 100644 pkg/sandbox/noop.go diff --git a/config.dev.yaml b/config.dev.yaml new file mode 100644 index 00000000..40adf489 --- /dev/null +++ b/config.dev.yaml @@ -0,0 +1,31 @@ +# Lean local-dev panda config — runs the server on the host for `panda devnet` +# work, without the production code-execution sandbox or the cloud proxy. +# +# panda-server serve --config config.dev.yaml # (or: go run ./cmd/server serve --config config.dev.yaml) +# panda devnet ls --config config.dev.yaml + +server: + host: "127.0.0.1" + port: 2480 + url: "http://localhost:2480" + +# Sandbox is panda's code-execution backend — not needed for devnets. +sandbox: + backend: none + +# No credential proxy in local dev. `optional: true` lets the server boot even +# though nothing is listening (the default url is unreachable). +proxy: + optional: true + +# The single Kubernetes cluster to use. Switch rails by editing this block. +cluster: + name: bruno + kubeconfig_context: bruno + +devnet: + package: github.com/ethpandaops/ethereum-package + docker_cache: docker.ethquokkaops.io + +observability: + metrics_enabled: false diff --git a/pkg/app/app.go b/pkg/app/app.go index 61a63a2c..cc292b40 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -120,13 +120,19 @@ func (a *App) Build(ctx context.Context) error { } if err := proxyClient.Start(ctx); err != nil { - _ = a.stop(ctx) + if !a.cfg.Proxy.Optional { + _ = a.stop(ctx) + + return fmt.Errorf("starting proxy client: %w", err) + } - return fmt.Errorf("starting proxy client: %w", err) + a.log.WithError(err).Warn("Proxy unreachable at startup; continuing without it (proxy.optional=true). " + + "Datasource-backed features are unavailable until the proxy is reachable; background refresh will retry.") + } else { + a.log.WithField("url", proxyClient.URL()).Info("Proxy client connected") } a.ProxyClient = proxyClient - a.log.WithField("url", proxyClient.URL()).Info("Proxy client connected") // 4. Initialize modules. if err := a.initModules(proxyClient); err != nil { @@ -154,13 +160,14 @@ func (a *App) Build(ctx context.Context) error { }) if err := cartographoorClient.Start(ctx); err != nil { - _ = a.stop(ctx) - - return fmt.Errorf("starting cartographoor client: %w", err) + // Cartographoor is best-effort network metadata (with its own caching and + // retry); a startup failure shouldn't take the whole server down. + a.log.WithError(err).Warn("Cartographoor client startup failed; continuing without it") + } else { + a.log.Info("Cartographoor client started") } a.Cartographoor = cartographoorClient - a.log.Info("Cartographoor client started") // 7. Inject cartographoor client into modules. a.injectCartographoorClient() diff --git a/pkg/config/config.go b/pkg/config/config.go index 46543ba7..c46f93ed 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -156,6 +156,12 @@ type ProxyConfig struct { // Auth configures authentication for the proxy. // Optional - if not set, the proxy must allow unauthenticated access. Auth *ProxyAuthConfig `yaml:"auth,omitempty"` + + // Optional makes an unreachable proxy non-fatal at startup: the server logs + // a warning and continues without it (datasource-backed features are + // unavailable until the proxy is reachable; background refresh retries). + // Useful for a lean local-dev server that doesn't need the credential proxy. + Optional bool `yaml:"optional,omitempty"` } // ProxyAuthConfig configures authentication for the proxy. @@ -534,7 +540,9 @@ const MaxSandboxTimeout = 7_776_000 // Validate validates the configuration. func (c *Config) Validate() error { - if c.Sandbox.Image == "" { + // A disabled sandbox ("none") needs no image — lean dev servers run without + // code execution. + if c.Sandbox.Backend != "none" && c.Sandbox.Image == "" { return errors.New("sandbox.image is required") } diff --git a/pkg/sandbox/noop.go b/pkg/sandbox/noop.go new file mode 100644 index 00000000..a0302820 --- /dev/null +++ b/pkg/sandbox/noop.go @@ -0,0 +1,44 @@ +package sandbox + +import ( + "context" + "errors" +) + +// errDisabled is returned by the no-op backend's execution methods. +var errDisabled = errors.New("sandbox is disabled (sandbox.backend: none); code execution is unavailable") + +// noopBackend is a sandbox backend that does nothing. It lets the server run +// without a code-execution sandbox — useful for a lean local-dev server focused +// on features that don't need code execution (e.g. `panda devnet`). Execution +// and session methods fail cleanly rather than panicking. +type noopBackend struct{} + +// NewNoopBackend returns a disabled sandbox backend. +func NewNoopBackend() Service { return &noopBackend{} } + +func (b *noopBackend) Start(context.Context) error { return nil } +func (b *noopBackend) Stop(context.Context) error { return nil } +func (b *noopBackend) Name() string { return "none" } + +func (b *noopBackend) Execute(context.Context, ExecuteRequest) (*ExecutionResult, error) { + return nil, errDisabled +} + +func (b *noopBackend) ListSessions(context.Context, string) ([]SessionInfo, error) { + return nil, errDisabled +} + +func (b *noopBackend) CreateSession(context.Context, string, map[string]string) (string, error) { + return "", errDisabled +} + +func (b *noopBackend) DestroySession(context.Context, string, string) error { + return errDisabled +} + +func (b *noopBackend) CanCreateSession(context.Context, string) (bool, int, int) { + return false, 0, 0 +} + +func (b *noopBackend) SessionsEnabled() bool { return false } diff --git a/pkg/sandbox/sandbox.go b/pkg/sandbox/sandbox.go index 9a4f1d9e..ea10350c 100644 --- a/pkg/sandbox/sandbox.go +++ b/pkg/sandbox/sandbox.go @@ -119,6 +119,9 @@ const ( BackendDocker BackendType = "docker" // BackendGVisor uses Docker with gVisor runtime for enhanced isolation. BackendGVisor BackendType = "gvisor" + // BackendNone disables the sandbox entirely (no code execution). Useful for + // a lean dev server running only features that don't need a sandbox. + BackendNone BackendType = "none" ) // New creates a new sandbox service based on the configuration. @@ -130,6 +133,10 @@ func New(cfg config.SandboxConfig, log logrus.FieldLogger) (Service, error) { return NewDockerBackend(cfg, log) case BackendGVisor: return NewGVisorBackend(cfg, log) + case BackendNone: + log.Warn("Sandbox backend is 'none' — code execution is disabled") + + return NewNoopBackend(), nil default: return nil, fmt.Errorf("unsupported sandbox backend: %s", cfg.Backend) } diff --git a/pkg/server/builder.go b/pkg/server/builder.go index 70c17f97..821d8f18 100644 --- a/pkg/server/builder.go +++ b/pkg/server/builder.go @@ -62,8 +62,17 @@ func (b *Builder) Build(ctx context.Context) (Service, error) { searchRuntime, err := searchruntime.Build(ctx, b.log, application.ModuleRegistry, application.ProxyClient, b.cfg.Storage.CacheDir, b.cfg.ConsensusSpecs) if err != nil { - _ = application.Stop(ctx) - return nil, fmt.Errorf("building search runtime: %w", err) + // Semantic search needs the proxy's embedding service. When the proxy is + // optional (lean dev server), degrade to an empty runtime — search + // endpoints return gracefully (the search service guards nil indices) + // rather than taking the server down. + if !b.cfg.Proxy.Optional { + _ = application.Stop(ctx) + return nil, fmt.Errorf("building search runtime: %w", err) + } + + b.log.WithError(err).Warn("Search runtime unavailable; semantic search disabled (proxy.optional=true)") + searchRuntime = &searchruntime.Runtime{} } // searchruntime returns concrete typed pointers that are nil when semantic From 316823d6ee46b5b4700bb84467441e79b88e6eb2 Mon Sep 17 00:00:00 2001 From: qu0b Date: Sun, 14 Jun 2026 00:37:54 +0000 Subject: [PATCH 04/14] feat(devnet): add 'services' and 'logs' commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'panda devnet services ' (list running services) and 'panda devnet logs [service...] [--tail N]' so devnet logs can be read through the panda server without shelling out to the kurtosis CLI or a local gateway — and thus through the cloud proxy when remote. Logs are read straight from the service pods via the Kubernetes API (the server already holds the kubeconfig). This fork ships container logs to OTel/ClickHouse, which leaves the engine's file-based log API empty, so the SDK GetServiceLogs path returns nothing; raw pod logs are always available and need no aggregator. The enclave namespace is resolved by the kurtosistech.com/enclave-id label (pool-claimed enclaves keep the idle enclave's kt-idle-enclave- namespace). Non-following by design so it rides the plain request/response operation path. Co-Authored-By: Claude Opus 4.8 --- docs/devnet.md | 15 ++++- go.mod | 9 ++- go.sum | 5 ++ pkg/cli/devnet.go | 99 +++++++++++++++++++++++++++++- pkg/devnet/devnet.go | 89 +++++++++++++++++++++++++++ pkg/devnet/logs.go | 104 ++++++++++++++++++++++++++++++++ pkg/server/operations_devnet.go | 83 +++++++++++++++++++++++++ 7 files changed, 397 insertions(+), 7 deletions(-) create mode 100644 pkg/devnet/logs.go diff --git a/docs/devnet.md b/docs/devnet.md index 795bc13d..67bb423f 100644 --- a/docs/devnet.md +++ b/docs/devnet.md @@ -87,14 +87,23 @@ Add a `cloud` entry the same way for the cloud rail. panda devnet up my-devnet --args ./network_params.yaml panda devnet ls panda devnet inspect my-devnet +panda devnet services my-devnet # list service names +panda devnet logs my-devnet # recent logs, all services +panda devnet logs my-devnet el-1-geth-lighthouse --tail 500 panda devnet down my-devnet # or: panda devnet down --all ``` -Debug a running devnet with the `kurtosis` CLI directly — the server already -points it at the right backend: +`services` and `logs` go through the panda server (which holds the cluster +connection), so they work wherever `panda devnet ls` works — including remotely +through the cloud proxy — without needing the `kurtosis` CLI or a gateway +locally. Logs are read straight from the pods, so they're available even though +this fork ships container logs to OTel/ClickHouse (which leaves the engine's +log API empty). + +For live following, use the `kurtosis` CLI directly (the server already points +it at the right backend): ```bash -kurtosis enclave inspect my-devnet kurtosis service logs my-devnet el-1-geth-lighthouse -f ``` diff --git a/go.mod b/go.mod index 00ef78cf..2cf50f30 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,8 @@ require ( golang.org/x/time v0.15.0 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.36.2 + k8s.io/apimachinery v0.36.2 k8s.io/client-go v0.36.2 ) @@ -59,6 +61,7 @@ require ( github.com/docker/go-connections v0.7.0 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/ebitengine/purego v0.10.0 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect @@ -72,12 +75,15 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/go-test/deep v1.1.0 // indirect github.com/go-yaml/yaml v2.1.0+incompatible // indirect github.com/gofiber/fiber/v3 v3.1.0 // indirect github.com/gofiber/schema v1.7.0 // indirect github.com/gofiber/utils/v2 v2.0.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -155,6 +161,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect @@ -171,9 +178,9 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/grpc v1.81.0 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/apimachinery v0.36.2 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect diff --git a/go.sum b/go.sum index 211be99a..1915473d 100644 --- a/go.sum +++ b/go.sum @@ -71,6 +71,7 @@ github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26 github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -138,10 +139,12 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/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.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= 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-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= @@ -227,6 +230,7 @@ github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQe github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -253,6 +257,7 @@ github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZD github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0= diff --git a/pkg/cli/devnet.go b/pkg/cli/devnet.go index 8f4e9ce3..25ef16f6 100644 --- a/pkg/cli/devnet.go +++ b/pkg/cli/devnet.go @@ -20,6 +20,7 @@ var ( devnetDryRun bool devnetDockerCache string devnetDownAll bool + devnetLogsTail int ) var devnetCmd = &cobra.Command{ @@ -55,6 +56,8 @@ func init() { devnetUpCmd, devnetLsCmd, devnetInspectCmd, + devnetServicesCmd, + devnetLogsCmd, devnetDownCmd, ) @@ -73,7 +76,12 @@ func init() { devnetDownCmd.Flags().BoolVar(&devnetDownAll, "all", false, "destroy every devnet enclave") + devnetLogsCmd.Flags().IntVar(&devnetLogsTail, "tail", 0, + "number of recent log lines per service (0 = server default)") + devnetInspectCmd.ValidArgsFunction = completeEnclaveNames + devnetServicesCmd.ValidArgsFunction = completeEnclaveNames + devnetLogsCmd.ValidArgsFunction = completeEnclaveNames devnetDownCmd.ValidArgsFunction = completeEnclaveNames } @@ -141,9 +149,94 @@ format); without it the package defaults are used.`, } fmt.Fprintf(out, "\nDevnet %q is up.\n", result.Enclave) - fmt.Fprintf(out, " inspect: panda devnet inspect %s\n", result.Enclave) - fmt.Fprintf(out, " logs: kurtosis service logs %s -f\n", result.Enclave) - fmt.Fprintf(out, " destroy: panda devnet down %s\n", result.Enclave) + fmt.Fprintf(out, " inspect: panda devnet inspect %s\n", result.Enclave) + fmt.Fprintf(out, " services: panda devnet services %s\n", result.Enclave) + fmt.Fprintf(out, " logs: panda devnet logs %s [service]\n", result.Enclave) + fmt.Fprintf(out, " destroy: panda devnet down %s\n", result.Enclave) + + return nil + }, +} + +var devnetServicesCmd = &cobra.Command{ + Use: "services ", + Short: "List services running in a devnet", + Long: `List the services (EL/CL/VC clients and tools) running in a devnet enclave. + +The names shown are what 'panda devnet logs' accepts to select services.`, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + resp, err := runServerOperation("devnet.services", map[string]any{"enclave": args[0]}) + if err != nil { + return err + } + + var svcs []devnet.Service + if err := decodeOperationData(resp, &svcs); err != nil { + return err + } + + if isJSON() { + return printJSON(svcs) + } + + if len(svcs) == 0 { + fmt.Println("No services found.") + return nil + } + + rows := make([][]string, 0, len(svcs)) + for _, s := range svcs { + rows = append(rows, []string{s.Name, shortUUID(s.UUID)}) + } + printTable([]string{"SERVICE", "UUID"}, rows) + + return nil + }, +} + +var devnetLogsCmd = &cobra.Command{ + Use: "logs [service...]", + Short: "Show recent logs for devnet services", + Long: `Fetch recent logs for services in a devnet enclave. + +With no service names, logs for every service are returned. Each line is +prefixed with its service name. Use 'panda devnet services ' to see +the available service names. + +Logs are fetched through the panda server (which holds the cluster connection), +so this works wherever 'panda devnet ls' works — including remotely through the +cloud proxy — without needing the kurtosis CLI or a gateway locally.`, + Example: ` panda devnet logs my-devnet + panda devnet logs my-devnet el-1-geth-lighthouse cl-1-lighthouse-geth + panda devnet logs my-devnet el-1-geth-lighthouse --tail 500`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opArgs := map[string]any{"enclave": args[0]} + if len(args) > 1 { + services := make([]any, 0, len(args)-1) + for _, s := range args[1:] { + services = append(services, s) + } + opArgs["services"] = services + } + if cmd.Flags().Changed("tail") { + opArgs["tail"] = devnetLogsTail + } + + resp, err := runServerOperation("devnet.logs", opArgs) + if err != nil { + return err + } + + var result struct { + Logs string `json:"logs"` + } + if err := decodeOperationData(resp, &result); err != nil { + return err + } + + fmt.Fprint(cmd.OutOrStdout(), result.Logs) return nil }, diff --git a/pkg/devnet/devnet.go b/pkg/devnet/devnet.go index b37ba225..0c22feef 100644 --- a/pkg/devnet/devnet.go +++ b/pkg/devnet/devnet.go @@ -27,6 +27,10 @@ import ( // other package is specified. const DefaultPackage = "github.com/ethpandaops/ethereum-package" +// defaultLogTailLines is how many recent lines per service `Logs` returns when +// the caller doesn't ask for a specific tail. +const defaultLogTailLines = 200 + // Client wraps a connection to a Kurtosis engine. type Client struct { kurtosis *kurtosis_context.KurtosisContext @@ -233,6 +237,91 @@ func (c *Client) Down(ctx context.Context, identifier string) error { return nil } +// Service is a service (EL/CL/VC client or tool) running in a devnet enclave. +type Service struct { + Name string `json:"name"` + UUID string `json:"uuid"` +} + +// Services lists the services running in an enclave, sorted by name. The names +// are what `Logs` accepts to select services. +func (c *Client) Services(ctx context.Context, enclave string) ([]Service, error) { + enclaveCtx, err := c.kurtosis.GetEnclaveContext(ctx, enclave) + if err != nil { + return nil, fmt.Errorf("getting enclave %q: %w", enclave, err) + } + + svcs, err := enclaveCtx.GetServices() + if err != nil { + return nil, fmt.Errorf("listing services in %q: %w", enclave, err) + } + + out := make([]Service, 0, len(svcs)) + for name, uuid := range svcs { + out = append(out, Service{Name: string(name), UUID: string(uuid)}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + + return out, nil +} + +// LogOptions configures a logs fetch. +type LogOptions struct { + // Services selects services by name; empty means every service. + Services []string + // TailLines is the number of recent lines per service. 0 uses + // defaultLogTailLines. + TailLines uint32 +} + +// Logs writes recent logs for the selected services (or all) to out, each line +// prefixed with its service name. It does not follow: it returns once the +// requested history has been written, so it works over the plain +// request/response operation path (and thus through the cloud proxy). +// +// On the Kubernetes backend it reads pod logs directly from the cluster: this +// fork ships container logs to OTel/ClickHouse, so the engine's file-based log +// store (what the Kurtosis log API reads) is empty. Raw pod logs are always +// available and need no aggregator. +func (c *Client) Logs(ctx context.Context, enclave string, opts LogOptions, out io.Writer) error { + info, err := c.kurtosis.GetEnclave(ctx, enclave) + if err != nil { + return fmt.Errorf("inspecting enclave %q: %w", enclave, err) + } + + all, err := c.Services(ctx, enclave) + if err != nil { + return err + } + + wanted := opts.Services + if len(wanted) == 0 { + for _, s := range all { + wanted = append(wanted, s.Name) + } + } else { + known := map[string]bool{} + for _, s := range all { + known[s.Name] = true + } + for _, w := range wanted { + if !known[w] { + return fmt.Errorf("service %q not found in enclave %q", w, enclave) + } + } + } + if len(wanted) == 0 { + return fmt.Errorf("enclave %q has no services", enclave) + } + + tail := opts.TailLines + if tail == 0 { + tail = defaultLogTailLines + } + + return podLogs(ctx, info.GetEnclaveUuid(), wanted, int64(tail), out) +} + func toEnclave(info *kurtosis_engine_rpc_api_bindings.EnclaveInfo) Enclave { e := Enclave{ Name: info.GetName(), diff --git a/pkg/devnet/logs.go b/pkg/devnet/logs.go new file mode 100644 index 00000000..728f1c50 --- /dev/null +++ b/pkg/devnet/logs.go @@ -0,0 +1,104 @@ +package devnet + +import ( + "bufio" + "context" + "fmt" + "io" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +// enclaveIDLabel is the label Kurtosis puts on an enclave's Kubernetes namespace +// carrying the enclave UUID. We resolve the namespace from it rather than +// assuming a name pattern, because pool-claimed enclaves keep the namespace of +// the idle enclave they were created from (kt-idle-enclave-). +const enclaveIDLabel = "kurtosistech.com/enclave-id" + +// userServiceContainer is the container name Kurtosis gives every user service's +// main container on Kubernetes (alongside a files-artifact-expander init +// container). +const userServiceContainer = "user-service-container" + +// podLogs fetches the last tail lines of each service's pod log in the enclave's +// namespace and writes them to out, prefixed with the service name. A service +// whose pod is missing or has no logs (e.g. a completed one-shot task) is noted +// and skipped rather than failing the whole request. +func podLogs(ctx context.Context, enclaveUUID string, services []string, tail int64, out io.Writer) error { + clientset, err := newK8sClient() + if err != nil { + return err + } + + namespace, err := enclaveNamespace(ctx, clientset, enclaveUUID) + if err != nil { + return err + } + + for _, svc := range services { + if err := writePodLogs(ctx, clientset, namespace, svc, tail, out); err != nil { + fmt.Fprintf(out, "%s | \n", svc, err) + } + } + + return nil +} + +func writePodLogs(ctx context.Context, clientset kubernetes.Interface, namespace, service string, tail int64, out io.Writer) error { + req := clientset.CoreV1().Pods(namespace).GetLogs(service, &corev1.PodLogOptions{ + Container: userServiceContainer, + TailLines: &tail, + }) + + stream, err := req.Stream(ctx) + if err != nil { + return err + } + defer func() { _ = stream.Close() }() + + scanner := bufio.NewScanner(stream) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + fmt.Fprintf(out, "%s | %s\n", service, scanner.Text()) + } + + return scanner.Err() +} + +// enclaveNamespace finds the Kubernetes namespace for an enclave by its UUID +// label. +func enclaveNamespace(ctx context.Context, clientset kubernetes.Interface, enclaveUUID string) (string, error) { + nss, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("%s=%s", enclaveIDLabel, enclaveUUID), + }) + if err != nil { + return "", fmt.Errorf("finding namespace for enclave %s: %w", enclaveUUID, err) + } + if len(nss.Items) == 0 { + return "", fmt.Errorf("no Kubernetes namespace found for enclave %s", enclaveUUID) + } + + return nss.Items[0].Name, nil +} + +// newK8sClient builds a Kubernetes clientset from the current kubeconfig context +// (which EnsureKubeContext points at the configured cluster). +func newK8sClient() (*kubernetes.Clientset, error) { + cfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{}, + ).ClientConfig() + if err != nil { + return nil, fmt.Errorf("loading kubeconfig: %w", err) + } + + clientset, err := kubernetes.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("building Kubernetes client: %w", err) + } + + return clientset, nil +} diff --git a/pkg/server/operations_devnet.go b/pkg/server/operations_devnet.go index f8b2e782..64159cb2 100644 --- a/pkg/server/operations_devnet.go +++ b/pkg/server/operations_devnet.go @@ -24,6 +24,10 @@ func (s *service) handleDevnetOperation(operationID string, w http.ResponseWrite s.handleDevnetLs(w, r) case "devnet.inspect": s.handleDevnetInspect(w, r) + case "devnet.services": + s.handleDevnetServices(w, r) + case "devnet.logs": + s.handleDevnetLogs(w, r) case "devnet.down": s.handleDevnetDown(w, r) default: @@ -175,6 +179,85 @@ func (s *service) handleDevnetInspect(w http.ResponseWriter, r *http.Request) { }) } +func (s *service) handleDevnetServices(w http.ResponseWriter, r *http.Request) { + req, err := decodeOperationRequest(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + enclave, err := requiredStringArg(req.Args, "enclave") + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + var out bytes.Buffer + client, err := s.devnetClient(&out) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + + svcs, err := client.Services(r.Context(), enclave) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + + writeOperationResponse(s.log, w, http.StatusOK, operations.Response{ + Kind: "devnet.services", + Data: svcs, + }) +} + +func (s *service) handleDevnetLogs(w http.ResponseWriter, r *http.Request) { + req, err := decodeOperationRequest(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + enclave, err := requiredStringArg(req.Args, "enclave") + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + var serviceNames []string + for _, v := range optionalSliceArg(req.Args, "services") { + if name, ok := v.(string); ok && name != "" { + serviceNames = append(serviceNames, name) + } + } + + tail := optionalIntArg(req.Args, "tail", 0) + if tail < 0 { + tail = 0 + } + + var out bytes.Buffer + client, err := s.devnetClient(&out) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + + var logs bytes.Buffer + if err := client.Logs(r.Context(), enclave, devnet.LogOptions{ + Services: serviceNames, + TailLines: uint32(tail), + }, &logs); err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + + writeOperationResponse(s.log, w, http.StatusOK, operations.Response{ + Kind: "devnet.logs", + Data: map[string]any{"logs": logs.String()}, + }) +} + func (s *service) handleDevnetDown(w http.ResponseWriter, r *http.Request) { req, err := decodeOperationRequest(r) if err != nil { From aa816094c889b5d70735f5f835c6313660bad77e Mon Sep 17 00:00:00 2001 From: qu0b Date: Sun, 14 Jun 2026 01:09:49 +0000 Subject: [PATCH 05/14] feat(devnet): live log follow (panda devnet logs -f) Adds '-f/--follow' to stream service logs live until Ctrl-C. Because an open-ended log stream doesn't fit the JSON operation envelope, it uses a dedicated chunked-text endpoint (GET /api/v1/devnet/logs) under the same auth group; the non-follow path keeps using the request/response operation. Server streams pod logs with Follow=true, one prefixed line at a time, flushing each so a remote viewer (through the cloud proxy) sees logs live; multiple services are followed concurrently with serialized writes. The stream stops when the client disconnects (request context cancels the upstream pod streams). Client adds a signal-cancellable streaming GET (serverStreamGet) that copies the stream to stdout until interrupted. Validated end-to-end on bruno: single- and all-service follow show tail + live lines (geth/lighthouse/vc) interleaved. Co-Authored-By: Claude Opus 4.8 --- docs/devnet.md | 12 ++-- gotcha.md | 113 ++++++++++++++++++++++++++++++++ pkg/cli/devnet.go | 31 ++++++++- pkg/cli/serverclient.go | 47 +++++++++++++ pkg/devnet/devnet.go | 34 ++++++++-- pkg/devnet/logs.go | 50 +++++++++++++- pkg/server/api.go | 4 ++ pkg/server/operations_devnet.go | 81 +++++++++++++++++++++++ 8 files changed, 355 insertions(+), 17 deletions(-) create mode 100644 gotcha.md diff --git a/docs/devnet.md b/docs/devnet.md index 67bb423f..e16cafab 100644 --- a/docs/devnet.md +++ b/docs/devnet.md @@ -90,6 +90,8 @@ panda devnet inspect my-devnet panda devnet services my-devnet # list service names panda devnet logs my-devnet # recent logs, all services panda devnet logs my-devnet el-1-geth-lighthouse --tail 500 +panda devnet logs my-devnet -f # follow all services live +panda devnet logs my-devnet el-1-geth-lighthouse -f # follow one (Ctrl-C to stop) panda devnet down my-devnet # or: panda devnet down --all ``` @@ -98,14 +100,8 @@ connection), so they work wherever `panda devnet ls` works — including remotel through the cloud proxy — without needing the `kurtosis` CLI or a gateway locally. Logs are read straight from the pods, so they're available even though this fork ships container logs to OTel/ClickHouse (which leaves the engine's -log API empty). - -For live following, use the `kurtosis` CLI directly (the server already points -it at the right backend): - -```bash -kurtosis service logs my-devnet el-1-geth-lighthouse -f -``` +log API empty). `-f` streams chunked text live; non-`-f` rides the plain +request/response operation path. ## Roadmap: cloud rail behind the proxy diff --git a/gotcha.md b/gotcha.md new file mode 100644 index 00000000..b730e19b --- /dev/null +++ b/gotcha.md @@ -0,0 +1,113 @@ +# Panda CLI Gotchas + +Common pitfalls when using the `panda` CLI, especially from scripts or agents. + +## 1. Module Imports + +The sandbox uses `ethpandaops` as the package name, not shorthand helpers. + +```python +# Wrong +ch("xatu", "SELECT ...") +clickhouse.query("xatu", "SELECT ...") + +# Right +from ethpandaops import clickhouse +clickhouse.query("xatu", "SELECT ...") +``` + +Run `panda docs ` to see the correct import and function signatures. + +## 2. ClickHouse Column Names + +Column names vary between tables. Don't assume — verify with `system.columns` or `panda schema`. + +```python +# Wrong — this column doesn't exist +SELECT meta_execution_implementation FROM beacon_api_eth_v1_events_head + +# Right — check what's available +SELECT name FROM system.columns +WHERE table = 'beacon_api_eth_v1_events_head' AND name LIKE 'meta_%' +``` + +The actual column is `meta_client_implementation`, not `meta_execution_implementation`. + +## 3. Session Limits + +The sandbox enforces a max of **10 concurrent sessions**. Each `panda execute` call creates a new session by default. + +```bash +# Check current sessions +panda session list + +# Reuse an existing session +panda execute --session --code '...' + +# Clean up +panda session destroy +``` + +If you hit `maximum sessions limit reached (10/10)`, destroy unused sessions first. + +## 4. Python f-string Bracket Syntax + +The sandbox runs Python 3.11, which does **not** support nested quotes inside f-string brackets. + +```python +# Wrong — SyntaxError in Python 3.11 +print(f"Latest slot: {df["slot"].max()}") + +# Right — assign to a variable first +latest = df["slot"].max() +print(f"Latest slot: {latest}") +``` + +## 5. Shell Quoting for SQL Strings + +When passing `--code` in single quotes, SQL string literals need careful escaping. + +```bash +# Wrong — breaks the shell quoting +panda execute --code '... WHERE name = 'foo' ...' + +# Right — use quote-escape-quote pattern +panda execute --code '... WHERE name = '"'"'foo'"'"' ...' +``` + +## 6. ethnode API + +There is no `list_networks()` on the ethnode module. You need to know the network name and node name upfront. + +Node naming convention: `{cl_client}-{el_client}-{index}` (e.g., `lighthouse-reth-super-1`). + +```bash +# Check the actual API +panda docs ethnode +``` + +## 7. ClickHouse Cluster Syntax + +Xatu data is split across clusters with **different query syntax**: + +| Cluster | Table syntax | Network filter | +|---------|-------------|----------------| +| `xatu` | `FROM table_name` | `WHERE meta_network_name = '...'` | +| `xatu-cbt` | `FROM network.table_name` | Database prefix is the filter | +| `xatu-experimental` | `FROM table_name` | `WHERE meta_network_name = '...'` (devnets only) | + +## 8. Partition Key Filtering + +Always filter by the table's partition key (usually `slot_start_date_time`) to avoid query timeouts. Unfiltered queries on large tables will be slow or fail. + +```python +# Wrong — full table scan +clickhouse.query("xatu", "SELECT * FROM beacon_api_eth_v1_events_block LIMIT 10") + +# Right — partition-aware +clickhouse.query("xatu", """ + SELECT * FROM beacon_api_eth_v1_events_block + WHERE slot_start_date_time >= now() - INTERVAL 1 HOUR + LIMIT 10 +""") +``` diff --git a/pkg/cli/devnet.go b/pkg/cli/devnet.go index 25ef16f6..9dc828d6 100644 --- a/pkg/cli/devnet.go +++ b/pkg/cli/devnet.go @@ -4,7 +4,11 @@ import ( "encoding/json" "fmt" "io" + "net/url" "os" + "os/signal" + "strconv" + "syscall" "time" "github.com/spf13/cobra" @@ -21,6 +25,7 @@ var ( devnetDockerCache string devnetDownAll bool devnetLogsTail int + devnetLogsFollow bool ) var devnetCmd = &cobra.Command{ @@ -78,6 +83,8 @@ func init() { devnetLogsCmd.Flags().IntVar(&devnetLogsTail, "tail", 0, "number of recent log lines per service (0 = server default)") + devnetLogsCmd.Flags().BoolVarP(&devnetLogsFollow, "follow", "f", false, + "stream logs live until interrupted (Ctrl-C)") devnetInspectCmd.ValidArgsFunction = completeEnclaveNames devnetServicesCmd.ValidArgsFunction = completeEnclaveNames @@ -151,7 +158,7 @@ format); without it the package defaults are used.`, fmt.Fprintf(out, "\nDevnet %q is up.\n", result.Enclave) fmt.Fprintf(out, " inspect: panda devnet inspect %s\n", result.Enclave) fmt.Fprintf(out, " services: panda devnet services %s\n", result.Enclave) - fmt.Fprintf(out, " logs: panda devnet logs %s [service]\n", result.Enclave) + fmt.Fprintf(out, " logs: panda devnet logs %s [service] [-f]\n", result.Enclave) fmt.Fprintf(out, " destroy: panda devnet down %s\n", result.Enclave) return nil @@ -212,6 +219,10 @@ cloud proxy — without needing the kurtosis CLI or a gateway locally.`, panda devnet logs my-devnet el-1-geth-lighthouse --tail 500`, Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if devnetLogsFollow { + return followDevnetLogs(cmd, args[0], args[1:]) + } + opArgs := map[string]any{"enclave": args[0]} if len(args) > 1 { services := make([]any, 0, len(args)-1) @@ -242,6 +253,24 @@ cloud proxy — without needing the kurtosis CLI or a gateway locally.`, }, } +// followDevnetLogs streams logs live from the server's streaming endpoint until +// the user interrupts (Ctrl-C). It uses a raw GET (not the JSON operation path) +// because the response is an open-ended chunked text stream. +func followDevnetLogs(cmd *cobra.Command, enclave string, serviceNames []string) error { + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + + query := url.Values{"enclave": []string{enclave}} + for _, name := range serviceNames { + query.Add("service", name) + } + if cmd.Flags().Changed("tail") { + query.Set("tail", strconv.Itoa(devnetLogsTail)) + } + + return serverStreamGet(ctx, "/api/v1/devnet/logs", query, cmd.OutOrStdout()) +} + var devnetLsCmd = &cobra.Command{ Use: "ls", Aliases: []string{"list"}, diff --git a/pkg/cli/serverclient.go b/pkg/cli/serverclient.go index e77e0f6e..ca51a18d 100644 --- a/pkg/cli/serverclient.go +++ b/pkg/cli/serverclient.go @@ -289,3 +289,50 @@ func isConnectionRefused(err error) bool { // Fallback: some wrapped errors don't propagate the syscall errno. return strings.Contains(err.Error(), "connection refused") } + +// serverStreamGet performs a GET and copies the streamed response body to out +// as it arrives, without buffering, until the stream ends or ctx is cancelled +// (e.g. Ctrl-C). Used for following devnet logs. +func serverStreamGet(ctx context.Context, path string, query url.Values, out io.Writer) error { + baseURL, err := serverBaseURL() + if err != nil { + return err + } + + reqURL := strings.TrimRight(baseURL, "/") + path + if len(query) > 0 { + reqURL += "?" + query.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + resp, err := serverHTTP.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil // cancelled (Ctrl-C) — clean stop + } + if isConnectionRefused(err) { + return fmt.Errorf( + "server is not running at %s — run 'panda init' or 'panda server start' first", + baseURL, + ) + } + + return fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, _ := io.ReadAll(resp.Body) + return decodeAPIError(resp.StatusCode, data) + } + + if _, err := io.Copy(out, resp.Body); err != nil && ctx.Err() == nil { + return fmt.Errorf("reading log stream: %w", err) + } + + return nil +} diff --git a/pkg/devnet/devnet.go b/pkg/devnet/devnet.go index 0c22feef..c8cc0f82 100644 --- a/pkg/devnet/devnet.go +++ b/pkg/devnet/devnet.go @@ -284,14 +284,38 @@ type LogOptions struct { // store (what the Kurtosis log API reads) is empty. Raw pod logs are always // available and need no aggregator. func (c *Client) Logs(ctx context.Context, enclave string, opts LogOptions, out io.Writer) error { + uuid, wanted, tail, err := c.resolveLogTargets(ctx, enclave, opts) + if err != nil { + return err + } + + return podLogs(ctx, uuid, wanted, tail, out) +} + +// FollowLogs streams logs for the selected services (or all) to out until ctx is +// cancelled, each line prefixed with its service name. flush, if non-nil, is +// called after each line so an HTTP handler can push it to the client +// immediately. Like Logs it reads pod logs directly from the cluster. +func (c *Client) FollowLogs(ctx context.Context, enclave string, opts LogOptions, out io.Writer, flush func()) error { + uuid, wanted, tail, err := c.resolveLogTargets(ctx, enclave, opts) + if err != nil { + return err + } + + return followPodLogs(ctx, uuid, wanted, tail, out, flush) +} + +// resolveLogTargets validates the enclave and selected services, returning the +// enclave UUID, the resolved service names, and the per-service tail count. +func (c *Client) resolveLogTargets(ctx context.Context, enclave string, opts LogOptions) (string, []string, int64, error) { info, err := c.kurtosis.GetEnclave(ctx, enclave) if err != nil { - return fmt.Errorf("inspecting enclave %q: %w", enclave, err) + return "", nil, 0, fmt.Errorf("inspecting enclave %q: %w", enclave, err) } all, err := c.Services(ctx, enclave) if err != nil { - return err + return "", nil, 0, err } wanted := opts.Services @@ -306,12 +330,12 @@ func (c *Client) Logs(ctx context.Context, enclave string, opts LogOptions, out } for _, w := range wanted { if !known[w] { - return fmt.Errorf("service %q not found in enclave %q", w, enclave) + return "", nil, 0, fmt.Errorf("service %q not found in enclave %q", w, enclave) } } } if len(wanted) == 0 { - return fmt.Errorf("enclave %q has no services", enclave) + return "", nil, 0, fmt.Errorf("enclave %q has no services", enclave) } tail := opts.TailLines @@ -319,7 +343,7 @@ func (c *Client) Logs(ctx context.Context, enclave string, opts LogOptions, out tail = defaultLogTailLines } - return podLogs(ctx, info.GetEnclaveUuid(), wanted, int64(tail), out) + return info.GetEnclaveUuid(), wanted, int64(tail), nil } func toEnclave(info *kurtosis_engine_rpc_api_bindings.EnclaveInfo) Enclave { diff --git a/pkg/devnet/logs.go b/pkg/devnet/logs.go index 728f1c50..a8887068 100644 --- a/pkg/devnet/logs.go +++ b/pkg/devnet/logs.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "sync" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -39,7 +40,8 @@ func podLogs(ctx context.Context, enclaveUUID string, services []string, tail in } for _, svc := range services { - if err := writePodLogs(ctx, clientset, namespace, svc, tail, out); err != nil { + write := func(line string) { fmt.Fprintln(out, line) } + if err := streamPodLogs(ctx, clientset, namespace, svc, tail, false, write); err != nil { fmt.Fprintf(out, "%s | \n", svc, err) } } @@ -47,10 +49,52 @@ func podLogs(ctx context.Context, enclaveUUID string, services []string, tail in return nil } -func writePodLogs(ctx context.Context, clientset kubernetes.Interface, namespace, service string, tail int64, out io.Writer) error { +// followPodLogs streams each service's pod log concurrently until ctx is +// cancelled, serializing writes (and flushes) through a mutex so interleaved +// lines stay intact. flush may be nil. +func followPodLogs(ctx context.Context, enclaveUUID string, services []string, tail int64, out io.Writer, flush func()) error { + clientset, err := newK8sClient() + if err != nil { + return err + } + + namespace, err := enclaveNamespace(ctx, clientset, enclaveUUID) + if err != nil { + return err + } + + var mu sync.Mutex + write := func(line string) { + mu.Lock() + defer mu.Unlock() + fmt.Fprintln(out, line) + if flush != nil { + flush() + } + } + + var wg sync.WaitGroup + for _, svc := range services { + wg.Add(1) + go func(svc string) { + defer wg.Done() + if err := streamPodLogs(ctx, clientset, namespace, svc, tail, true, write); err != nil && ctx.Err() == nil { + write(fmt.Sprintf("%s | ", svc, err)) + } + }(svc) + } + wg.Wait() + + return nil +} + +// streamPodLogs reads a single service pod's log (optionally following) and +// hands each line, already prefixed with the service name, to write. +func streamPodLogs(ctx context.Context, clientset kubernetes.Interface, namespace, service string, tail int64, follow bool, write func(line string)) error { req := clientset.CoreV1().Pods(namespace).GetLogs(service, &corev1.PodLogOptions{ Container: userServiceContainer, TailLines: &tail, + Follow: follow, }) stream, err := req.Stream(ctx) @@ -62,7 +106,7 @@ func writePodLogs(ctx context.Context, clientset kubernetes.Interface, namespace scanner := bufio.NewScanner(stream) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { - fmt.Fprintf(out, "%s | %s\n", service, scanner.Text()) + write(fmt.Sprintf("%s | %s", service, scanner.Text())) } return scanner.Err() diff --git a/pkg/server/api.go b/pkg/server/api.go index 5980160b..03bc84f4 100644 --- a/pkg/server/api.go +++ b/pkg/server/api.go @@ -41,6 +41,10 @@ func (s *service) mountAPIRoutes(r chi.Router) { r.Post("/build/status", s.handleAPIBuildStatus) r.HandleFunc("/operations/{operationID}", s.handleAPIOperation) + // Streaming devnet log follow. Raw chunked text rather than the JSON + // operation envelope, so it can't ride the operations route. + r.Get("/devnet/logs", s.handleDevnetLogsStream) + // Public file serving (no auth — same as MinIO anonymous download). r.Get("/storage/files/*", s.handleStorageServeFile) diff --git a/pkg/server/operations_devnet.go b/pkg/server/operations_devnet.go index 64159cb2..820bfdf2 100644 --- a/pkg/server/operations_devnet.go +++ b/pkg/server/operations_devnet.go @@ -2,7 +2,10 @@ package server import ( "bytes" + "fmt" "net/http" + "strconv" + "strings" "github.com/ethpandaops/panda/pkg/devnet" "github.com/ethpandaops/panda/pkg/operations" @@ -258,6 +261,84 @@ func (s *service) handleDevnetLogs(w http.ResponseWriter, r *http.Request) { }) } +// handleDevnetLogsStream follows service logs and streams them to the client as +// chunked plain text (one prefixed line at a time), flushing each line so a +// remote viewer sees logs live. It runs until the client disconnects (which +// cancels the request context and stops the upstream pod log streams). +func (s *service) handleDevnetLogsStream(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + enclave := strings.TrimSpace(q.Get("enclave")) + if enclave == "" { + writeAPIError(w, http.StatusBadRequest, "enclave is required") + return + } + + var serviceNames []string + for _, name := range q["service"] { + if name = strings.TrimSpace(name); name != "" { + serviceNames = append(serviceNames, name) + } + } + + tail := 0 + if t := strings.TrimSpace(q.Get("tail")); t != "" { + n, err := strconv.Atoi(t) + if err != nil || n < 0 { + writeAPIError(w, http.StatusBadRequest, "tail must be a non-negative integer") + return + } + tail = n + } + + flusher, ok := w.(http.Flusher) + if !ok { + writeAPIError(w, http.StatusInternalServerError, "response writer does not support streaming") + return + } + + var out bytes.Buffer + client, err := s.devnetClient(&out) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + + // Validate the enclave (and any named services) before committing to a 200 + // stream, so genuine errors come back as proper status codes. + known, err := client.Services(r.Context(), enclave) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + knownNames := map[string]bool{} + for _, svc := range known { + knownNames[svc.Name] = true + } + for _, name := range serviceNames { + if !knownNames[name] { + writeAPIError(w, http.StatusBadRequest, fmt.Sprintf("service %q not found in enclave %q", name, enclave)) + return + } + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "no-cache") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + s.log.WithField("owner", authOwnerID(r)).WithField("enclave", enclave).Info("devnet logs follow") + + if err := client.FollowLogs(r.Context(), enclave, devnet.LogOptions{ + Services: serviceNames, + TailLines: uint32(tail), + }, w, flusher.Flush); err != nil && r.Context().Err() == nil { + fmt.Fprintf(w, "\n", err) + flusher.Flush() + } +} + func (s *service) handleDevnetDown(w http.ResponseWriter, r *http.Request) { req, err := decodeOperationRequest(r) if err != nil { From 09b842bde75bf626eb39062996a3576b4c96fbae Mon Sep 17 00:00:00 2001 From: qu0b Date: Sun, 14 Jun 2026 01:34:53 +0000 Subject: [PATCH 06/14] feat(devnet): show service ports and private IP in 'services' 'panda devnet services' now lists each service's in-cluster ports (rpc:8545, engine-rpc:8551, http:4000, ...) and private IP, so you can see a devnet's topology and reach client APIs immediately after 'up' without digging through kurtosis inspect. Switches Services() from GetServices to GetServiceContexts to pull port specs; adds a Port type and a Service.Endpoint(portName) helper, both exposed in --json for scripting. Co-Authored-By: Claude Opus 4.8 --- docs/devnet.md | 2 +- pkg/cli/devnet.go | 20 ++++++++++++-- pkg/devnet/devnet.go | 63 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/docs/devnet.md b/docs/devnet.md index e16cafab..a5440f3b 100644 --- a/docs/devnet.md +++ b/docs/devnet.md @@ -87,7 +87,7 @@ Add a `cloud` entry the same way for the cloud rail. panda devnet up my-devnet --args ./network_params.yaml panda devnet ls panda devnet inspect my-devnet -panda devnet services my-devnet # list service names +panda devnet services my-devnet # list services + ports panda devnet logs my-devnet # recent logs, all services panda devnet logs my-devnet el-1-geth-lighthouse --tail 500 panda devnet logs my-devnet -f # follow all services live diff --git a/pkg/cli/devnet.go b/pkg/cli/devnet.go index 9dc828d6..b7887e26 100644 --- a/pkg/cli/devnet.go +++ b/pkg/cli/devnet.go @@ -8,6 +8,7 @@ import ( "os" "os/signal" "strconv" + "strings" "syscall" "time" @@ -194,14 +195,29 @@ The names shown are what 'panda devnet logs' accepts to select services.`, rows := make([][]string, 0, len(svcs)) for _, s := range svcs { - rows = append(rows, []string{s.Name, shortUUID(s.UUID)}) + rows = append(rows, []string{s.Name, formatPorts(s.Ports), s.PrivateIP}) } - printTable([]string{"SERVICE", "UUID"}, rows) + printTable([]string{"SERVICE", "PORTS", "PRIVATE IP"}, rows) return nil }, } +// formatPorts renders a service's ports compactly as "name:number" entries, +// e.g. "rpc:8545 ws:8546 engine-rpc:8551". +func formatPorts(ports []devnet.Port) string { + if len(ports) == 0 { + return "-" + } + + parts := make([]string, 0, len(ports)) + for _, p := range ports { + parts = append(parts, fmt.Sprintf("%s:%d", p.Name, p.Number)) + } + + return strings.Join(parts, " ") +} + var devnetLogsCmd = &cobra.Command{ Use: "logs [service...]", Short: "Show recent logs for devnet services", diff --git a/pkg/devnet/devnet.go b/pkg/devnet/devnet.go index c8cc0f82..beb8ec35 100644 --- a/pkg/devnet/devnet.go +++ b/pkg/devnet/devnet.go @@ -17,6 +17,7 @@ import ( "time" "github.com/kurtosis-tech/kurtosis/api/golang/core/kurtosis_core_rpc_api_bindings" + "github.com/kurtosis-tech/kurtosis/api/golang/core/lib/services" "github.com/kurtosis-tech/kurtosis/api/golang/core/lib/starlark_run_config" "github.com/kurtosis-tech/kurtosis/api/golang/engine/kurtosis_engine_rpc_api_bindings" "github.com/kurtosis-tech/kurtosis/api/golang/engine/lib/kurtosis_context" @@ -237,34 +238,84 @@ func (c *Client) Down(ctx context.Context, identifier string) error { return nil } +// Port is a network port exposed by a service. +type Port struct { + // Name is the port's logical name (e.g. "rpc", "engine-rpc", "metrics"). + Name string `json:"name"` + // Number is the in-cluster (private) container port. + Number uint16 `json:"number"` + // Transport is the L4 protocol ("TCP"/"UDP"). + Transport string `json:"transport"` + // Application is the L7 protocol if known (e.g. "http", "ws"); may be empty. + Application string `json:"application,omitempty"` +} + // Service is a service (EL/CL/VC client or tool) running in a devnet enclave. type Service struct { Name string `json:"name"` UUID string `json:"uuid"` + // PrivateIP is the service's in-cluster IP. + PrivateIP string `json:"private_ip,omitempty"` + // Ports are the in-cluster ports the service exposes, sorted by name. + Ports []Port `json:"ports,omitempty"` +} + +// Endpoint returns the in-cluster address for a named port (e.g. +// "el-1-geth-lighthouse:8545"), or "" if the service has no such port. +func (s Service) Endpoint(portName string) string { + for _, p := range s.Ports { + if p.Name == portName { + return fmt.Sprintf("%s:%d", s.Name, p.Number) + } + } + + return "" } -// Services lists the services running in an enclave, sorted by name. The names -// are what `Logs` accepts to select services. +// Services lists the services running in an enclave, sorted by name, with their +// in-cluster ports. The names are what `Logs` accepts to select services. func (c *Client) Services(ctx context.Context, enclave string) ([]Service, error) { enclaveCtx, err := c.kurtosis.GetEnclaveContext(ctx, enclave) if err != nil { return nil, fmt.Errorf("getting enclave %q: %w", enclave, err) } - svcs, err := enclaveCtx.GetServices() + // An empty identifier set asks for every service. + ctxs, err := enclaveCtx.GetServiceContexts(map[string]bool{}) if err != nil { return nil, fmt.Errorf("listing services in %q: %w", enclave, err) } - out := make([]Service, 0, len(svcs)) - for name, uuid := range svcs { - out = append(out, Service{Name: string(name), UUID: string(uuid)}) + out := make([]Service, 0, len(ctxs)) + for name, sc := range ctxs { + out = append(out, Service{ + Name: string(name), + UUID: string(sc.GetServiceUUID()), + PrivateIP: sc.GetPrivateIPAddress(), + Ports: toPorts(sc.GetPrivatePorts()), + }) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, nil } +// toPorts flattens a Kurtosis port-spec map into a name-sorted []Port. +func toPorts(specs map[string]*services.PortSpec) []Port { + ports := make([]Port, 0, len(specs)) + for name, spec := range specs { + ports = append(ports, Port{ + Name: name, + Number: spec.GetNumber(), + Transport: kurtosis_core_rpc_api_bindings.Port_TransportProtocol(spec.GetTransportProtocol()).String(), + Application: spec.GetMaybeApplicationProtocol(), + }) + } + sort.Slice(ports, func(i, j int) bool { return ports[i].Name < ports[j].Name }) + + return ports +} + // LogOptions configures a logs fetch. type LogOptions struct { // Services selects services by name; empty means every service. From 7e8d44f1bfdfd1106a605361021bb4d024ae3cbd Mon Sep 17 00:00:00 2001 From: qu0b Date: Sun, 14 Jun 2026 01:58:19 +0000 Subject: [PATCH 07/14] test(devnet): cover services/logs operations Adds unit tests asserting devnet.services, devnet.logs and the streaming logs endpoint reject missing-enclave / bad-tail args before any engine call, and extends the live lifecycle test (PANDA_DEVNET_LIVE=1) to verify services returns the EL with an rpc endpoint and logs returns non-empty output. Co-Authored-By: Claude Opus 4.8 --- pkg/server/operations_devnet_test.go | 67 ++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/pkg/server/operations_devnet_test.go b/pkg/server/operations_devnet_test.go index 4eeb75e4..31b4b55e 100644 --- a/pkg/server/operations_devnet_test.go +++ b/pkg/server/operations_devnet_test.go @@ -61,6 +61,45 @@ func TestHandleDevnetOperation_InspectRequiresEnclave(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } +func TestHandleDevnetOperation_ServicesRequiresEnclave(t *testing.T) { + s := newDevnetTestService() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/operations/devnet.services", strings.NewReader(`{"args":{}}`)) + + handled := s.handleDevnetOperation("devnet.services", w, r) + require.True(t, handled) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestHandleDevnetOperation_LogsRequiresEnclave(t *testing.T) { + s := newDevnetTestService() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/operations/devnet.logs", strings.NewReader(`{"args":{}}`)) + + handled := s.handleDevnetOperation("devnet.logs", w, r) + require.True(t, handled) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestHandleDevnetLogsStream_RequiresEnclave(t *testing.T) { + s := newDevnetTestService() + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/v1/devnet/logs", nil) + + s.handleDevnetLogsStream(w, r) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestHandleDevnetLogsStream_RejectsBadTail(t *testing.T) { + s := newDevnetTestService() + w := httptest.NewRecorder() + // A non-numeric tail must be rejected before any engine call. + r := httptest.NewRequest(http.MethodGet, "/api/v1/devnet/logs?enclave=x&tail=abc", nil) + + s.handleDevnetLogsStream(w, r) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + // TestHandleDevnetLs_Integration exercises the full handler path against a live // Kurtosis engine (decode → connect → list → JSON response). It skips when no // engine/gateway is reachable, so it is safe in CI but validates locally. @@ -157,6 +196,34 @@ func TestDevnetLifecycle_Live(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &insp)) assert.Equal(t, enclave, insp.Data.Name) + // services — the EL should be present with an rpc port endpoint. + w = callDevnet(s, "devnet.services", map[string]any{"enclave": enclave}) + require.Equal(t, http.StatusOK, w.Code, "services failed: %s", w.Body.String()) + var svc struct { + Data []devnet.Service `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &svc)) + var elName, rpcEndpoint string + for _, sx := range svc.Data { + if strings.HasPrefix(sx.Name, "el-1") { + elName = sx.Name + rpcEndpoint = sx.Endpoint("rpc") + } + } + require.NotEmpty(t, elName, "el-1 service not found") + assert.NotEmpty(t, rpcEndpoint, "el-1 rpc endpoint not found") + + // logs — recent logs for the EL should be non-empty. + w = callDevnet(s, "devnet.logs", map[string]any{"enclave": enclave, "services": []any{elName}, "tail": 20}) + require.Equal(t, http.StatusOK, w.Code, "logs failed: %s", w.Body.String()) + var lg struct { + Data struct { + Logs string `json:"logs"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &lg)) + assert.NotEmpty(t, strings.TrimSpace(lg.Data.Logs), "no logs returned for el-1") + // down. w = callDevnet(s, "devnet.down", map[string]any{"enclave": enclave}) require.Equal(t, http.StatusOK, w.Code, "down failed: %s", w.Body.String()) From e7d60dca0c983c11f65427af5f97f405ca732e5c Mon Sep 17 00:00:00 2001 From: qu0b Date: Sun, 14 Jun 2026 13:32:07 +0000 Subject: [PATCH 08/14] feat(devnet): user-scoped external access to services via Traefik ingress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'panda devnet up' (when devnet.ingress.enabled) creates a Traefik Ingress per HTTP/WS service port so each is reachable at a stable, GitHub-user-scoped hostname, and 'panda devnet endpoints' lists the URLs. Hostnames are clean dotted labels: ... dora.bal3.qu0b.k3s.bruno (primary port) -... ws-el-1-geth-lighthouse.bal3.qu0b.k3s.bruno The left-most label is the only variable part below .., so a per-enclave wildcard cert *... covers every host. This matches the existing ethpandaops devnet DNS/cert pattern: the zone is served by a self-hosted authoritative DNS (bruno: dnsmasq *.k3s.bruno resolves any depth; prod: NS-delegate to ethpandaops.general.dns_server) and certs come from ZeroSSL DNS-01 via cert-manager (no Let's Encrypt rate limits) — no Cloudflare upgrade. is server-derived (authOwnerID, else config local_owner) — never client-supplied — making it the multi-tenant boundary (per-user/enclave wildcard cert + forward-auth enforcing authenticated-user==owner). Exposes http/ws ports (rpc/ws/http/api/metrics); skips engine-rpc (JWT) and p2p. Reconcile is non-fatal to 'up'; ingresses live in the enclave namespace and are GC'd on 'down'. bruno->prod is config-only (base_domain/entrypoint/tls_secret/auth_middleware); no code or scheme change. Validated live on bruno: dora UI (200), EL JSON-RPC, EL WS (101 upgrade), CL beacon API served through the ingress at clean qu0b-scoped dotted hostnames. Co-Authored-By: Claude Opus 4.8 --- config.dev.yaml | 6 + docs/devnet.md | 54 ++++ pkg/cli/devnet.go | 61 ++++- pkg/config/config.go | 9 + pkg/config/devnet.go | 46 ++++ pkg/devnet/ingress.go | 373 +++++++++++++++++++++++++++ pkg/devnet/ingress_test.go | 243 +++++++++++++++++ pkg/server/operations_devnet.go | 82 ++++++ pkg/server/operations_devnet_test.go | 7 + 9 files changed, 878 insertions(+), 3 deletions(-) create mode 100644 pkg/devnet/ingress.go create mode 100644 pkg/devnet/ingress_test.go diff --git a/config.dev.yaml b/config.dev.yaml index 40adf489..e090c515 100644 --- a/config.dev.yaml +++ b/config.dev.yaml @@ -26,6 +26,12 @@ cluster: devnet: package: github.com/ethpandaops/ethereum-package docker_cache: docker.ethquokkaops.io + ingress: + enabled: true + base_domain: k3s.bruno + entrypoint: web + ingress_class: traefik + local_owner: qu0b observability: metrics_enabled: false diff --git a/docs/devnet.md b/docs/devnet.md index a5440f3b..ef7cce06 100644 --- a/docs/devnet.md +++ b/docs/devnet.md @@ -92,6 +92,7 @@ panda devnet logs my-devnet # recent logs, all service panda devnet logs my-devnet el-1-geth-lighthouse --tail 500 panda devnet logs my-devnet -f # follow all services live panda devnet logs my-devnet el-1-geth-lighthouse -f # follow one (Ctrl-C to stop) +panda devnet endpoints my-devnet # external URLs per service panda devnet down my-devnet # or: panda devnet down --all ``` @@ -103,6 +104,59 @@ this fork ships container logs to OTel/ClickHouse (which leaves the engine's log API empty). `-f` streams chunked text live; non-`-f` rides the plain request/response operation path. +## External access to services (RPC, dora, …) + +When `devnet.ingress.enabled` is set, `up` creates a Traefik `Ingress` per HTTP/WS +service port so each is reachable at a stable, **GitHub-user-scoped** hostname: + +``` +... # primary port, e.g. dora.my-devnet.qu0b.k3s.bruno + # el-1-geth-lighthouse.my-devnet.qu0b.k3s.bruno (rpc) +-... # other ports, e.g. ws-el-1-geth-lighthouse.my-devnet.qu0b.k3s.bruno +``` + +Names are clean dotted labels. This works because the devnet zone is served by a +self-hosted **authoritative DNS** (on bruno, dnsmasq's `*.k3s.bruno` wildcard +already resolves at any depth; in prod, NS-delegate the zone to a self-hosted DNS +like the `ethpandaops.general.dns_server` role) and certs come from **ZeroSSL +DNS-01** (no Let's Encrypt rate limits) via cert-manager — a per-enclave wildcard +`*...` covers every host above, since the left-most label is +the only variable part below it. + +`panda devnet endpoints my-devnet` lists them (`--json` for scripting). Web UIs +(dora, grafana) load at the host root; EL JSON-RPC, WebSocket (`ws--…`) and the +CL beacon API are reached the same way — straight through Traefik, so there are +no proxy body/timeout limits on RPC or large responses. + +The `` segment is **server-derived** (the authenticated GitHub login, never +client-supplied; `local_owner` is used in lean dev). That makes it the multi-tenant +boundary: a single per-user wildcard cert `*..` covers all of a user's +devnets, and a Traefik forward-auth middleware can enforce *authenticated user == +``* so users only reach their own services. + +```yaml +devnet: + ingress: + enabled: true + base_domain: k3s.bruno # bruno (LAN; dnsmasq *.k3s.bruno wildcard already routes to Traefik) + entrypoint: web # plain HTTP on the trusted LAN + ingress_class: traefik + local_owner: qu0b # owner when the request carries no identity (lean dev) +``` + +Flip to production by changing only this block — no code or hostname-scheme change: + +```yaml +devnet: + ingress: + enabled: true + base_domain: devnet.ethpandaops.io + entrypoint: websecure + tls_secret: devnet-wildcard-tls # *..devnet.ethpandaops.io (cert-manager DNS-01) + auth_middleware: devnet-forward-auth@kubernetescrd + # local_owner unset → owner comes from the authenticated identity +``` + ## Roadmap: cloud rail behind the proxy Today both rails run the Kurtosis client in the local server. Because the local diff --git a/pkg/cli/devnet.go b/pkg/cli/devnet.go index b7887e26..6a6e694e 100644 --- a/pkg/cli/devnet.go +++ b/pkg/cli/devnet.go @@ -63,6 +63,7 @@ func init() { devnetLsCmd, devnetInspectCmd, devnetServicesCmd, + devnetEndpointsCmd, devnetLogsCmd, devnetDownCmd, ) @@ -89,6 +90,7 @@ func init() { devnetInspectCmd.ValidArgsFunction = completeEnclaveNames devnetServicesCmd.ValidArgsFunction = completeEnclaveNames + devnetEndpointsCmd.ValidArgsFunction = completeEnclaveNames devnetLogsCmd.ValidArgsFunction = completeEnclaveNames devnetDownCmd.ValidArgsFunction = completeEnclaveNames } @@ -137,9 +139,11 @@ format); without it the package defaults are used.`, } var result struct { - Enclave string `json:"enclave"` - Output string `json:"output"` - Error string `json:"error"` + Enclave string `json:"enclave"` + Output string `json:"output"` + Error string `json:"error"` + Endpoints []devnet.ServiceEndpoints `json:"endpoints"` + IngressError string `json:"ingress_error"` } if err := decodeOperationData(resp, &result); err != nil { return err @@ -162,6 +166,18 @@ format); without it the package defaults are used.`, fmt.Fprintf(out, " logs: panda devnet logs %s [service] [-f]\n", result.Enclave) fmt.Fprintf(out, " destroy: panda devnet down %s\n", result.Enclave) + if len(result.Endpoints) > 0 { + fmt.Fprintln(out, "\nendpoints:") + for _, e := range result.Endpoints { + if e.PrimaryURL != "" { + fmt.Fprintf(out, " %-28s %s\n", e.Service, e.PrimaryURL) + } + } + } + if result.IngressError != "" { + fmt.Fprintf(out, "\n(ingress not fully configured: %s)\n", result.IngressError) + } + return nil }, } @@ -218,6 +234,45 @@ func formatPorts(ports []devnet.Port) string { return strings.Join(parts, " ") } +var devnetEndpointsCmd = &cobra.Command{ + Use: "endpoints ", + Short: "Show external URLs for a devnet's services", + Long: `Show the stable external URLs panda assigns to a devnet's services. + +Each exposed service is reachable at an owner-scoped hostname; this lists the +primary URL per service (the dora UI and EL rpc are the headline ones). Pass +--json for the full per-port detail.`, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + resp, err := runServerOperation("devnet.endpoints", map[string]any{"enclave": args[0]}) + if err != nil { + return err + } + + var eps []devnet.ServiceEndpoints + if err := decodeOperationData(resp, &eps); err != nil { + return err + } + + if isJSON() { + return printJSON(eps) + } + + if len(eps) == 0 { + fmt.Println("No exposed services found.") + return nil + } + + rows := make([][]string, 0, len(eps)) + for _, e := range eps { + rows = append(rows, []string{e.Service, e.PrimaryURL}) + } + printTable([]string{"SERVICE", "URL"}, rows) + + return nil + }, +} + var devnetLogsCmd = &cobra.Command{ Use: "logs [service...]", Short: "Show recent logs for devnet services", diff --git a/pkg/config/config.go b/pkg/config/config.go index c46f93ed..d442768d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -415,6 +415,15 @@ func applyDefaults(cfg *Config) { cfg.ConsensusSpecs.Repository = "ethereum/consensus-specs" } + // Devnet ingress defaults. + if cfg.Devnet.Ingress.IngressClass == "" { + cfg.Devnet.Ingress.IngressClass = "traefik" + } + + if cfg.Devnet.Ingress.Entrypoint == "" { + cfg.Devnet.Ingress.Entrypoint = "web" + } + // Storage defaults. if cfg.Storage.BaseDir == "" { cfg.Storage.BaseDir = pandaDataDir("storage") diff --git a/pkg/config/devnet.go b/pkg/config/devnet.go index 3afd8a50..3a6a6527 100644 --- a/pkg/config/devnet.go +++ b/pkg/config/devnet.go @@ -29,4 +29,50 @@ type DevnetConfig struct { // through it, which avoids Docker Hub rate limits — especially important on // a multi-node Kubernetes backend. Empty disables it. DockerCache string `yaml:"docker_cache,omitempty"` + + // Ingress configures GitHub-user-scoped external access to a devnet's + // services via Traefik Ingress objects panda creates on Kubernetes. Disabled + // by default; see IngressConfig. + Ingress IngressConfig `yaml:"ingress,omitempty"` +} + +// IngressConfig configures how panda exposes a devnet's services externally by +// creating Traefik Ingress objects in the enclave's namespace. Each exposed +// service port becomes reachable at a stable, owner-scoped hostname of the form +// ----.., with a primary alias of +// --... The left-most segment is a single +// DNS label so one wildcard certificate (*..) covers every +// host. +type IngressConfig struct { + // Enabled turns ingress creation on. When false, panda creates no Ingress + // objects and the endpoints operation still computes hostnames for display. + Enabled bool `yaml:"enabled"` + + // BaseDomain is the apex the per-owner subdomains hang off, e.g. "k3s.bruno" + // (bruno) or "devnet.ethpandaops.io" (prod). + BaseDomain string `yaml:"base_domain"` + + // IngressClass is the spec.ingressClassName set on created Ingresses. + // Defaults to "traefik" when empty. + IngressClass string `yaml:"ingress_class"` + + // Entrypoint is the Traefik entrypoint routers attach to (the + // traefik.ingress.kubernetes.io/router.entrypoints annotation), e.g. "web" + // (bruno, plain http) or "websecure" (prod). Defaults to "web" when empty. + Entrypoint string `yaml:"entrypoint"` + + // TLSSecret is the wildcard TLS secret name to attach to every host. Empty + // means plain http (bruno); a non-empty value switches computed endpoint + // URLs to https. + TLSSecret string `yaml:"tls_secret"` + + // AuthMiddleware is an optional Traefik middleware reference (e.g. + // "devnet-forward-auth@kubernetescrd") added via the + // traefik.ingress.kubernetes.io/router.middlewares annotation. Empty on + // bruno; set in prod to enforce auth at the edge. + AuthMiddleware string `yaml:"auth_middleware"` + + // LocalOwner is the owner label used when the request carries no + // authenticated identity (bruno/lean dev). Never sourced from client args. + LocalOwner string `yaml:"local_owner"` } diff --git a/pkg/devnet/ingress.go b/pkg/devnet/ingress.go new file mode 100644 index 00000000..ecef374a --- /dev/null +++ b/pkg/devnet/ingress.go @@ -0,0 +1,373 @@ +package devnet + +import ( + "context" + "fmt" + "hash/fnv" + "strings" + + networkingv1 "k8s.io/api/networking/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + + "github.com/ethpandaops/panda/pkg/config" +) + +// maxDNSLabel is the RFC 1035 limit on a single DNS label. Hosts are dotted — +// ... for a service's primary port and +// -... for the rest — so the enclave and +// owner are their own readable labels and a per-enclave wildcard certificate +// (*...) covers every host (the left-most label is the +// only variable part below it). The left-most label is shortened +// deterministically if it would exceed this limit (see shortenLabel). +const maxDNSLabel = 63 + +// PortEndpoint is a single exposed port's external URL. +type PortEndpoint struct { + // Name is the port's logical name (e.g. "rpc", "ws", "metrics"). + Name string `json:"name"` + // URL is the fully-qualified external URL for the port. + URL string `json:"url"` +} + +// ServiceEndpoints describes the external URLs panda assigns to one service: +// the primary URL (the headline one, e.g. an EL's rpc or dora's http) and the +// per-port URLs. It is computed purely from the service ports and ingress +// config — no cluster access — so it is unit-testable. +type ServiceEndpoints struct { + // Service is the service name. + Service string `json:"service"` + // PrimaryURL points at the service's primary port (rpc > http > first http + // app > first exposed). Empty if the service exposes no ports. + PrimaryURL string `json:"primary_url"` + // Ports are the per-port URLs, in the order the ports were exposed. + Ports []PortEndpoint `json:"ports"` +} + +// exposedPortNames is the set of port names panda exposes externally regardless +// of declared application protocol. Everything else (engine-rpc behind JWT, p2p, +// discovery, quic, raw udp) is deliberately left in-cluster only. +var exposedPortNames = map[string]bool{ + "rpc": true, + "ws": true, + "http": true, + "api": true, + "metrics": true, +} + +// isExposed reports whether a port should be reachable from outside the cluster: +// any http/ws application port, or one of the well-known exposed names. +func isExposed(p Port) bool { + if p.Application == "http" || p.Application == "ws" { + return true + } + + return exposedPortNames[p.Name] +} + +// exposedPorts returns the service's externally-exposed ports, preserving the +// service's (name-sorted) port order. +func exposedPorts(svc Service) []Port { + out := make([]Port, 0, len(svc.Ports)) + for _, p := range svc.Ports { + if isExposed(p) { + out = append(out, p) + } + } + + return out +} + +// primaryPort picks the headline port among the exposed ones: a port named +// "rpc", else one named "http", else the first http application port, else the +// first exposed port. It returns false when there are no exposed ports. +func primaryPort(exposed []Port) (Port, bool) { + if len(exposed) == 0 { + return Port{}, false + } + + for _, p := range exposed { + if p.Name == "rpc" { + return p, true + } + } + for _, p := range exposed { + if p.Name == "http" { + return p, true + } + } + for _, p := range exposed { + if p.Application == "http" { + return p, true + } + } + + return exposed[0], true +} + +// sanitizeLabel reduces s to a valid DNS label: lowercased, with every run of +// disallowed characters collapsed to a single hyphen and leading/trailing +// hyphens stripped. A label that would exceed maxDNSLabel is shortened +// deterministically (see shortenLabel). An empty result becomes "x" so callers +// always get a usable label. +func sanitizeLabel(s string) string { + s = strings.ToLower(s) + + var b strings.Builder + lastHyphen := false + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastHyphen = false + continue + } + // Any other rune (including '-') becomes a single collapsed hyphen. + if !lastHyphen { + b.WriteByte('-') + lastHyphen = true + } + } + + out := strings.Trim(b.String(), "-") + if out == "" { + return "x" + } + if len(out) > maxDNSLabel { + out = shortenLabel(out) + } + + return out +} + +// shortenLabel deterministically compresses an over-long label to fit within +// maxDNSLabel by keeping a readable prefix and appending an fnv hash of the full +// label, so distinct long labels stay distinct. It uses hash/fnv (stable across +// runs), never math/rand. +func shortenLabel(label string) string { + h := fnv.New32a() + _, _ = h.Write([]byte(label)) + suffix := fmt.Sprintf("-%08x", h.Sum32()) + + prefix := label[:maxDNSLabel-len(suffix)] + prefix = strings.TrimRight(prefix, "-") + + return prefix + suffix +} + +// leftLabel builds the left-most DNS label of a host. For a service's primary +// port it is just the sanitized service name (the clean, headline URL); for any +// other port it is "-" — a single label so a per-enclave wildcard +// certificate still covers it. The label is shortened deterministically if it +// would exceed one DNS label. +func leftLabel(portName, service string, primary bool) string { + if primary { + return sanitizeLabel(service) + } + + label := sanitizeLabel(portName) + "-" + sanitizeLabel(service) + if len(label) > maxDNSLabel { + label = shortenLabel(label) + } + + return label +} + +// host assembles a full dotted hostname from the left-most label and the +// enclave, owner and base labels, e.g. "dora.bal3.qu0b.k3s.bruno". The enclave +// and owner are sanitized into their own labels; base is appended verbatim (it +// may itself be multi-label, e.g. "devnet.ethpandaops.io"). +func host(label, enclave, owner, base string) string { + parts := []string{label, sanitizeLabel(enclave), sanitizeLabel(owner)} + if base != "" { + parts = append(parts, base) + } + + return strings.Join(parts, ".") +} + +// scheme returns the URL scheme implied by the ingress config: https when a TLS +// secret is configured, http otherwise. +func scheme(cfg config.IngressConfig) string { + if cfg.TLSSecret != "" { + return "https" + } + + return "http" +} + +// Endpoints computes the external URLs for each service that exposes at least +// one port, given the enclave name, the resolved owner, and the ingress config. +// It performs no cluster access and is the source of truth shared by the +// endpoints operation and ingress reconciliation. Services with no exposed +// ports are omitted. +func Endpoints(services []Service, enclaveName, owner string, cfg config.IngressConfig) []ServiceEndpoints { + sch := scheme(cfg) + + out := make([]ServiceEndpoints, 0, len(services)) + for _, svc := range services { + exposed := exposedPorts(svc) + if len(exposed) == 0 { + continue + } + + primary, hasPrimary := primaryPort(exposed) + + ports := make([]PortEndpoint, 0, len(exposed)) + primaryURL := "" + for _, p := range exposed { + isPrimary := hasPrimary && p.Name == primary.Name && p.Number == primary.Number + h := host(leftLabel(p.Name, svc.Name, isPrimary), enclaveName, owner, cfg.BaseDomain) + url := sch + "://" + h + ports = append(ports, PortEndpoint{Name: p.Name, URL: url}) + if isPrimary { + primaryURL = url + } + } + + out = append(out, ServiceEndpoints{ + Service: svc.Name, + PrimaryURL: primaryURL, + Ports: ports, + }) + } + + return out +} + +// ReconcileIngresses upserts one Traefik Ingress per service (that exposes +// ports) in the enclave's Kubernetes namespace, making each devnet service +// reachable at its stable owner-scoped hostname. It is idempotent: existing +// Ingresses are updated in place. Teardown is handled by namespace deletion on +// 'down' — the Ingresses live in the enclave namespace — so there is no explicit +// delete; the owner/enclave labels let them be selected later if needed. +func ReconcileIngresses(ctx context.Context, enclaveUUID, enclaveName, owner string, services []Service, cfg config.IngressConfig) error { + clientset, err := newK8sClient() + if err != nil { + return err + } + + namespace, err := enclaveNamespace(ctx, clientset, enclaveUUID) + if err != nil { + return err + } + + for _, svc := range services { + ingress := buildIngress(namespace, enclaveUUID, enclaveName, owner, svc, cfg) + if ingress == nil { + // No exposed ports — nothing to route. + continue + } + + if err := upsertIngress(ctx, clientset, namespace, ingress); err != nil { + return fmt.Errorf("reconciling ingress for service %q: %w", svc.Name, err) + } + } + + return nil +} + +// buildIngress constructs the Ingress object for one service, or nil when the +// service exposes no ports. It creates one rule per exposed port plus a primary +// alias rule, attaches the configured Traefik entrypoint (and optional auth +// middleware), and adds a single TLS entry covering every host when a TLS +// secret is configured. +func buildIngress(namespace, enclaveUUID, enclaveName, owner string, svc Service, cfg config.IngressConfig) *networkingv1.Ingress { + exposed := exposedPorts(svc) + if len(exposed) == 0 { + return nil + } + + pathType := networkingv1.PathTypePrefix + ingressClass := cfg.IngressClass + + rules := make([]networkingv1.IngressRule, 0, len(exposed)+1) + hosts := make([]string, 0, len(exposed)+1) + + rule := func(h string, portNumber uint16) networkingv1.IngressRule { + return networkingv1.IngressRule{ + Host: h, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/", + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: svc.Name, + Port: networkingv1.ServiceBackendPort{Number: int32(portNumber)}, + }, + }, + }}, + }, + }, + } + } + + primary, hasPrimary := primaryPort(exposed) + for _, p := range exposed { + // The primary port gets the clean ... + // host; other ports get a - left label. + isPrimary := hasPrimary && p.Name == primary.Name && p.Number == primary.Number + h := host(leftLabel(p.Name, svc.Name, isPrimary), enclaveName, owner, cfg.BaseDomain) + hosts = append(hosts, h) + rules = append(rules, rule(h, p.Number)) + } + + annotations := map[string]string{ + "traefik.ingress.kubernetes.io/router.entrypoints": cfg.Entrypoint, + } + if cfg.AuthMiddleware != "" { + annotations["traefik.ingress.kubernetes.io/router.middlewares"] = cfg.AuthMiddleware + } + + spec := networkingv1.IngressSpec{ + IngressClassName: &ingressClass, + Rules: rules, + } + if cfg.TLSSecret != "" { + spec.TLS = []networkingv1.IngressTLS{{ + Hosts: hosts, + SecretName: cfg.TLSSecret, + }} + } + + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "panda-" + sanitizeLabel(svc.Name), + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "panda", + "panda.devnet/owner": sanitizeLabel(owner), + "panda.devnet/enclave": enclaveUUID, + }, + Annotations: annotations, + }, + Spec: spec, + } +} + +// upsertIngress creates the Ingress, or updates it in place when it already +// exists (carrying over the live ResourceVersion so the update is accepted). +func upsertIngress(ctx context.Context, clientset kubernetes.Interface, namespace string, ingress *networkingv1.Ingress) error { + api := clientset.NetworkingV1().Ingresses(namespace) + + existing, err := api.Get(ctx, ingress.Name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + if _, err := api.Create(ctx, ingress, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("creating ingress %q: %w", ingress.Name, err) + } + + return nil + } + if err != nil { + return fmt.Errorf("getting ingress %q: %w", ingress.Name, err) + } + + ingress.ResourceVersion = existing.ResourceVersion + if _, err := api.Update(ctx, ingress, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("updating ingress %q: %w", ingress.Name, err) + } + + return nil +} diff --git a/pkg/devnet/ingress_test.go b/pkg/devnet/ingress_test.go new file mode 100644 index 00000000..bca37684 --- /dev/null +++ b/pkg/devnet/ingress_test.go @@ -0,0 +1,243 @@ +package devnet + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/panda/pkg/config" +) + +func TestSanitizeLabel(t *testing.T) { + t.Run("lowercases and collapses bad chars", func(t *testing.T) { + assert.Equal(t, "el-1-geth", sanitizeLabel("EL_1.Geth")) + assert.Equal(t, "a-b", sanitizeLabel("a__b")) + assert.Equal(t, "rpc", sanitizeLabel("--rpc--")) + }) + + t.Run("empty becomes x", func(t *testing.T) { + assert.Equal(t, "x", sanitizeLabel("")) + assert.Equal(t, "x", sanitizeLabel("___")) + }) + + t.Run("github numeric owner survives", func(t *testing.T) { + assert.Equal(t, "1234567", sanitizeLabel("1234567")) + }) + + t.Run("shortens over-63-char labels deterministically", func(t *testing.T) { + long := strings.Repeat("a", 200) + got := sanitizeLabel(long) + require.LessOrEqual(t, len(got), maxDNSLabel) + // Deterministic: same input -> same output. + assert.Equal(t, got, sanitizeLabel(long)) + // Distinct inputs -> distinct outputs (hash suffix). + other := strings.Repeat("a", 199) + "b" + assert.NotEqual(t, got, sanitizeLabel(other)) + }) +} + +func TestLeftLabel(t *testing.T) { + t.Run("primary port -> bare service label", func(t *testing.T) { + assert.Equal(t, "el-1-geth-lighthouse", leftLabel("rpc", "el-1-geth-lighthouse", true)) + assert.Equal(t, "dora", leftLabel("http", "dora", true)) + }) + + t.Run("non-primary port -> -", func(t *testing.T) { + assert.Equal(t, "ws-el-1-geth-lighthouse", leftLabel("ws", "el-1-geth-lighthouse", false)) + assert.Equal(t, "metrics-dora", leftLabel("metrics", "dora", false)) + }) + + t.Run("over-long label is shortened to one DNS label", func(t *testing.T) { + got := leftLabel("metrics", strings.Repeat("x", 80), false) + require.LessOrEqual(t, len(got), maxDNSLabel) + assert.NotContains(t, got, ".") + }) +} + +func TestHost(t *testing.T) { + // Clean dotted: