diff --git a/config.dev.yaml b/config.dev.yaml new file mode 100644 index 00000000..e7f15365 --- /dev/null +++ b/config.dev.yaml @@ -0,0 +1,38 @@ +# 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 + ingress: + enabled: true + base_domain: k3s.bruno + ingress_class: traefik + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + local_owner: qu0b + +observability: + metrics_enabled: false 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-production.md b/docs/devnet-production.md new file mode 100644 index 00000000..a4138d54 --- /dev/null +++ b/docs/devnet-production.md @@ -0,0 +1,169 @@ +# Deploying `panda devnet` remote access to production + +This guide takes the `panda devnet` feature (multi-client Kurtosis devnets + +owner-scoped external access to their services — RPC, dora, beacon API, …) from +the local **bruno** setup to the ethpandaops **platform**. + +The guiding principle is **maintainability**: reuse the platform's existing +building blocks and add as few new, stateful components as possible. The design +below adds exactly **two** GitOps apps (the Kurtosis engine and `panda-server`) +and **nothing else** on the DNS/cert/tunnel side — devnet hosts ride the +platform's existing `*.ethpandaops.io` Cloudflare tunnel + edge cert + nginx. + +It assumes the feature from PR #213 (`services`, `logs`/`-f`, `endpoints`, +user-scoped `Ingress`, `devnet use`, `host_style`). For the local design and the +bruno setup see [`devnet.md`](./devnet.md). + +## The hostname decision (why flat, not a self-hosted zone) + +Devnet hosts are user-scoped. The depth question is: how do we get TLS + routing +for them without standing up new infrastructure? + +- A self-hosted authoritative DNS zone (RFC2136) + a ZeroSSL DNS-01 issuer would + give clean **dotted** names at any depth — but it's a new stateful DNS server, + a new ACME account, TSIG secrets, and an NS delegation to operate **forever**. + That's a lot of maintenance surface for cosmetic hostnames. **Rejected.** +- The platform already routes `*.ethpandaops.io` through a Cloudflare tunnel to + `ingress-nginx-devnets`, with TLS terminated at the Cloudflare edge by the + universal-SSL cert. That wildcard covers exactly **one label** under the apex. + +So in prod panda uses **`host_style: flat`** — it folds service/enclave/owner into +a single DNS label: + +``` +----.ethpandaops.io dora--bal3--qu0b.ethpandaops.io +------.ethpandaops.io ws--el-1-geth-lighthouse--bal3--qu0b.ethpandaops.io +--.ethpandaops.io dora--qu0b.ethpandaops.io (default-devnet alias) +``` + +One label ⇒ covered by the **existing** wildcard cert + tunnel rule + nginx. The +cost is `--`-separated names instead of dotted ones; the benefit is **zero new +DNS/cert/tunnel components**. Maintainability wins. + +## TL;DR — what's already there vs net-new + +| Concern | Platform-provided | Net-new for devnets | +|---|---|---| +| GitOps | ArgoCD (root-app + ApplicationSet) | one `Application` for the Kurtosis engine, one for `panda-server` | +| Ingress | ingress-nginx-devnets | — (panda creates per-service `Ingress` objects at runtime) | +| DNS | Cloudflare `*.ethpandaops.io` → tunnel | — (flat hosts fall under the existing wildcard) | +| Certs | Cloudflare edge universal-SSL on `*.ethpandaops.io` | — (TLS terminates at the edge; panda Ingresses serve HTTP) | +| Tunnel | cloudflare-tunnel-devnets `*.ethpandaops.io` → nginx | — (flat hosts match the existing rule) | +| Auth | Dex/OIDC; **hosted panda-proxy** | gate the create/manage path at panda-server; (optional) Cloudflare Access at the edge for service hosts | +| Kurtosis engine | — | GitOps engine on the analytics cluster (mirror the bruno engine) | + +## Architecture in production + +``` +panda CLI ──HTTPS──▶ panda-proxy (hosted, OIDC/Dex) identity (GitHub login/ID) + │ derives AuthUser, forwards op + ▼ + panda-server (in-cluster: SA + `kurtosis gateway` sidecar) + │ Kurtosis SDK + k8s API (creates Ingress) + ▼ + Kurtosis engine (GitOps) ──▶ enclave namespace (EL/CL/VC/dora …) + ▲ + browser / cast / wallet ──HTTPS──▶ Cloudflare edge ──tunnel──▶ ingress-nginx-devnets ──▶ service + dora--bal3--qu0b.ethpandaops.io / el-1-…--bal3--qu0b.ethpandaops.io +``` + +Key alignment points: +- **panda-server runs in the analytics cluster** (Deployment + ServiceAccount), + with a `kurtosis gateway` sidecar to the in-cluster engine (`:9710`). That gives + it the Kurtosis SDK connection *and* the k8s API access to create `Ingress` + objects. No kubeconfig file — an in-cluster SA + RBAC is enough. +- **Identity is server-derived from the hosted proxy** (`AuthUser` → GitHub + login). The owner is never client-supplied; it's the multi-tenant namespace. +- panda creates Ingresses **at runtime** per devnet; the existing tunnel + nginx + pick them up by Host match — **no per-devnet GitOps, DNS or cert changes**. + +## Required code changes — done (on PR #213) + +- **Controller-agnostic ingress.** `ingress_class` + a verbatim `annotations` map + + a `tls` toggle, so prod uses nginx with `tls: false` (edge TLS). +- **`host_style: flat`.** Single-label hosts that fall under `*.ethpandaops.io`. +- **Owner label = GitHub login** (`resolveOwner` prefers `GitHubLogin`), so hosts + read `dora--…--qu0b` not `dora--…--583231`. Numeric ID kept for authz. + +## Auth + +- **Create / manage path (the important one):** gated at **panda-server** via the + hosted proxy's OIDC (GitHub-org membership) — only authorized members can spin + up enclaves. This is the "only authorized people" boundary. +- **Service hosts (dora/RPC/beacon):** carry **no per-Ingress auth**, so `cast`, + wallets and scripts reach RPC without an interactive browser SSO flow. If the + org wants these gated too, do it **uniformly at the edge with Cloudflare Access** + (which supports service tokens for programmatic RPC) — not a bespoke per-owner + forward-auth service to build and maintain. The `` label remains the + namespace; edge access policy is a platform-level config, not panda's concern. + +## Kurtosis engine on the analytics cluster + +Mirror the bruno GitOps engine (`applications/kurtosis-engine/`): an ArgoCD +`Application` deploying the engine (+ logs components) into a `kurtosis-engine` +namespace, carrying the fork's bruno learnings — grace-period fast teardown, the +enclave warm-pool self-heal, the v9 `SERIALIZED_ARGS` with `poolSize: 2`, and a +`WaitForFirstConsumer` storage class. + +**The one real platform dependency:** the engine + APIC + files-artifacts-expander ++ logs images are a **fork** (they carry the patches above). They must be hosted +where the analytics cluster can pull them — build them through the platform's +existing image pipeline and set `engine.image` / `engine.imageOrg` accordingly. +The bruno build lives on a private registry the platform can't reach. + +`panda-server` reaches the engine via the `kurtosis gateway` sidecar; the engine +itself is never publicly exposed. + +## Config (prod `devnet.ingress`) + +bruno → prod is config-only — no code or hostname-scheme change: + +```yaml +cluster: + name: "" # in-cluster SA + gateway sidecar; leave empty + kubeconfig_context: "" + +devnet: + package: github.com/ethpandaops/ethereum-package + docker_cache: docker.ethquokkaops.io + ingress: + enabled: true + host_style: flat # single label fits *.ethpandaops.io + base_domain: ethpandaops.io + ingress_class: ingress-nginx-devnets # the platform's devnet ingress controller + tls: false # TLS terminates at the Cloudflare edge + # local_owner unset → owner = authenticated GitHub login +``` + +> No `cert-manager.io/cluster-issuer`, no DNS-01 issuer, no TSIG, no NS +> delegation, no `tls_secret`. The Cloudflare edge cert on `*.ethpandaops.io` +> serves TLS; the existing tunnel rule routes to nginx; nginx matches the flat +> Host. That's the whole maintainability payoff. + +## Rollout + +1. **Code:** merge PR #213; tag a release → goreleaser builds the `panda` CLI and + the `panda-server`/`panda-proxy` images. +2. **Fork images:** build the kurtosis-engine fork images via the platform image + pipeline; set `engine.image`/`engine.imageOrg` in `applications/kurtosis-engine/`. +3. **Infra (GitOps):** the two ArgoCD `Application`s — `kurtosis-engine` and + `panda-server` (Deployment + SA + RBAC for Ingress + gateway sidecar) — are in + the platform PR. Nothing to add on DNS/cert/tunnel. +4. **Proxy:** point the hosted panda-proxy's devnet routes at the in-cluster + `panda-server`. +5. **CLI:** users install `panda` from the release and target the prod proxy URL. + +## Validation + +As a real GitHub user, through the proxy: +- `panda devnet up smoke --args ...` → `panda devnet endpoints smoke` +- `https://dora--smoke--qu0b.ethpandaops.io` loads in a browser with a valid cert +- `https://el-1-geth-lighthouse--smoke--qu0b.ethpandaops.io` serves + `eth_blockNumber`; WS upgrades; a large `eth_getLogs` returns +- `panda devnet down smoke` removes the enclave (and its Ingresses with it) + +## Rollback + +`devnet.ingress.enabled: false` disables Ingress creation (the devnets still run; +only external access stops). Removing the engine/`panda-server` ArgoCD apps tears +the rest down; nothing is shared with the hosted proxy's existing datasource role. diff --git a/docs/devnet.md b/docs/devnet.md new file mode 100644 index 00000000..1f71f032 --- /dev/null +++ b/docs/devnet.md @@ -0,0 +1,200 @@ +# 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 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 +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 use my-devnet # make it your default (short URLs) +panda devnet down my-devnet # or: panda devnet down --all +``` + +`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). `-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 +``` + +That layout is the **dotted** host style (`host_style: dotted`, the default). It +needs DNS that resolves arbitrary depth — true on bruno, where dnsmasq's +`*.k3s.bruno` wildcard already resolves any sub-label and routes to Traefik, with +no TLS on the trusted LAN. + +In production set `host_style: flat`, which folds the same parts into a **single +DNS label** so every host sits exactly one level under the apex: + +``` +----. # primary, e.g. dora--my-devnet--qu0b.ethpandaops.io +------. # other ports, e.g. ws--el-1-geth-lighthouse--my-devnet--qu0b.ethpandaops.io +``` + +One label is exactly what the platform's **existing** `*.ethpandaops.io` wildcard +(Cloudflare universal-SSL edge cert + cloudflare-tunnel rule → ingress-nginx-devnets) +already covers — so prod reuses that path with **zero new DNS, cert or tunnel +components**. TLS terminates at the Cloudflare edge, so panda's Ingresses serve +plain HTTP (`tls: false`). + +Your **default devnet** also gets short, enclave-less aliases — `..` +(dotted, e.g. `dora.qu0b.k3s.bruno`) or `--.` (flat, e.g. +`dora--qu0b.ethpandaops.io`). The newest `panda devnet up` becomes the default; +`panda devnet use ` switches it back to an earlier one. The +enclave-qualified URLs keep working for every devnet regardless. + +`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). It is the multi-tenant +namespace boundary — each user's devnets live under their own `` label. +Access control for the *create/manage* path is enforced at panda-server (GitHub-org +membership via the hosted proxy's OIDC); the service hosts themselves carry no +per-Ingress auth so RPC/`cast` work unauthenticated (gate at the edge — e.g. +Cloudflare Access — if you need it, including service tokens for RPC). + +The ingress is controller-agnostic: `host_style` (dotted/flat), `ingress_class`, +an `annotations` map applied verbatim to every Ingress (routing, edge auth), and a +`tls` toggle. + +```yaml +devnet: + ingress: + enabled: true + host_style: dotted # multi-label clean names + base_domain: k3s.bruno # bruno (LAN; dnsmasq *.k3s.bruno wildcard already routes to Traefik) + ingress_class: traefik + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web # plain HTTP on the trusted LAN + local_owner: qu0b # owner when the request carries no identity (lean dev) +``` + +Flip to production by changing only this block — no code change. Prod reuses the +platform's existing `*.ethpandaops.io` tunnel + edge cert + ingress-nginx-devnets, +so there's nothing new to provision: + +```yaml +devnet: + ingress: + enabled: true + host_style: flat # single label fits *.ethpandaops.io + base_domain: ethpandaops.io + ingress_class: ingress-nginx-devnets + tls: false # TLS terminates at the Cloudflare edge + # 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 +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. + +For the concrete production rollout on the ethpandaops platform (GitOps Kurtosis +engine, DNS, certs, Dex/OIDC auth, and the hosted panda-proxy), see +[`devnet-production.md`](./devnet-production.md). diff --git a/go.mod b/go.mod index 7c43b2fb..0eab85c1 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.18.3 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,26 @@ 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/api v0.36.2 + k8s.io/apimachinery v0.36.2 + 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 +59,12 @@ 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/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 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 @@ -62,21 +75,36 @@ 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 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 +112,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 +136,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 +161,8 @@ 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 golang.org/x/mod v0.35.0 // indirect @@ -137,10 +175,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/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/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..54725a00 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= @@ -35,9 +71,11 @@ 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= +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 +86,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 +115,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 +127,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= @@ -92,14 +139,22 @@ 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= 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 +163,92 @@ 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.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= +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.18.3 h1:iSEOUkvz/HprdB2mB204hTLqYxtMVt0FMNSH5/qZfeE= +github.com/kurtosis-tech/kurtosis/api/golang v1.18.3/go.mod h1:DzV5Rlt7qHIYu3ROh674q4MWgbc5zI7MJ4qeDWVuh/g= +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= @@ -143,14 +257,21 @@ 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= 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 +290,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 +320,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 +329,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 +342,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 +359,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 +372,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 +394,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 +414,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 +442,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 +541,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 +550,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 +599,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/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/cli/devnet.go b/pkg/cli/devnet.go new file mode 100644 index 00000000..51ae85b1 --- /dev/null +++ b/pkg/cli/devnet.go @@ -0,0 +1,595 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "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 + devnetLogsTail int + devnetLogsFollow 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, + devnetServicesCmd, + devnetEndpointsCmd, + devnetUseCmd, + devnetLogsCmd, + 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") + + 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 + devnetEndpointsCmd.ValidArgsFunction = completeEnclaveNames + devnetUseCmd.ValidArgsFunction = completeEnclaveNames + devnetLogsCmd.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(cmd, "devnet.up", opArgs) + if err != nil { + return err + } + + var result struct { + Enclave string `json:"enclave"` + Output string `json:"output"` + Error string `json:"error"` + Endpoints []devnet.ServiceEndpoints `json:"endpoints"` + AliasEndpoints []devnet.ServiceEndpoints `json:"alias_endpoints"` + IngressError string `json:"ingress_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, " services: panda devnet services %s\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) + + 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 len(result.AliasEndpoints) > 0 { + fmt.Fprintf(out, "\ndefault devnet (short URLs, this devnet until the next 'up' or 'devnet use'):\n") + for _, e := range result.AliasEndpoints { + 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 + }, +} + +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(cmd *cobra.Command, args []string) error { + resp, err := runServerOperation(cmd, "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, formatPorts(s.Ports), s.PrivateIP}) + } + 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 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(cmd *cobra.Command, args []string) error { + resp, err := runServerOperation(cmd, "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 devnetUseCmd = &cobra.Command{ + Use: "use ", + Short: "Make a devnet your default (claim the short . URLs)", + Long: `Point your short hostnames (.., e.g. dora.qu0b.k3s.bruno) +at this devnet. + +The newest 'panda devnet up' already becomes your default; use this to switch +back to an earlier devnet. The enclave-qualified URLs (from 'endpoints') keep +working for every devnet regardless.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := runServerOperation(cmd, "devnet.use", map[string]any{"enclave": args[0]}) + if err != nil { + return err + } + + var result struct { + Enclave string `json:"enclave"` + AliasEndpoints []devnet.ServiceEndpoints `json:"alias_endpoints"` + } + if err := decodeOperationData(resp, &result); err != nil { + return err + } + + if isJSON() { + return printJSON(result) + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Default devnet is now %q. Short URLs:\n", result.Enclave) + for _, e := range result.AliasEndpoints { + if e.PrimaryURL != "" { + fmt.Fprintf(out, " %-28s %s\n", e.Service, e.PrimaryURL) + } + } + + 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 { + 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) + for _, s := range args[1:] { + services = append(services, s) + } + opArgs["services"] = services + } + if cmd.Flags().Changed("tail") { + opArgs["tail"] = devnetLogsTail + } + + resp, err := runServerOperation(cmd, "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 + }, +} + +// 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"}, + Short: "List devnet enclaves", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + enclaves, err := fetchEnclaves(cmd) + 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(cmd *cobra.Command, args []string) error { + resp, err := runServerOperation(cmd, "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(cmd *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(cmd, "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(cmd *cobra.Command) ([]devnet.Enclave, error) { + resp, err := runServerOperation(cmd, "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(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + enclaves, err := fetchEnclaves(cmd) + 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/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/config/config.go b/pkg/config/config.go index b4d89876..a08125e0 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:"-"` } @@ -154,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. @@ -407,6 +415,14 @@ 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.HostStyle == "" { + cfg.Devnet.Ingress.HostStyle = "dotted" + } + // Storage defaults. if cfg.Storage.BaseDir == "" { cfg.Storage.BaseDir = pandaDataDir("storage") @@ -532,7 +548,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/config/devnet.go b/pkg/config/devnet.go new file mode 100644 index 00000000..604f9612 --- /dev/null +++ b/pkg/config/devnet.go @@ -0,0 +1,100 @@ +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"` + + // 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, dotted hostname: +// ... for the primary port and +// -... for the rest, so a per-enclave +// wildcard certificate (*...) covers every host. +// The owner's default devnet additionally gets a short alias +// .. (covered by a per-owner wildcard). +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 "ethpandaops.io" (prod). + BaseDomain string `yaml:"base_domain"` + + // HostStyle selects how a host is assembled below BaseDomain: + // "dotted" (default) — multi-label .... + // Clean, readable names; needs DNS that resolves arbitrary depth (a + // self-hosted/dnsmasq zone, e.g. bruno) since a *. wildcard cert + // and a single-label tunnel rule only cover one label. + // "flat" — single-label ----. (and + // ----… for non-primary ports). Folds everything into one + // DNS label so it sits exactly one level under the apex — which is what a + // *. wildcard cert and a Cloudflare tunnel rule + edge cert cover. + // Use in prod to reuse existing wildcard infra with zero new components. + HostStyle string `yaml:"host_style"` + + // IngressClass is the spec.ingressClassName set on created Ingresses, e.g. + // "traefik" (bruno) or "ingress-nginx-devnets" (prod). Defaults to "traefik". + IngressClass string `yaml:"ingress_class"` + + // Annotations are applied verbatim to every Ingress panda creates. This is + // the controller-agnostic hook for routing, TLS issuance and edge auth — set + // whatever the chosen ingress controller / cert-manager / auth layer needs, + // e.g. + // traefik: {"traefik.ingress.kubernetes.io/router.entrypoints": "web"} + // nginx: {"nginx.ingress.kubernetes.io/auth-url": "...", "cert-manager.io/cluster-issuer": "zerossl-devnet"} + Annotations map[string]string `yaml:"annotations"` + + // TLS, when true, makes panda emit a TLS section on every Ingress so the edge + // (or cert-manager) serves https and computed URLs use https. With TLSSecret + // empty, a per-Ingress secret name is derived (cert-manager issues it via the + // issuer named in Annotations). Empty/false means plain http (bruno). + TLS bool `yaml:"tls"` + + // TLSSecret optionally pins a fixed (e.g. pre-provisioned wildcard) secret for + // the canonical hosts instead of a per-Ingress cert-manager secret. Setting it + // implies TLS. + TLSSecret string `yaml:"tls_secret"` + + // AliasTLSSecret optionally pins a fixed secret (e.g. a per-owner wildcard + // *..) for the short default-devnet alias hosts. The alias hangs + // one label higher than the canonical hosts, so it may need a different cert. + // Setting it implies TLS for the alias. + AliasTLSSecret string `yaml:"alias_tls_secret"` + + // 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/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..beb8ec35 --- /dev/null +++ b/pkg/devnet/devnet.go @@ -0,0 +1,422 @@ +// 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/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" + "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" + +// 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 +} + +// 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 +} + +// 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, 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) + } + + // 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(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. + 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 { + 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 "", nil, 0, fmt.Errorf("inspecting enclave %q: %w", enclave, err) + } + + all, err := c.Services(ctx, enclave) + if err != nil { + return "", nil, 0, 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 "", nil, 0, fmt.Errorf("service %q not found in enclave %q", w, enclave) + } + } + } + if len(wanted) == 0 { + return "", nil, 0, fmt.Errorf("enclave %q has no services", enclave) + } + + tail := opts.TailLines + if tail == 0 { + tail = defaultLogTailLines + } + + return info.GetEnclaveUuid(), wanted, int64(tail), 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/devnet/ingress.go b/pkg/devnet/ingress.go new file mode 100644 index 00000000..55a63672 --- /dev/null +++ b/pkg/devnet/ingress.go @@ -0,0 +1,561 @@ +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 +} + +// boundLabel shortens an over-long DNS label deterministically. +func boundLabel(label string) string { + if len(label) > maxDNSLabel { + return shortenLabel(label) + } + + return label +} + +// dotBase prefixes a non-empty base domain with a dot (base may itself be +// multi-label, e.g. "ethpandaops.io"). +func dotBase(base string) string { + if base == "" { + return "" + } + + return "." + base +} + +// serviceHost builds a service port's hostname per the configured host style. +// +// dotted (default; dev/self-hosted DNS that resolves any depth): +// ... (primary port) +// -... (other ports) +// flat (prod behind a single-label wildcard / cloudflare tunnel + edge cert): +// ----. (primary port) +// ------. (other ports) +// +// Flat folds everything into one DNS label so it sits one level under the apex +// (what a *. wildcard cert / tunnel rule covers). +func serviceHost(portName, service, enclave, owner string, primary bool, cfg config.IngressConfig) string { + if cfg.HostStyle == "flat" { + parts := make([]string, 0, 4) + if !primary { + parts = append(parts, sanitizeLabel(portName)) + } + parts = append(parts, sanitizeLabel(service), sanitizeLabel(enclave), sanitizeLabel(owner)) + + return boundLabel(strings.Join(parts, "--")) + dotBase(cfg.BaseDomain) + } + + left := sanitizeLabel(service) + if !primary { + left = boundLabel(sanitizeLabel(portName) + "-" + sanitizeLabel(service)) + } + + return left + "." + sanitizeLabel(enclave) + "." + sanitizeLabel(owner) + dotBase(cfg.BaseDomain) +} + +// scheme returns the URL scheme implied by the ingress config for canonical +// hosts: https when TLS is enabled (or a fixed secret is pinned), http otherwise. +func scheme(cfg config.IngressConfig) string { + if cfg.TLS || cfg.TLSSecret != "" { + return "https" + } + + return "http" +} + +// copyAnnotations returns a fresh copy of the configured ingress annotations so +// callers never mutate the shared config map. These are applied verbatim to +// every Ingress, making panda controller-agnostic (Traefik, nginx, …) and the +// hook for cert-manager and edge auth. +func copyAnnotations(cfg config.IngressConfig) map[string]string { + out := make(map[string]string, len(cfg.Annotations)) + for k, v := range cfg.Annotations { + out[k] = v + } + + return out +} + +// 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 := serviceHost(p.Name, svc.Name, enclaveName, owner, isPrimary, cfg) + 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 +} + +// aliasLabel marks an ingress as a short default-devnet alias, so the alias can +// be moved atomically across a user's enclaves. +const aliasLabel = "panda.devnet/alias" + +// aliasScheme returns the URL scheme for alias hosts: https when TLS is enabled +// (or a fixed alias secret is pinned), http otherwise. +func aliasScheme(cfg config.IngressConfig) string { + if cfg.TLS || cfg.AliasTLSSecret != "" { + return "https" + } + + return "http" +} + +// aliasHostname builds the short default-devnet alias host for a service per the +// configured host style: dotted ".." (e.g. +// "dora.qu0b.k3s.bruno") or flat "--." (one label, e.g. +// "dora--qu0b.ethpandaops.io"). +func aliasHostname(service, owner string, cfg config.IngressConfig) string { + if cfg.HostStyle == "flat" { + return boundLabel(sanitizeLabel(service)+"--"+sanitizeLabel(owner)) + dotBase(cfg.BaseDomain) + } + + return sanitizeLabel(service) + "." + sanitizeLabel(owner) + dotBase(cfg.BaseDomain) +} + +// AliasEndpoints computes the short default-devnet alias URLs — one per service +// with a primary port: ... These resolve to the owner's +// current default devnet (the most recent 'up', or one pinned with 'devnet use'). +func AliasEndpoints(services []Service, owner string, cfg config.IngressConfig) []ServiceEndpoints { + sch := aliasScheme(cfg) + + out := make([]ServiceEndpoints, 0, len(services)) + for _, svc := range services { + if _, ok := primaryPort(exposedPorts(svc)); !ok { + continue + } + url := sch + "://" + aliasHostname(svc.Name, owner, cfg) + out = append(out, ServiceEndpoints{ + Service: svc.Name, + PrimaryURL: url, + Ports: []PortEndpoint{{Name: "primary", URL: url}}, + }) + } + + return out +} + +// SetDefaultAlias makes the given enclave the owner's default devnet: it removes +// any existing short-alias ingresses for the owner (across all namespaces), then +// creates .. alias ingresses (primary port) in this +// enclave's namespace. This is how 'up' (newest wins) and 'panda devnet use' +// point an owner's bare hostnames at exactly one devnet. +func SetDefaultAlias(ctx context.Context, enclaveUUID, 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 + } + + if err := clearAliasIngresses(ctx, clientset, owner); err != nil { + return err + } + + for _, svc := range services { + ingress := buildAliasIngress(namespace, enclaveUUID, owner, svc, cfg) + if ingress == nil { + continue + } + if err := upsertIngress(ctx, clientset, namespace, ingress); err != nil { + return fmt.Errorf("creating alias ingress for service %q: %w", svc.Name, err) + } + } + + return nil +} + +// clearAliasIngresses deletes every panda alias ingress for the owner across all +// namespaces, so the default alias only ever resolves to one devnet. +func clearAliasIngresses(ctx context.Context, clientset kubernetes.Interface, owner string) error { + selector := fmt.Sprintf("app.kubernetes.io/managed-by=panda,%s=true,panda.devnet/owner=%s", + aliasLabel, sanitizeLabel(owner)) + + list, err := clientset.NetworkingV1().Ingresses("").List(ctx, metav1.ListOptions{LabelSelector: selector}) + if err != nil { + return fmt.Errorf("listing alias ingresses for owner %q: %w", owner, err) + } + + for i := range list.Items { + ing := &list.Items[i] + if err := clientset.NetworkingV1().Ingresses(ing.Namespace).Delete(ctx, ing.Name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("deleting alias ingress %s/%s: %w", ing.Namespace, ing.Name, err) + } + } + + return nil +} + +// buildAliasIngress constructs the short-alias Ingress for one service (host +// .. -> primary port), or nil when the service has no +// primary port. +func buildAliasIngress(namespace, enclaveUUID, owner string, svc Service, cfg config.IngressConfig) *networkingv1.Ingress { + primary, ok := primaryPort(exposedPorts(svc)) + if !ok { + return nil + } + + pathType := networkingv1.PathTypePrefix + ingressClass := cfg.IngressClass + h := aliasHostname(svc.Name, owner, cfg) + + annotations := copyAnnotations(cfg) + + spec := networkingv1.IngressSpec{ + IngressClassName: &ingressClass, + Rules: []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(primary.Number)}, + }, + }, + }}, + }, + }, + }}, + } + if cfg.TLS || cfg.AliasTLSSecret != "" { + secret := cfg.AliasTLSSecret + if secret == "" { + secret = "panda-alias-" + sanitizeLabel(svc.Name) + "-tls" + } + spec.TLS = []networkingv1.IngressTLS{{Hosts: []string{h}, SecretName: secret}} + } + + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "panda-alias-" + sanitizeLabel(svc.Name), + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "panda", + "panda.devnet/owner": sanitizeLabel(owner), + "panda.devnet/enclave": enclaveUUID, + aliasLabel: "true", + }, + Annotations: annotations, + }, + Spec: spec, + } +} + +// 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 := serviceHost(p.Name, svc.Name, enclaveName, owner, isPrimary, cfg) + hosts = append(hosts, h) + rules = append(rules, rule(h, p.Number)) + } + + annotations := copyAnnotations(cfg) + + spec := networkingv1.IngressSpec{ + IngressClassName: &ingressClass, + Rules: rules, + } + if cfg.TLS || cfg.TLSSecret != "" { + secret := cfg.TLSSecret + if secret == "" { + secret = "panda-" + sanitizeLabel(svc.Name) + "-tls" + } + spec.TLS = []networkingv1.IngressTLS{{Hosts: hosts, SecretName: secret}} + } + + 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..f4fd83dd --- /dev/null +++ b/pkg/devnet/ingress_test.go @@ -0,0 +1,325 @@ +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 TestServiceHost(t *testing.T) { + dotted := config.IngressConfig{HostStyle: "dotted", BaseDomain: "k3s.bruno"} + flat := config.IngressConfig{HostStyle: "flat", BaseDomain: "ethpandaops.io"} + + t.Run("dotted primary -> ...", func(t *testing.T) { + assert.Equal(t, "dora.bal3.qu0b.k3s.bruno", + serviceHost("http", "dora", "bal3", "qu0b", true, dotted)) + }) + + t.Run("dotted non-primary -> -...", func(t *testing.T) { + assert.Equal(t, "metrics-dora.bal3.qu0b.k3s.bruno", + serviceHost("metrics", "dora", "bal3", "qu0b", false, dotted)) + }) + + t.Run("dotted sanitizes each label; base appended verbatim", func(t *testing.T) { + cfg := config.IngressConfig{HostStyle: "dotted", BaseDomain: "devnet.ethpandaops.io"} + assert.Equal(t, "x.my-dev.q-u-0b.devnet.ethpandaops.io", + serviceHost("rpc", "x", "my.dev", "q.u_0b", true, cfg)) + }) + + t.Run("flat primary -> single label ----.", func(t *testing.T) { + got := serviceHost("http", "dora", "bal3", "qu0b", true, flat) + assert.Equal(t, "dora--bal3--qu0b.ethpandaops.io", got) + // Everything below the base is one DNS label (no dots). + assert.Equal(t, "dora--bal3--qu0b", strings.SplitN(got, ".", 2)[0]) + }) + + t.Run("flat non-primary -> ------.", func(t *testing.T) { + assert.Equal(t, "metrics--dora--bal3--qu0b.ethpandaops.io", + serviceHost("metrics", "dora", "bal3", "qu0b", false, flat)) + }) + + t.Run("flat over-long label is shortened to one DNS label", func(t *testing.T) { + got := serviceHost("metrics", strings.Repeat("x", 80), "bal3", "qu0b", false, flat) + label := strings.SplitN(got, ".", 2)[0] + require.LessOrEqual(t, len(label), maxDNSLabel) + }) + + t.Run("empty base omits trailing dot", func(t *testing.T) { + cfg := config.IngressConfig{HostStyle: "dotted"} + assert.Equal(t, "dora.bal3.qu0b", serviceHost("http", "dora", "bal3", "qu0b", true, cfg)) + }) +} + +func TestIsExposed(t *testing.T) { + cases := []struct { + port Port + want bool + }{ + {Port{Name: "rpc"}, true}, + {Port{Name: "ws"}, true}, + {Port{Name: "http"}, true}, + {Port{Name: "api"}, true}, + {Port{Name: "metrics"}, true}, + {Port{Name: "custom", Application: "http"}, true}, + {Port{Name: "custom", Application: "ws"}, true}, + {Port{Name: "engine-rpc"}, false}, + {Port{Name: "tcp-discovery"}, false}, + {Port{Name: "udp-discovery"}, false}, + {Port{Name: "quic-discovery"}, false}, + {Port{Name: "p2p"}, false}, + {Port{Name: "udp"}, false}, + } + for _, c := range cases { + assert.Equalf(t, c.want, isExposed(c.port), "port %+v", c.port) + } +} + +func TestPrimaryPort(t *testing.T) { + t.Run("rpc wins", func(t *testing.T) { + p, ok := primaryPort([]Port{{Name: "metrics"}, {Name: "http"}, {Name: "rpc"}}) + require.True(t, ok) + assert.Equal(t, "rpc", p.Name) + }) + + t.Run("http when no rpc", func(t *testing.T) { + p, ok := primaryPort([]Port{{Name: "metrics"}, {Name: "http"}}) + require.True(t, ok) + assert.Equal(t, "http", p.Name) + }) + + t.Run("first http application when no rpc/http names", func(t *testing.T) { + p, ok := primaryPort([]Port{{Name: "metrics"}, {Name: "ui", Application: "http"}}) + require.True(t, ok) + assert.Equal(t, "ui", p.Name) + }) + + t.Run("first exposed otherwise", func(t *testing.T) { + p, ok := primaryPort([]Port{{Name: "metrics"}, {Name: "ws"}}) + require.True(t, ok) + assert.Equal(t, "metrics", p.Name) + }) + + t.Run("none when empty", func(t *testing.T) { + _, ok := primaryPort(nil) + assert.False(t, ok) + }) +} + +func TestEndpoints(t *testing.T) { + services := []Service{ + { + Name: "el-1-geth-lighthouse", + Ports: []Port{ + {Name: "engine-rpc", Number: 8551}, + {Name: "metrics", Number: 9001}, + {Name: "rpc", Number: 8545, Application: "http"}, + }, + }, + { + Name: "dora", + Ports: []Port{{Name: "http", Number: 8080, Application: "http"}}, + }, + { + // No exposed ports -> omitted. + Name: "validator-key-generation", + Ports: []Port{{Name: "p2p", Number: 30303}}, + }, + } + + t.Run("http when no TLS secret", func(t *testing.T) { + cfg := config.IngressConfig{BaseDomain: "k3s.bruno"} + eps := Endpoints(services, "mydevnet", "qu0b", cfg) + + require.Len(t, eps, 2) + + el := eps[0] + assert.Equal(t, "el-1-geth-lighthouse", el.Service) + // rpc is primary -> clean bare host. + assert.Equal(t, "http://el-1-geth-lighthouse.mydevnet.qu0b.k3s.bruno", el.PrimaryURL) + // engine-rpc is skipped; rpc + metrics exposed. + var names []string + for _, p := range el.Ports { + names = append(names, p.Name) + } + assert.ElementsMatch(t, []string{"rpc", "metrics"}, names) + for _, p := range el.Ports { + switch p.Name { + case "rpc": // primary -> bare host, same as PrimaryURL + assert.Equal(t, "http://el-1-geth-lighthouse.mydevnet.qu0b.k3s.bruno", p.URL) + case "metrics": // non-primary -> - left label + assert.Equal(t, "http://metrics-el-1-geth-lighthouse.mydevnet.qu0b.k3s.bruno", p.URL) + } + } + + dora := eps[1] + assert.Equal(t, "http://dora.mydevnet.qu0b.k3s.bruno", dora.PrimaryURL) + }) + + t.Run("https when TLS secret set", func(t *testing.T) { + cfg := config.IngressConfig{BaseDomain: "devnet.ethpandaops.io", TLSSecret: "wildcard"} + eps := Endpoints(services, "mydevnet", "42", cfg) + + require.Len(t, eps, 2) + assert.Equal(t, "https://el-1-geth-lighthouse.mydevnet.42.devnet.ethpandaops.io", eps[0].PrimaryURL) + for _, p := range eps[0].Ports { + assert.True(t, strings.HasPrefix(p.URL, "https://")) + } + }) +} + +func TestBuildIngress(t *testing.T) { + cfg := config.IngressConfig{ + BaseDomain: "k3s.bruno", + IngressClass: "traefik", + Annotations: map[string]string{ + "traefik.ingress.kubernetes.io/router.entrypoints": "web", + "cert-manager.io/cluster-issuer": "zerossl-devnet", + }, + TLSSecret: "wildcard", + } + svc := Service{ + Name: "el-1", + Ports: []Port{ + {Name: "engine-rpc", Number: 8551}, + {Name: "rpc", Number: 8545, Application: "http"}, + {Name: "ws", Number: 8546, Application: "ws"}, + }, + } + + ing := buildIngress("ns", "uuid-1", "dev", "qu0b", svc, cfg) + require.NotNil(t, ing) + + assert.Equal(t, "panda-el-1", ing.Name) + assert.Equal(t, "ns", ing.Namespace) + assert.Equal(t, "panda", ing.Labels["app.kubernetes.io/managed-by"]) + assert.Equal(t, "qu0b", ing.Labels["panda.devnet/owner"]) + assert.Equal(t, "uuid-1", ing.Labels["panda.devnet/enclave"]) + // Configured annotations are applied verbatim (controller-agnostic). + assert.Equal(t, "web", ing.Annotations["traefik.ingress.kubernetes.io/router.entrypoints"]) + assert.Equal(t, "zerossl-devnet", ing.Annotations["cert-manager.io/cluster-issuer"]) + require.NotNil(t, ing.Spec.IngressClassName) + assert.Equal(t, "traefik", *ing.Spec.IngressClassName) + + // engine-rpc skipped; rpc (primary -> bare host) + ws (-) = 2 rules. + require.Len(t, ing.Spec.Rules, 2) + assert.Equal(t, "el-1.dev.qu0b.k3s.bruno", ing.Spec.Rules[0].Host) + assert.Equal(t, int32(8545), ing.Spec.Rules[0].HTTP.Paths[0].Backend.Service.Port.Number) + assert.Equal(t, "ws-el-1.dev.qu0b.k3s.bruno", ing.Spec.Rules[1].Host) + assert.Equal(t, int32(8546), ing.Spec.Rules[1].HTTP.Paths[0].Backend.Service.Port.Number) + + // TLS covers all hosts. + require.Len(t, ing.Spec.TLS, 1) + assert.Equal(t, "wildcard", ing.Spec.TLS[0].SecretName) + assert.ElementsMatch(t, + []string{"el-1.dev.qu0b.k3s.bruno", "ws-el-1.dev.qu0b.k3s.bruno"}, + ing.Spec.TLS[0].Hosts) + + t.Run("no exposed ports yields nil", func(t *testing.T) { + nilIng := buildIngress("ns", "uuid", "dev", "qu0b", + Service{Name: "x", Ports: []Port{{Name: "p2p"}}}, cfg) + assert.Nil(t, nilIng) + }) + + t.Run("no TLS disables TLS", func(t *testing.T) { + plain := buildIngress("ns", "uuid", "dev", "qu0b", svc, + config.IngressConfig{BaseDomain: "k3s.bruno", IngressClass: "traefik"}) + require.NotNil(t, plain) + assert.Empty(t, plain.Spec.TLS) + assert.Empty(t, plain.Annotations) + }) + + t.Run("TLS without a fixed secret derives a per-ingress secret", func(t *testing.T) { + auto := buildIngress("ns", "uuid", "dev", "qu0b", svc, + config.IngressConfig{BaseDomain: "k3s.bruno", IngressClass: "nginx", TLS: true}) + require.NotNil(t, auto) + require.Len(t, auto.Spec.TLS, 1) + assert.Equal(t, "panda-el-1-tls", auto.Spec.TLS[0].SecretName) + }) +} + +func TestAliasHostname(t *testing.T) { + dotted := config.IngressConfig{HostStyle: "dotted", BaseDomain: "k3s.bruno"} + assert.Equal(t, "dora.qu0b.k3s.bruno", aliasHostname("dora", "qu0b", dotted)) + assert.Equal(t, "el-1-geth.q-u-0b.devnet.ethpandaops.io", + aliasHostname("el-1-geth", "q.u_0b", + config.IngressConfig{HostStyle: "dotted", BaseDomain: "devnet.ethpandaops.io"})) + assert.Equal(t, "dora.qu0b", aliasHostname("dora", "qu0b", config.IngressConfig{HostStyle: "dotted"})) + + // Flat: single label --.. + flat := config.IngressConfig{HostStyle: "flat", BaseDomain: "ethpandaops.io"} + assert.Equal(t, "dora--qu0b.ethpandaops.io", aliasHostname("dora", "qu0b", flat)) +} + +func TestAliasEndpoints(t *testing.T) { + services := []Service{ + {Name: "dora", Ports: []Port{{Name: "http", Number: 8080, Application: "http"}}}, + {Name: "novel", Ports: []Port{{Name: "p2p", Number: 30303}}}, // no primary -> omitted + } + + t.Run("http when no alias TLS", func(t *testing.T) { + eps := AliasEndpoints(services, "qu0b", config.IngressConfig{BaseDomain: "k3s.bruno"}) + require.Len(t, eps, 1) + assert.Equal(t, "dora", eps[0].Service) + assert.Equal(t, "http://dora.qu0b.k3s.bruno", eps[0].PrimaryURL) + }) + + t.Run("https when alias TLS set", func(t *testing.T) { + eps := AliasEndpoints(services, "qu0b", + config.IngressConfig{BaseDomain: "devnet.ethpandaops.io", AliasTLSSecret: "w"}) + require.Len(t, eps, 1) + assert.Equal(t, "https://dora.qu0b.devnet.ethpandaops.io", eps[0].PrimaryURL) + }) +} + +func TestBuildAliasIngress(t *testing.T) { + cfg := config.IngressConfig{BaseDomain: "k3s.bruno", IngressClass: "traefik"} + svc := Service{Name: "el-1", Ports: []Port{ + {Name: "engine-rpc", Number: 8551}, + {Name: "rpc", Number: 8545, Application: "http"}, + }} + + ing := buildAliasIngress("ns", "uuid-1", "qu0b", svc, cfg) + require.NotNil(t, ing) + assert.Equal(t, "panda-alias-el-1", ing.Name) + assert.Equal(t, "true", ing.Labels[aliasLabel]) + assert.Equal(t, "qu0b", ing.Labels["panda.devnet/owner"]) + require.Len(t, ing.Spec.Rules, 1) + assert.Equal(t, "el-1.qu0b.k3s.bruno", ing.Spec.Rules[0].Host) + assert.Equal(t, int32(8545), ing.Spec.Rules[0].HTTP.Paths[0].Backend.Service.Port.Number) + + nilIng := buildAliasIngress("ns", "u", "qu0b", + Service{Name: "x", Ports: []Port{{Name: "p2p"}}}, cfg) + assert.Nil(t, nilIng) +} diff --git a/pkg/devnet/logs.go b/pkg/devnet/logs.go new file mode 100644 index 00000000..3ebf174a --- /dev/null +++ b/pkg/devnet/logs.go @@ -0,0 +1,154 @@ +package devnet + +import ( + "bufio" + "context" + "fmt" + "io" + "sync" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "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 { + 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) + } + } + + return nil +} + +// 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) + 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() { + write(fmt.Sprintf("%s | %s", 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) { + // In-cluster first (the prod panda-server runs as a pod with a + // ServiceAccount); fall back to a kubeconfig for local/bruno use. + cfg, err := rest.InClusterConfig() + if err != nil { + cfg, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{}, + ).ClientConfig() + if err != nil { + return nil, fmt.Errorf("loading Kubernetes config (no in-cluster config and no 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/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/api.go b/pkg/server/api.go index 5980160b..08760709 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) @@ -858,6 +862,25 @@ func authOwnerID(r *http.Request) string { return fmt.Sprintf("%d", user.GitHubID) } +// authOwnerLogin returns the caller's GitHub login — preferred for the +// human-readable, owner-scoped devnet hostnames (dora.qu0b.… not dora.583231.…) +// — falling back to the numeric ID, or "" when unauthenticated. Like +// authOwnerID it is server-derived from the request, never client-supplied. +func authOwnerLogin(r *http.Request) string { + user := auth.GetAuthUser(r.Context()) + if user == nil { + return "" + } + if user.GitHubLogin != "" { + return user.GitHubLogin + } + if user.GitHubID != 0 { + return fmt.Sprintf("%d", user.GitHubID) + } + + return "" +} + func parseOptionalInt(r *http.Request, key string) (int, error) { value := strings.TrimSpace(r.URL.Query().Get(key)) if value == "" { diff --git a/pkg/server/builder.go b/pkg/server/builder.go index 73ebe02f..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 @@ -169,6 +178,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..ac572800 --- /dev/null +++ b/pkg/server/operations_devnet.go @@ -0,0 +1,553 @@ +package server + +import ( + "bytes" + "fmt" + "net/http" + "strconv" + "strings" + + "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.services": + s.handleDevnetServices(w, r) + case "devnet.endpoints": + s.handleDevnetEndpoints(w, r) + case "devnet.use": + s.handleDevnetUse(w, r) + case "devnet.logs": + s.handleDevnetLogs(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 + } + + // Expose the devnet's services externally (owner-scoped Traefik Ingresses) + // and surface the computed endpoints. Reconcile failures must NOT fail 'up': + // the devnet is already running, so we log a warning and report the error in + // the response rather than aborting. + if s.devnetCfg.Ingress.Enabled { + ownerID := s.resolveOwner(r) + + svcs, svcErr := client.Services(r.Context(), enclaveName) + if svcErr != nil { + s.log.WithError(svcErr).WithField("enclave", enclaveName).Warn("ingress: listing services failed") + data["ingress_error"] = svcErr.Error() + } else { + data["endpoints"] = devnet.Endpoints(svcs, enclaveName, ownerID, s.devnetCfg.Ingress) + + // ReconcileIngresses needs the enclave UUID (the namespace label), + // which Up does not return — resolve it via Inspect. + info, inspErr := client.Inspect(r.Context(), enclaveName) + if inspErr != nil { + s.log.WithError(inspErr).WithField("enclave", enclaveName).Warn("ingress: resolving enclave UUID failed") + data["ingress_error"] = inspErr.Error() + } else if recErr := devnet.ReconcileIngresses(r.Context(), info.UUID, enclaveName, ownerID, svcs, s.devnetCfg.Ingress); recErr != nil { + s.log.WithError(recErr).WithField("enclave", enclaveName).Warn("ingress: reconcile failed") + data["ingress_error"] = recErr.Error() + } else if aliasErr := devnet.SetDefaultAlias(r.Context(), info.UUID, ownerID, svcs, s.devnetCfg.Ingress); aliasErr != nil { + // The newest 'up' becomes the owner's default devnet (its bare + // . hostnames). Non-fatal like the rest. + s.log.WithError(aliasErr).WithField("enclave", enclaveName).Warn("ingress: setting default alias failed") + data["ingress_error"] = aliasErr.Error() + } else { + data["alias_endpoints"] = devnet.AliasEndpoints(svcs, ownerID, s.devnetCfg.Ingress) + } + } + } + + writeOperationResponse(s.log, w, http.StatusOK, operations.Response{ + Kind: "devnet.up", + Data: data, + Meta: map[string]any{"success": true}, + }) +} + +// resolveOwner derives the owner used to scope a devnet's ingress hostnames. It +// is always server-derived, never taken from client-supplied args: the +// authenticated GitHub identity in production, the configured LocalOwner in +// bruno/lean dev, or "local" as a last resort. +func (s *service) resolveOwner(r *http.Request) string { + // Prefer the GitHub login for readable owner-scoped hostnames; fall back to + // the configured local owner (bruno/lean dev) then "local". + if owner := authOwnerLogin(r); owner != "" { + return owner + } + if owner := s.devnetCfg.Ingress.LocalOwner; owner != "" { + return owner + } + + return "local" +} + +// handleDevnetEndpoints returns the external URLs for a running devnet's +// services (the primary URL and per-port URLs). It mirrors handleDevnetServices: +// the enclave arg is required (400 if missing, before any engine call), and the +// owner is resolved server-side. +func (s *service) handleDevnetEndpoints(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 + } + + owner := s.resolveOwner(r) + + 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.endpoints", + Data: devnet.Endpoints(svcs, enclave, owner, s.devnetCfg.Ingress), + }) +} + +// handleDevnetUse makes the given enclave the caller's default devnet: it points +// the owner's short .. hostnames at this enclave (moving +// them off any other devnet of the same owner). Owner is server-derived. +func (s *service) handleDevnetUse(w http.ResponseWriter, r *http.Request) { + if !s.devnetCfg.Ingress.Enabled { + writeAPIError(w, http.StatusBadRequest, "devnet ingress is not enabled") + return + } + + 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 + } + + owner := s.resolveOwner(r) + + 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 + } + + svcs, err := client.Services(r.Context(), enclave) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + + if err := devnet.SetDefaultAlias(r.Context(), info.UUID, owner, svcs, s.devnetCfg.Ingress); err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + + writeOperationResponse(s.log, w, http.StatusOK, operations.Response{ + Kind: "devnet.use", + Data: map[string]any{ + "enclave": info.Name, + "alias_endpoints": devnet.AliasEndpoints(svcs, owner, s.devnetCfg.Ingress), + }, + }) +} + +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) 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()}, + }) +} + +// 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 { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + all, err := optionalBoolArg(req.Args, "all") + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + // Validate the target before connecting to the engine, so a missing + // enclave/all is a 400 rather than a 502 when the engine is unreachable. + var enclave string + if !boolOrFalse(all) { + enclave, err = requiredStringArg(req.Args, "enclave") + if err != nil { + writeAPIError(w, http.StatusBadRequest, "enclave is required, or pass all=true") + 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 { + 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..1a3c2b48 --- /dev/null +++ b/pkg/server/operations_devnet_test.go @@ -0,0 +1,252 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "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) +} + +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_EndpointsRequiresEnclave(t *testing.T) { + s := newDevnetTestService() + // The 400 must come from requiredStringArg before any engine call. + w := callDevnet(s, "devnet.endpoints", map[string]any{}) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestHandleDevnetOperation_UseRequiresEnclave(t *testing.T) { + s := newDevnetTestService() + s.devnetCfg = config.DevnetConfig{Ingress: config.IngressConfig{Enabled: true}} + // Ingress is enabled, so the 400 must come from the missing enclave arg. + w := callDevnet(s, "devnet.use", map[string]any{}) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestHandleDevnetOperation_UseRequiresIngressEnabled(t *testing.T) { + s := newDevnetTestService() // ingress disabled by default + w := callDevnet(s, "devnet.use", map[string]any{"enclave": "x"}) + 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. +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. +} + +// 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) + + // 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()) + t.Log("lifecycle ok: up → ls → inspect → down") +} 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,