diff --git a/AGENTS.md b/AGENTS.md index 36298517..7b9cfeb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ uv run python -m scripts.repl ### Module System -Twelve compiled-in modules are registered in `pkg/app/app.go`: +Thirteen compiled-in modules are registered in `pkg/app/app.go`: - `clickhouse` - `prometheus` - `loki` @@ -106,6 +106,7 @@ Twelve compiled-in modules are registered in `pkg/app/app.go`: - `ethnode` - `cbt` - `benchmarkoor` +- `buildoor` (devnet builder instances: per-slot action plans, jq transforms, slot outcomes) - `tracoor` - `block_archive` - `datasets` (dataset knowledge packs, lives in `datasets/` at the repo root) @@ -148,6 +149,12 @@ CLI commands and groups include: - `benchmarkoor` - `block-archive` - `build` (GitHub Actions Docker image builder) +- `buildoor` — per-slot action plans on devnet buildoor instances (jq payload/bid/envelope + transforms, plan/results inspection); server-side ops resolve instances via the + network's buildoor overview service. Reads are open and go direct; mutations route + through a proxy advertising buildoor (the proxy holds a CF Access service token and + mints per-devnet authenticatoor JWTs), or direct with an explicit caller token. + The same operations back the sandbox Python module (`from ethpandaops import buildoor`) - `cbt` - `clickhouse` - `config` @@ -264,6 +271,7 @@ modules/ ethnode/ # Ethnode module cbt/ # CBT module benchmarkoor/ # Benchmarkoor module + buildoor/ # Buildoor module (per-slot action plans on devnet builders) tracoor/ # Tracoor module block_archive/ # Block archive module runbooks/ # Embedded markdown runbooks diff --git a/modules/buildoor/examples.go b/modules/buildoor/examples.go new file mode 100644 index 00000000..2f616db4 --- /dev/null +++ b/modules/buildoor/examples.go @@ -0,0 +1,30 @@ +package buildoor + +import ( + _ "embed" + "fmt" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/ethpandaops/panda/pkg/types" +) + +//go:embed examples.yaml +var examplesYAML []byte + +var queryExamples map[string]types.ExampleCategory + +func init() { + if err := yaml.Unmarshal(examplesYAML, &queryExamples); err != nil { + panic(fmt.Sprintf("failed to parse buildoor examples.yaml: %v", err)) + } + + for key, category := range queryExamples { + for i := range category.Examples { + category.Examples[i].Query = strings.TrimSpace(category.Examples[i].Query) + } + + queryExamples[key] = category + } +} diff --git a/modules/buildoor/examples.yaml b/modules/buildoor/examples.yaml new file mode 100644 index 00000000..523aa60a --- /dev/null +++ b/modules/buildoor/examples.yaml @@ -0,0 +1,57 @@ +buildoor_action_plans: + name: Buildoor Action Plans + description: Discover devnet builder instances, hot-patch payloads with jq transforms, verify slot outcomes + examples: + - name: Discover buildoor instances + description: List networks with buildoor deployments and each network's builder instances + query: | + from ethpandaops import buildoor + + networks = buildoor.list_networks() + print(f"Networks with buildoor: {[n['name'] for n in networks]}") + + instances = buildoor.list_instances(networks[0]["name"]) + for inst in instances: + overview = buildoor.get_overview(networks[0]["name"], inst["name"]) + print(f"{inst['name']}: current_slot={overview.get('current_slot')}") + + - name: Validate and apply a payload transform + description: Test a jq expression (e.g. gas limit hot-patch), then set it on future slots + query: | + from ethpandaops import buildoor + + network, instance = "glamsterdam-devnet-7", "lighthouse-geth-1" + + # Dry-run the expression through buildoor's production jq path first. + result = buildoor.test_transform(network, instance, "payload", ".gas_limit = 300000000") + assert not result.get("error"), result + print(f"validated against {result['input_source']}") + + # The panda proxy credentials the mutation when it advertises buildoor; + # pass token=... with a personal authenticatoor bearer to go direct. + # Plans freeze ~1 slot ahead: target slots at least 2 ahead of current. + current = buildoor.get_overview(network, instance)["current_slot"] + applied = buildoor.set_transforms( + network, instance, + slots=[current + 2, current + 3], + payload=".gas_limit = 300000000", + ) + print(f"planned slots: {applied['slots']}") + + - name: Verify what a planned slot did + description: Read the attempt-level outcome (build, bids, reveals, inclusion) and the frozen plan a slot ran with + query: | + from ethpandaops import buildoor + + network, instance = "glamsterdam-devnet-7", "lighthouse-geth-1" + slot = 65000 # a slot that already executed + + results = buildoor.get_slot_results(network, instance, slot, slot) + for record in results["results"]: + build = record.get("build") or {} + print(f"slot {record['slot']}: build={build.get('status')}") + # For a payload transform like .gas_limit = N, confirm it landed: + print(f" built gas_limit: {build.get('gas_limit')}") + print(f" bids: {len(record.get('bids', []))}, " + f"reveals: {len(record.get('reveal_attempts', []))}, " + f"included: {record.get('inclusion') is not None}") diff --git a/modules/buildoor/module.go b/modules/buildoor/module.go new file mode 100644 index 00000000..b5bc562e --- /dev/null +++ b/modules/buildoor/module.go @@ -0,0 +1,119 @@ +// Package buildoor exposes devnet buildoor builder instances to the sandbox: +// per-slot action plans, jq transforms, and slot outcome history over the +// server-side buildoor.* operations. +package buildoor + +import ( + "context" + "encoding/json" + "fmt" + "maps" + + "github.com/ethpandaops/panda/pkg/cartographoor" + "github.com/ethpandaops/panda/pkg/module" + "github.com/ethpandaops/panda/pkg/types" +) + +// Compile-time interface checks. +var ( + _ module.Module = (*Module)(nil) + _ module.DefaultEnabled = (*Module)(nil) + _ module.CartographoorAware = (*Module)(nil) + _ module.SandboxEnvProvider = (*Module)(nil) + _ module.DatasourceInfoProvider = (*Module)(nil) + _ module.ExamplesProvider = (*Module)(nil) + _ module.PythonAPIDocsProvider = (*Module)(nil) +) + +// Module implements the module.Module interface for the buildoor module. +type Module struct { + cartographoorClient cartographoor.CartographoorClient +} + +// New creates a new buildoor module. +func New() *Module { + return &Module{} +} + +func (m *Module) Name() string { return "buildoor" } + +// DefaultEnabled implements module.DefaultEnabled. +// Buildoor is enabled by default since it requires no configuration. +func (m *Module) DefaultEnabled() bool { return true } + +func (m *Module) Init(_ []byte) error { return nil } + +func (m *Module) ApplyDefaults() {} + +func (m *Module) Validate() error { return nil } + +// SandboxEnv returns ETHPANDAOPS_BUILDOOR_NETWORKS with the network->overview +// URL mapping from cartographoor, so sandbox code can fail fast when no +// buildoor deployments exist. +func (m *Module) SandboxEnv() (map[string]string, error) { + if m.cartographoorClient == nil { + return nil, nil + } + + networks := m.cartographoorClient.GetActiveNetworks() + buildoorNetworks := make(map[string]string, len(networks)) + + for name, network := range networks { + if network.ServiceURLs != nil && network.ServiceURLs.Buildoor != "" { + buildoorNetworks[name] = network.ServiceURLs.Buildoor + } + } + + if len(buildoorNetworks) == 0 { + return nil, nil + } + + networksJSON, err := json.Marshal(buildoorNetworks) + if err != nil { + return nil, fmt.Errorf("marshaling buildoor networks: %w", err) + } + + return map[string]string{ + "ETHPANDAOPS_BUILDOOR_NETWORKS": string(networksJSON), + }, nil +} + +// DatasourceInfo returns empty since networks are the datasources, +// and those come from cartographoor. +func (m *Module) DatasourceInfo() []types.DatasourceInfo { + return nil +} + +func (m *Module) Examples() map[string]types.ExampleCategory { + result := make(map[string]types.ExampleCategory, len(queryExamples)) + maps.Copy(result, queryExamples) + + return result +} + +func (m *Module) PythonAPIDocs() map[string]types.ModuleDoc { + return map[string]types.ModuleDoc{ + "buildoor": { + Description: "Drive devnet buildoor builder instances: per-slot action plans, jq transforms, slot outcomes", + Functions: map[string]types.FunctionDoc{ + "list_networks": {Signature: "list_networks() -> list[dict]", Description: "List networks with buildoor deployments"}, + "list_instances": {Signature: "list_instances(network) -> list[dict]", Description: "List a network's builder instances (name, url)"}, + "get_overview": {Signature: "get_overview(network, instance) -> dict", Description: "Instance status incl. current_slot and service states"}, + "get_action_plan": {Signature: "get_action_plan(network, instance, min_slot, max_slot) -> dict", Description: "Per-slot action plans in the inclusive range"}, + "get_slot_results": {Signature: "get_slot_results(network, instance, min_slot, max_slot) -> dict", Description: "Attempt-level outcome history (build, bids, reveals, inclusion, applied plan)"}, + "test_transform": {Signature: "test_transform(network, instance, target, expression, sample_slot=None) -> dict", Description: "Evaluate a jq expression against a sample payload/bid/envelope without touching any plan"}, + "update_action_plan": {Signature: "update_action_plan(network, instance, updates, token=None) -> dict", Description: "Apply raw PlanUpdate mutations; credentialed by the proxy, or direct with an authenticatoor bearer token"}, + "set_transforms": {Signature: "set_transforms(network, instance, token=None, slots=None, from_slot=None, to_slot=None, payload=None, bid=None, envelope=None) -> dict", Description: "Set jq transforms on future slots (>=2 ahead; '' clears one expression)"}, + }, + }, + } +} + +// SetCartographoorClient implements module.CartographoorAware. +func (m *Module) SetCartographoorClient(client cartographoor.CartographoorClient) { + m.cartographoorClient = client +} + +func (m *Module) Start(_ context.Context) error { return nil } + +func (m *Module) Stop(_ context.Context) error { return nil } diff --git a/modules/buildoor/python/buildoor.py b/modules/buildoor/python/buildoor.py new file mode 100644 index 00000000..c3c0f9cb --- /dev/null +++ b/modules/buildoor/python/buildoor.py @@ -0,0 +1,155 @@ +"""Thin buildoor wrappers over server operations. + +Drives devnet buildoor builder instances: per-slot action plans, jq payload/ +bid/envelope transforms, and slot outcome history. Reads are open. Mutations +are credentialed by the panda proxy when it advertises buildoor; an explicit +authenticatoor bearer token instead goes direct and keeps per-user attribution +in buildoor's audit log. Plans freeze ~1 slot ahead — target slots at least 2 +ahead of current. +""" + +from __future__ import annotations + +import os +from typing import Any + +from ethpandaops import _runtime + + +def _require_buildoor_available() -> None: + if not os.environ.get("ETHPANDAOPS_BUILDOOR_NETWORKS", "").strip(): + raise ValueError("Buildoor is not enabled or no buildoor deployments are available.") + + +def list_networks() -> list[dict[str, Any]]: + """List networks with buildoor deployments. + + Each entry has keys: name, description, url, type, extra. + """ + _require_buildoor_available() + data = _runtime.invoke_data("buildoor.list_networks") + return data.get("networks", []) + + +def list_instances(network: str) -> list[dict[str, Any]]: + """List a network's builder instances. Each entry has keys: name, url.""" + _require_buildoor_available() + data = _runtime.invoke_data("buildoor.list_instances", {"network": network}) + return data.get("instances", []) + + +def get_overview(network: str, instance: str) -> dict[str, Any]: + """Instance status: current_slot, running, service states, builder pubkey.""" + _require_buildoor_available() + payload = _runtime.invoke_json( + "buildoor.get_overview", {"network": network, "instance": instance} + ) + return payload if isinstance(payload, dict) else {} + + +def get_action_plan( + network: str, instance: str, min_slot: int, max_slot: int +) -> dict[str, Any]: + """Per-slot action plans in the inclusive range: {plans, min_slot, max_slot}.""" + _require_buildoor_available() + payload = _runtime.invoke_json( + "buildoor.get_action_plan", + {"network": network, "instance": instance, "min_slot": min_slot, "max_slot": max_slot}, + ) + return payload if isinstance(payload, dict) else {} + + +def get_slot_results( + network: str, instance: str, min_slot: int, max_slot: int +) -> dict[str, Any]: + """Attempt-level outcome history per slot: build, bids, block submissions, + reveal attempts, inclusion, and the frozen applied_plan.""" + _require_buildoor_available() + payload = _runtime.invoke_json( + "buildoor.get_slot_results", + {"network": network, "instance": instance, "min_slot": min_slot, "max_slot": max_slot}, + ) + return payload if isinstance(payload, dict) else {} + + +def test_transform( + network: str, + instance: str, + target: str, + expression: str, + sample_slot: int | None = None, +) -> dict[str, Any]: + """Evaluate a jq expression against a sample builder object without + touching any plan. target is payload, bid, or envelope; the input is the + slot's captured artifact when sample_slot is given and available, else a + template. Returns {target, input, input_source, output, error}.""" + _require_buildoor_available() + args: dict[str, Any] = { + "network": network, + "instance": instance, + "target": target, + "expression": expression, + } + if sample_slot: + args["sample_slot"] = sample_slot + + payload = _runtime.invoke_json("buildoor.test_transform", args) + return payload if isinstance(payload, dict) else {} + + +def update_action_plan( + network: str, instance: str, updates: list[dict[str, Any]], token: str | None = None +) -> dict[str, Any]: + """Apply raw PlanUpdate mutations atomically. Buildoor owns the schema: + each update targets slots and/or from_slot..to_slot with category members + and fine-grained set paths (e.g. {"set": {"bid.bid_value_gwei": 5000}}). + + Credentials: without a token the mutation routes through a panda proxy + that advertises buildoor (the proxy mints the devnet credential). Passing + a personal authenticatoor bearer token goes direct instead and keeps + per-user attribution in buildoor's audit log. Returns the authoritative + {status, slots, plans}.""" + _require_buildoor_available() + args: dict[str, Any] = {"network": network, "instance": instance, "updates": updates} + if token: + args["auth_token"] = token + + payload = _runtime.invoke_json("buildoor.update_action_plan", args) + return payload if isinstance(payload, dict) else {} + + +def set_transforms( + network: str, + instance: str, + token: str | None = None, + slots: list[int] | None = None, + from_slot: int | None = None, + to_slot: int | None = None, + payload: str | None = None, + bid: str | None = None, + envelope: str | None = None, +) -> dict[str, Any]: + """Set jq transforms on future slots (>=2 ahead of current — plans freeze + ~1 slot ahead). None leaves a transform unchanged; '' clears that one + expression. Target via slots and/or the inclusive from_slot..to_slot range.""" + update: dict[str, Any] = {} + if slots: + update["slots"] = slots + if from_slot is not None or to_slot is not None: + if from_slot is None or to_slot is None: + raise ValueError("from_slot and to_slot must be provided together.") + update["from_slot"] = from_slot + update["to_slot"] = to_slot + if not update: + raise ValueError("Provide target slots via slots or from_slot/to_slot.") + + expressions = { + "transforms.payload": payload, + "transforms.bid": bid, + "transforms.envelope": envelope, + } + update["set"] = {key: expr for key, expr in expressions.items() if expr is not None} + if not update["set"]: + raise ValueError("Provide at least one of payload, bid, or envelope.") + + return update_action_plan(network, instance, [update], token) diff --git a/pkg/app/app.go b/pkg/app/app.go index 4d175896..51216903 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -23,6 +23,7 @@ import ( datasetsmodule "github.com/ethpandaops/panda/datasets" benchmarkoormodule "github.com/ethpandaops/panda/modules/benchmarkoor" blockarchivemodule "github.com/ethpandaops/panda/modules/block_archive" + buildoormodule "github.com/ethpandaops/panda/modules/buildoor" cbtmodule "github.com/ethpandaops/panda/modules/cbt" chartkitmodule "github.com/ethpandaops/panda/modules/chartkit" clickhousemodule "github.com/ethpandaops/panda/modules/clickhouse" @@ -220,6 +221,7 @@ func (a *App) registerModules() *module.Registry { reg.Add(benchmarkoormodule.New()) reg.Add(blockarchivemodule.New()) + reg.Add(buildoormodule.New()) reg.Add(cbtmodule.New()) reg.Add(chartkitmodule.New()) reg.Add(clickhousemodule.New()) diff --git a/pkg/cli/buildoor.go b/pkg/cli/buildoor.go new file mode 100644 index 00000000..c42a1ed5 --- /dev/null +++ b/pkg/cli/buildoor.go @@ -0,0 +1,489 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +// buildoorTokenEnv is the fallback source for the bearer token that buildoor +// verifies against the devnet's authenticatoor service. +const buildoorTokenEnv = "PANDA_BUILDOOR_TOKEN" + +var buildoorCmd = &cobra.Command{ + GroupID: groupDirect, + Use: "buildoor", + Short: "Drive buildoor per-slot action plans", + Long: `Inspect buildoor builder instances and script their per-slot action plans: +hot-patch payloads with jq transforms, review planned slots, and check what +actually happened. + +A network's buildoor service URL (from cartographoor) is the multi-instance +overview; commands address one instance by its short name (see 'instances'). + +Slot arguments accept absolute numbers or offsets relative to the instance's +current slot (+N ahead, +-N behind). Plans freeze ~1 slot ahead — target +slots at least 2 ahead. + +Mutations are credentialed by the panda proxy when it advertises buildoor +(the hosted proxy mints devnet tokens itself — no flags needed). Passing a +personal authenticatoor bearer token instead (--token or ` + buildoorTokenEnv + `, +minted at https://auth..ethpandaops.io/auth/token) goes direct and +keeps per-user attribution in buildoor's audit log. + +Examples: + panda buildoor networks + panda buildoor instances glamsterdam-devnet-7 + panda buildoor test-transform glamsterdam-devnet-7 prysm-ethrex-1 payload '.gas_limit = 300000000' + panda buildoor transform glamsterdam-devnet-7 prysm-ethrex-1 --slots +2,+3 --payload '.gas_limit = 300000000' + panda buildoor plan glamsterdam-devnet-7 prysm-ethrex-1 + panda buildoor results glamsterdam-devnet-7 prysm-ethrex-1 --min-slot 1200 --max-slot 1210`, +} + +func init() { + rootCmd.AddCommand(buildoorCmd) + + buildoorCmd.AddCommand( + buildoorNetworksCmd, + buildoorInstancesCmd, + buildoorOverviewCmd, + buildoorPlanCmd, + buildoorResultsCmd, + buildoorTransformCmd, + buildoorTestTransformCmd, + buildoorPlanUpdateCmd, + ) + + completeBuildoorNetworks := completeOperationNetworkNames("buildoor.list_networks") + for _, cmd := range buildoorCmd.Commands() { + if cmd != buildoorNetworksCmd { + cmd.ValidArgsFunction = completeBuildoorNetworks + } + } + + buildoorPlanCmd.Flags().String("min-slot", "", "range start slot, inclusive (absolute or +N; default: current-8)") + buildoorPlanCmd.Flags().String("max-slot", "", "range end slot, inclusive (absolute or +N; default: current+24)") + + buildoorResultsCmd.Flags().String("min-slot", "", "range start slot, inclusive (absolute or +N)") + buildoorResultsCmd.Flags().String("max-slot", "", "range end slot, inclusive (absolute or +N)") + _ = buildoorResultsCmd.MarkFlagRequired("min-slot") + _ = buildoorResultsCmd.MarkFlagRequired("max-slot") + + buildoorTransformCmd.Flags().StringSlice("slots", nil, "target slots (absolute or +N, comma-separated)") + buildoorTransformCmd.Flags().String("from", "", "target range start slot, inclusive (absolute or +N)") + buildoorTransformCmd.Flags().String("to", "", "target range end slot, inclusive (absolute or +N)") + buildoorTransformCmd.Flags().String("payload", "", "jq expression rewriting the built execution payload ('' clears it)") + buildoorTransformCmd.Flags().String("bid", "", "jq expression rewriting the bid message before re-signing ('' clears it)") + buildoorTransformCmd.Flags().String("envelope", "", "jq expression rewriting the envelope message before re-signing ('' clears it)") + buildoorTransformCmd.Flags().Bool("clear", false, "remove all transforms from the target slots") + buildoorTransformCmd.Flags().String("token", "", "authenticatoor bearer token (default: $"+buildoorTokenEnv+")") + + buildoorTestTransformCmd.Flags().Uint64("sample-slot", 0, "run against this slot's captured artifact instead of a template") + + buildoorPlanUpdateCmd.Flags().String("updates", "", "PlanUpdate JSON array, passed through verbatim") + buildoorPlanUpdateCmd.Flags().String("token", "", "authenticatoor bearer token (default: $"+buildoorTokenEnv+")") + _ = buildoorPlanUpdateCmd.MarkFlagRequired("updates") +} + +var buildoorNetworksCmd = &cobra.Command{ + Use: "networks", + Short: "List networks with buildoor deployments", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + response, err := runServerOperation(cmd, "buildoor.list_networks", map[string]any{}) + if err != nil { + return err + } + + return printListing(response, "networks", "No networks with buildoor deployments found.") + }, +} + +var buildoorInstancesCmd = &cobra.Command{ + Use: "instances ", + Short: "List a network's buildoor instances", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + response, err := runServerOperation(cmd, "buildoor.list_instances", map[string]any{ + "network": args[0], + }) + if err != nil { + return err + } + + return printListing(response, "instances", "No buildoor instances found.") + }, +} + +var buildoorOverviewCmd = &cobra.Command{ + Use: "overview ", + Short: "Get an instance's status overview (always JSON)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + response, err := runServerOperationRaw(cmd, "buildoor.get_overview", map[string]any{ + "network": args[0], + "instance": args[1], + }) + if err != nil { + return err + } + + return printJSONBytes(response.Body) + }, +} + +var buildoorPlanCmd = &cobra.Command{ + Use: "plan ", + Short: "Get per-slot action plans (always JSON)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + minSlot, _ := cmd.Flags().GetString("min-slot") + maxSlot, _ := cmd.Flags().GetString("max-slot") + + if (minSlot == "") != (maxSlot == "") { + return fmt.Errorf("--min-slot and --max-slot must be provided together") + } + + if minSlot == "" { + minSlot, maxSlot = "+-8", "+24" + } + + return buildoorSlotRangeQuery(cmd, "buildoor.get_action_plan", args[0], args[1], minSlot, maxSlot) + }, +} + +var buildoorResultsCmd = &cobra.Command{ + Use: "results ", + Short: "Get per-slot outcome history (always JSON)", + Long: `Get the recorded outcome history — build, bids, block submissions, reveals, +inclusion, and the frozen applied plan — for every active slot in the range +(always JSON).`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + minSlot, _ := cmd.Flags().GetString("min-slot") + maxSlot, _ := cmd.Flags().GetString("max-slot") + + return buildoorSlotRangeQuery(cmd, "buildoor.get_slot_results", args[0], args[1], minSlot, maxSlot) + }, +} + +var buildoorTransformCmd = &cobra.Command{ + Use: "transform ", + Short: "Set jq transforms on future slots", + Long: `Set the action plan's jq transforms on future slots, hot-patching builder +objects for testing. Expressions run against the object's JSON form: + --payload rewrites the built execution payload before it feeds the bid + commitment and the reveal + --bid rewrites the bid message just before signing (then re-signed) + --envelope rewrites the envelope message just before signing (then re-signed) + +Plans freeze ~1 slot ahead, so target slots at least 2 ahead (e.g. --slots +2). +An empty expression ('') clears that one transform; --clear removes all three. + +Examples: + panda buildoor transform glamsterdam-devnet-7 prysm-ethrex-1 --slots +2,+3 --payload '.gas_limit = 300000000' + panda buildoor transform glamsterdam-devnet-7 prysm-ethrex-1 --from +2 --to +10 --payload '.gas_limit = 300000000' + panda buildoor transform glamsterdam-devnet-7 prysm-ethrex-1 --slots +2 --clear`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + network, instance := args[0], args[1] + + clearAll, _ := cmd.Flags().GetBool("clear") + set := make(map[string]any, 3) + + for _, target := range []string{"payload", "bid", "envelope"} { + if cmd.Flags().Changed(target) { + expr, _ := cmd.Flags().GetString(target) + set["transforms."+target] = expr + } + } + + if clearAll && len(set) > 0 { + return fmt.Errorf("--clear cannot be combined with transform expressions") + } + + if !clearAll && len(set) == 0 { + return fmt.Errorf("provide at least one of --payload, --bid, --envelope, or --clear") + } + + update, err := buildoorTargetSlots(cmd, network, instance) + if err != nil { + return err + } + + if clearAll { + update["transforms"] = nil + } else { + update["set"] = set + } + + return buildoorApplyUpdates(cmd, network, instance, []any{update}) + }, +} + +var buildoorTestTransformCmd = &cobra.Command{ + Use: "test-transform ", + Short: "Evaluate a jq transform against a sample object", + Long: `Evaluate a jq expression against a sample builder object on the instance +without touching any plan. Target is payload, bid, or envelope. The input is +the slot's captured artifact when --sample-slot is given and available, +otherwise a template. + +Example: + panda buildoor test-transform glamsterdam-devnet-7 prysm-ethrex-1 payload '.gas_limit = 300000000'`, + Args: cobra.ExactArgs(4), + RunE: func(cmd *cobra.Command, args []string) error { + opArgs := map[string]any{ + "network": args[0], + "instance": args[1], + "target": args[2], + "expression": args[3], + } + + if sampleSlot, _ := cmd.Flags().GetUint64("sample-slot"); sampleSlot > 0 { + opArgs["sample_slot"] = sampleSlot + } + + response, err := runServerOperationRaw(cmd, "buildoor.test_transform", opArgs) + if err != nil { + return err + } + + if isJSON() { + return printJSONBytes(response.Body) + } + + var result struct { + InputSource string `json:"input_source"` + Output string `json:"output"` + Error string `json:"error"` + } + + if err := json.Unmarshal(response.Body, &result); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + if result.Error != "" { + return fmt.Errorf("transform failed against %s input: %s", result.InputSource, result.Error) + } + + fmt.Printf("Input: %s\n%s\n", result.InputSource, result.Output) + + return nil + }, +} + +var buildoorPlanUpdateCmd = &cobra.Command{ + Use: "plan-update ", + Short: "Apply raw PlanUpdate JSON to the action plan", + Long: `Apply a raw bulk action-plan mutation, exposing the full PlanUpdate schema +(bid/builder_api/reveal/build categories, fine-grained set paths) beyond the +transform shortcuts. See buildoor's POST /api/buildoor/action-plan docs. + +Example: + panda buildoor plan-update glamsterdam-devnet-7 prysm-ethrex-1 \ + --updates '[{"slots":[1234],"set":{"bid.bid_value_gwei":5000}}]'`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + raw, _ := cmd.Flags().GetString("updates") + + var updates []any + if err := json.Unmarshal([]byte(raw), &updates); err != nil { + return fmt.Errorf("--updates must be a JSON array: %w", err) + } + + return buildoorApplyUpdates(cmd, args[0], args[1], updates) + }, +} + +// buildoorSlotRangeQuery resolves the (possibly relative) slot range and runs +// a range-scoped read operation, printing the raw JSON response. +func buildoorSlotRangeQuery(cmd *cobra.Command, operationID, network, instance, minSlot, maxSlot string) error { + resolve := buildoorSlotResolver(cmd, network, instance) + + minResolved, err := resolve(minSlot) + if err != nil { + return err + } + + maxResolved, err := resolve(maxSlot) + if err != nil { + return err + } + + response, err := runServerOperationRaw(cmd, operationID, map[string]any{ + "network": network, + "instance": instance, + "min_slot": minResolved, + "max_slot": maxResolved, + }) + if err != nil { + return err + } + + return printJSONBytes(response.Body) +} + +// buildoorTargetSlots builds the update's slot targeting from --slots or +// --from/--to, resolving relative values. +func buildoorTargetSlots(cmd *cobra.Command, network, instance string) (map[string]any, error) { + slots, _ := cmd.Flags().GetStringSlice("slots") + from, _ := cmd.Flags().GetString("from") + to, _ := cmd.Flags().GetString("to") + + if (from == "") != (to == "") { + return nil, fmt.Errorf("--from and --to must be provided together") + } + + if len(slots) == 0 && from == "" { + return nil, fmt.Errorf("provide target slots via --slots or --from/--to") + } + + resolve := buildoorSlotResolver(cmd, network, instance) + update := make(map[string]any, 3) + + if len(slots) > 0 { + resolved := make([]uint64, 0, len(slots)) + + for _, slot := range slots { + value, err := resolve(slot) + if err != nil { + return nil, err + } + + resolved = append(resolved, value) + } + + update["slots"] = resolved + } + + if from != "" { + fromResolved, err := resolve(from) + if err != nil { + return nil, err + } + + toResolved, err := resolve(to) + if err != nil { + return nil, err + } + + update["from_slot"] = fromResolved + update["to_slot"] = toResolved + } + + return update, nil +} + +// buildoorSlotResolver parses slot specs: plain numbers pass through, +N (and +// +-N) resolve against the instance's current slot, fetched once on first use. +func buildoorSlotResolver(cmd *cobra.Command, network, instance string) func(string) (uint64, error) { + currentSlot := int64(-1) + + return func(spec string) (uint64, error) { + offset, relative := strings.CutPrefix(spec, "+") + if !relative { + value, err := strconv.ParseUint(spec, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid slot %q: expected a slot number or +N", spec) + } + + return value, nil + } + + delta, err := strconv.ParseInt(offset, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid relative slot %q: expected +N", spec) + } + + if currentSlot < 0 { + slot, err := buildoorCurrentSlot(cmd, network, instance) + if err != nil { + return 0, err + } + + currentSlot = slot + } + + resolved := currentSlot + delta + if resolved < 0 { + resolved = 0 + } + + return uint64(resolved), nil + } +} + +// buildoorCurrentSlot reads the instance's current slot from its overview. +func buildoorCurrentSlot(cmd *cobra.Command, network, instance string) (int64, error) { + response, err := runServerOperationRaw(cmd, "buildoor.get_overview", map[string]any{ + "network": network, + "instance": instance, + }) + if err != nil { + return 0, err + } + + var overview struct { + CurrentSlot int64 `json:"current_slot"` + } + + if err := json.Unmarshal(response.Body, &overview); err != nil { + return 0, fmt.Errorf("decoding instance overview: %w", err) + } + + if overview.CurrentSlot <= 0 { + return 0, fmt.Errorf( + "instance %q reports current_slot=%d — cannot resolve relative slots, pass absolute slot numbers", + instance, overview.CurrentSlot, + ) + } + + return overview.CurrentSlot, nil +} + +// buildoorApplyUpdates runs the mutation and prints the authoritative result. +// Without an explicit token the server routes the mutation through a proxy +// that advertises buildoor (the proxy mints the devnet credential); a token +// forces the direct path and keeps per-user attribution in buildoor's audit. +func buildoorApplyUpdates(cmd *cobra.Command, network, instance string, updates []any) error { + token, _ := cmd.Flags().GetString("token") + if token == "" { + token = strings.TrimSpace(os.Getenv(buildoorTokenEnv)) + } + + args := map[string]any{ + "network": network, + "instance": instance, + "updates": updates, + } + if token != "" { + args["auth_token"] = token + } + + response, err := runServerOperationRaw(cmd, "buildoor.update_action_plan", args) + if err != nil { + return err + } + + if isJSON() { + return printJSONBytes(response.Body) + } + + var result struct { + Status string `json:"status"` + Slots []uint64 `json:"slots"` + } + + if err := json.Unmarshal(response.Body, &result); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + + fmt.Printf("Updated %d slot plan(s): %v\n", len(result.Slots), result.Slots) + + return nil +} diff --git a/pkg/cli/buildoor_test.go b/pkg/cli/buildoor_test.go new file mode 100644 index 00000000..a624433c --- /dev/null +++ b/pkg/cli/buildoor_test.go @@ -0,0 +1,33 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildoorSlotResolverParsesAbsoluteSlots(t *testing.T) { + // Absolute specs never touch the instance overview, so nil cmd is safe. + resolve := buildoorSlotResolver(nil, "testnet", "instance") + + value, err := resolve("1234") + require.NoError(t, err) + assert.Equal(t, uint64(1234), value) + + _, err = resolve("abc") + require.Error(t, err) + assert.Contains(t, err.Error(), "expected a slot number or +N") + + _, err = resolve("-5") + require.Error(t, err) +} + +func TestBuildoorCommandsAreRegistered(t *testing.T) { + for _, name := range []string{ + "networks", "instances", "overview", "plan", "results", + "transform", "test-transform", "plan-update", + } { + assert.True(t, hasSubcommand(buildoorCmd, name), "missing subcommand %q", name) + } +} diff --git a/pkg/proxy/auth_context.go b/pkg/proxy/auth_context.go index 6c062538..045613c6 100644 --- a/pkg/proxy/auth_context.go +++ b/pkg/proxy/auth_context.go @@ -7,6 +7,11 @@ type AuthUser struct { Subject string Username string Groups []string + // Audiences is the verified token's aud claim. Audience-gated routes + // (e.g. buildoor's required_audience) check membership here; the IdP + // cross-grants extra audiences per group, so presence proves a + // server-side policy decision, not a client-side request. + Audiences []string } type authUserContextKey string diff --git a/pkg/proxy/authorizer.go b/pkg/proxy/authorizer.go index b32168d0..a1066fa9 100644 --- a/pkg/proxy/authorizer.go +++ b/pkg/proxy/authorizer.go @@ -3,6 +3,7 @@ package proxy import ( "context" "net/http" + "strings" "github.com/sirupsen/logrus" @@ -16,6 +17,12 @@ import ( type Authorizer struct { log logrus.FieldLogger rules map[string][]datasourceVariantRule // "type:name" -> variants; "type" for type-level rules (ethnode) + + // buildoorRequiredAudience, when set, additionally requires the verified + // token's aud claim to carry this audience for /buildoor access. The IdP + // cross-grants it per group, so it is a second, centrally-managed gate on + // top of allowed_orgs. + buildoorRequiredAudience string } type datasourceVariantRule struct { @@ -84,6 +91,17 @@ func NewAuthorizer(log logrus.FieldLogger, cfg ServerConfig) *Authorizer { }} } + // Buildoor is gated at the type level, like workflow. + if cfg.Buildoor != nil && len(cfg.Buildoor.AllowedOrgs) > 0 { + a.rules[ruleKey("buildoor", "")] = []datasourceVariantRule{{ + allowedOrgs: append([]string(nil), cfg.Buildoor.AllowedOrgs...), + }} + } + + if cfg.Buildoor != nil { + a.buildoorRequiredAudience = strings.TrimSpace(cfg.Buildoor.RequiredAudience) + } + return a } @@ -130,12 +148,17 @@ func (a *Authorizer) FilterDatasources(ctx context.Context, resp DatasourcesResp filtered.BenchmarkoorInfo = a.filterDatasourceList(userOrgs, hasUser, "benchmarkoor", resp.BenchmarkoorInfo) filtered.ComputeInfo = a.filterDatasourceList(userOrgs, hasUser, "compute", resp.ComputeInfo) - // The workflow advert is type-level: hide it entirely from callers who - // lack the required org membership. + // The workflow and buildoor adverts are type-level: hide them entirely + // from callers who lack the required org membership. if resp.Workflow != nil && a.orgsMatch(userOrgs, hasUser, ruleKey("workflow", "")) { filtered.Workflow = resp.Workflow } + if resp.Buildoor != nil && a.orgsMatch(userOrgs, hasUser, ruleKey("buildoor", "")) && + a.buildoorAudienceOK(ctx) { + filtered.Buildoor = resp.Buildoor + } + return filtered } @@ -177,13 +200,17 @@ func (a *Authorizer) routeName(ctx context.Context, dsType, dsName string) (stri return "", false } - // Workflow is gated at the type level too (no X-Datasource name). - if dsType == "workflow" { - if a.orgsMatch(userOrgs, hasUser, ruleKey("workflow", "")) { - return "", true + // Workflow and buildoor are gated at the type level too (no X-Datasource name). + if dsType == "workflow" || dsType == "buildoor" { + if !a.orgsMatch(userOrgs, hasUser, ruleKey(dsType, "")) { + return "", false } - return "", false + if dsType == "buildoor" && !a.buildoorAudienceOK(ctx) { + return "", false + } + + return "", true } // For datasources endpoint, skip middleware check (filtered in handler). @@ -255,6 +282,35 @@ func allowedOrgsMatch(userOrgs, allowedOrgs []string) bool { // - OAuth mode: auth.AuthUser.Orgs // - OIDC mode: proxy.AuthUser.Groups // - None mode: returns false (no restriction) +// +// buildoorAudienceOK enforces the buildoor required_audience against the +// verified token's aud claim. Without a required audience it always passes. +// Without an authenticated user (auth mode none) it passes too, consistent +// with the org gates' local-trust behavior. OAuth-mode users carry no OIDC +// audiences and are therefore denied when a required audience is set — the +// cross-grant only exists at the OIDC IdP. +func (a *Authorizer) buildoorAudienceOK(ctx context.Context) bool { + if a.buildoorRequiredAudience == "" { + return true + } + + if user := GetAuthUser(ctx); user != nil { + for _, aud := range user.Audiences { + if aud == a.buildoorRequiredAudience { + return true + } + } + + return false + } + + if user := auth.GetAuthUser(ctx); user != nil { + return false + } + + return true +} + func getUserOrgs(ctx context.Context) ([]string, bool) { // Check proxy.AuthUser (OIDC mode). if user := GetAuthUser(ctx); user != nil { diff --git a/pkg/proxy/authorizer_buildoor_test.go b/pkg/proxy/authorizer_buildoor_test.go new file mode 100644 index 00000000..c8768c8d --- /dev/null +++ b/pkg/proxy/authorizer_buildoor_test.go @@ -0,0 +1,104 @@ +package proxy + +import ( + "context" + "io" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func newBuildoorAuthorizer(allowedOrgs []string, requiredAudience string) *Authorizer { + log := logrus.New() + log.SetOutput(io.Discard) + + return NewAuthorizer(log, ServerConfig{ + Buildoor: &BuildoorProxyConfig{ + StaticToken: "x", + AllowedOrgs: allowedOrgs, + RequiredAudience: requiredAudience, + }, + }) +} + +func buildoorUserCtx(groups, audiences []string) context.Context { + return withAuthUser(context.Background(), &AuthUser{ + Subject: "alice", + Username: "alice", + Groups: groups, + Audiences: audiences, + }) +} + +func TestBuildoorAuthorizerOrgAndAudienceGates(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + allowedOrgs []string + requiredAudience string + groups []string + audiences []string + want bool + }{ + "org and audience present": { + allowedOrgs: []string{"ethpandaops:Core"}, + requiredAudience: "buildoor", + groups: []string{"ethpandaops", "ethpandaops:Core"}, + audiences: []string{"panda-proxy", "buildoor"}, + want: true, + }, + "org ok but audience missing": { + allowedOrgs: []string{"ethpandaops:Core"}, + requiredAudience: "buildoor", + groups: []string{"ethpandaops", "ethpandaops:Core"}, + audiences: []string{"panda-proxy"}, + want: false, + }, + "audience ok but org missing": { + allowedOrgs: []string{"ethpandaops:Core"}, + requiredAudience: "buildoor", + groups: []string{"sigp"}, + audiences: []string{"panda-proxy", "buildoor"}, + want: false, + }, + "org gate only": { + allowedOrgs: []string{"ethpandaops:Core"}, + groups: []string{"ethpandaops:Core"}, + audiences: []string{"panda-proxy"}, + want: true, + }, + "audience gate only": { + requiredAudience: "buildoor", + groups: []string{"sigp"}, + audiences: []string{"panda-proxy", "buildoor"}, + want: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + a := newBuildoorAuthorizer(tc.allowedOrgs, tc.requiredAudience) + ctx := buildoorUserCtx(tc.groups, tc.audiences) + + assert.Equal(t, tc.want, a.isAllowed(ctx, "buildoor", "")) + + // The discovery advert must agree with the route decision. + filtered := a.FilterDatasources(ctx, DatasourcesResponse{ + Buildoor: &BuildoorAdvert{Enabled: true}, + }) + assert.Equal(t, tc.want, filtered.Buildoor != nil) + }) + } +} + +func TestBuildoorAuthorizerNoUserSkipsAudienceGate(t *testing.T) { + t.Parallel() + + // Auth mode none: no user in context → local trust, gates pass (matching + // the org gates' behavior). + a := newBuildoorAuthorizer([]string{"ethpandaops:Core"}, "buildoor") + assert.True(t, a.isAllowed(context.Background(), "buildoor", "")) +} diff --git a/pkg/proxy/client.go b/pkg/proxy/client.go index 9f2e317e..894a28ad 100644 --- a/pkg/proxy/client.go +++ b/pkg/proxy/client.go @@ -142,6 +142,7 @@ var ( _ Client = (*proxyClient)(nil) _ Service = (*proxyClient)(nil) _ WorkflowInfoProvider = (*proxyClient)(nil) + _ BuildoorInfoProvider = (*proxyClient)(nil) ) // NewClient creates a new proxy client. @@ -519,6 +520,15 @@ func (c *proxyClient) WorkflowInfo() (enabled bool, webURL string) { return c.datasources.Workflow.Enabled, c.datasources.Workflow.WebURL } +// BuildoorAvailable reports whether the proxy advertises credentialed devnet +// buildoor API access, read from the cached datasource discovery. +func (c *proxyClient) BuildoorAvailable() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.datasources.Buildoor != nil && c.datasources.Buildoor.Enabled +} + // Discover fetches datasource information from the proxy's /datasources endpoint. // A 401/403 invalidates the cached token and the request is retried once with a // fresh one, covering proxy-side revocation before the local expiry buffer diff --git a/pkg/proxy/handlers/buildoor.go b/pkg/proxy/handlers/buildoor.go new file mode 100644 index 00000000..3e022b13 --- /dev/null +++ b/pkg/proxy/handlers/buildoor.go @@ -0,0 +1,252 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +// buildoorTokenSkew refreshes a cached authenticatoor JWT this long before +// its expiry so an in-flight request never carries an expiring token. +const buildoorTokenSkew = 60 * time.Second + +// BuildoorConfig holds devnet buildoor API access configuration. The proxy is +// the credential boundary: it authenticates to each devnet's authenticatoor +// (auth.., behind Cloudflare Access) with a CF Access service +// token — the authenticatoor mints a JWT whose subject is the service token's +// common name — and injects that JWT as the bearer on buildoor instance API +// calls. StaticToken bypasses minting for local/ad-hoc deployments. +type BuildoorConfig struct { + CFAccessClientID string + CFAccessClientSecret string + StaticToken string + // DomainSuffix is the devnet DNS zone. Default: "ethpandaops.io". + DomainSuffix string +} + +// resolvedSuffix returns the configured domain suffix or the default. +func (c BuildoorConfig) resolvedSuffix() string { + if s := strings.TrimSpace(c.DomainSuffix); s != "" { + return s + } + + return "ethpandaops.io" +} + +// buildoorToken is one cached per-network authenticatoor JWT. +type buildoorToken struct { + token string + expires time.Time +} + +// buildoorBearerKey carries the resolved bearer from ServeHTTP (where minting +// can fail cleanly) into the reverse proxy's Rewrite (which cannot). +type buildoorBearerKey struct{} + +// BuildoorHandler proxies requests to devnet buildoor instance APIs. +// Path format: /{network}/{instance}/api/... (mounted under /buildoor), with +// the upstream host constructed as api-buildoor-{instance}.srv.{network}.{suffix} +// — the deterministic per-instance ingress the devnet deployments publish. +type BuildoorHandler struct { + log logrus.FieldLogger + cfg BuildoorConfig + httpClient *http.Client + + mu sync.Mutex + tokens map[string]buildoorToken + + proxyMu sync.RWMutex + proxies map[string]*httputil.ReverseProxy +} + +// NewBuildoorHandler creates a new buildoor handler. +func NewBuildoorHandler(log logrus.FieldLogger, cfg BuildoorConfig) *BuildoorHandler { + return &BuildoorHandler{ + log: log.WithField("handler", "buildoor"), + cfg: cfg, + httpClient: &http.Client{Timeout: 15 * time.Second}, + tokens: make(map[string]buildoorToken, 4), + proxies: make(map[string]*httputil.ReverseProxy, 8), + } +} + +// ServeHTTP handles buildoor instance API requests. The subtree mount does not +// strip the route prefix, so the handler drops it itself (mirroring ethnode). +func (h *BuildoorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/buildoor") + + parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) + if len(parts) < 3 || parts[2] == "" { + http.Error(w, "invalid path: must be /{network}/{instance}/api/...", http.StatusBadRequest) + return + } + + network, instance := parts[0], parts[1] + rest := "/" + parts[2] + + if !validSegment.MatchString(network) { + http.Error(w, "invalid network name: must match [a-z0-9-]", http.StatusBadRequest) + return + } + + if !validSegment.MatchString(instance) { + http.Error(w, "invalid instance name: must match [a-z0-9-]", http.StatusBadRequest) + return + } + + // Only the buildoor HTTP API is exposed — never the metrics/debug routes. + if !strings.HasPrefix(rest, "/api/") { + http.Error(w, "invalid path: only /api/ endpoints are proxied", http.StatusBadRequest) + return + } + + bearer, err := h.bearerForNetwork(r.Context(), network) + if err != nil { + h.log.WithError(err).WithField("network", network).Warn("Buildoor token acquisition failed") + http.Error(w, fmt.Sprintf("acquiring devnet auth token: %v", err), http.StatusBadGateway) + + return + } + + host := fmt.Sprintf("api-buildoor-%s.srv.%s.%s", instance, network, h.cfg.resolvedSuffix()) + proxy := h.getOrCreateProxy(host) + + r.URL.Path = rest + + h.log.WithFields(logrus.Fields{ + "network": network, + "instance": instance, + "path": rest, + "method": r.Method, + "upstream": host, + }).Debug("Proxying buildoor request") + + proxy.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), buildoorBearerKey{}, bearer))) +} + +// bearerForNetwork returns the bearer for the network's buildoor instances: +// the static token when configured, else a cached-or-minted authenticatoor JWT. +func (h *BuildoorHandler) bearerForNetwork(ctx context.Context, network string) (string, error) { + if h.cfg.StaticToken != "" { + return h.cfg.StaticToken, nil + } + + if h.cfg.CFAccessClientID == "" || h.cfg.CFAccessClientSecret == "" { + return "", fmt.Errorf("no buildoor credential configured (cf_access_client_id/secret or static_token)") + } + + h.mu.Lock() + defer h.mu.Unlock() + + if cached, ok := h.tokens[network]; ok && time.Now().Before(cached.expires.Add(-buildoorTokenSkew)) { + return cached.token, nil + } + + minted, err := h.mintToken(ctx, network) + if err != nil { + return "", err + } + + h.tokens[network] = minted + + return minted.token, nil +} + +// mintToken exchanges the CF Access service token for a devnet authenticatoor +// JWT via GET https://auth.{network}.{suffix}/auth/token. The authenticatoor +// accepts CF Access service tokens (allowServiceTokens) and uses the token's +// common name as the JWT subject — that identity appears in buildoor's audit +// log; the acting human stays attributed in this proxy's own audit log. +func (h *BuildoorHandler) mintToken(ctx context.Context, network string) (buildoorToken, error) { + tokenURL := fmt.Sprintf("https://auth.%s.%s/auth/token", network, h.cfg.resolvedSuffix()) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) + if err != nil { + return buildoorToken{}, fmt.Errorf("creating token request: %w", err) + } + + req.Header.Set("CF-Access-Client-Id", h.cfg.CFAccessClientID) + req.Header.Set("CF-Access-Client-Secret", h.cfg.CFAccessClientSecret) + + resp, err := h.httpClient.Do(req) + if err != nil { + return buildoorToken{}, fmt.Errorf("requesting token from %s: %w", tokenURL, err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return buildoorToken{}, fmt.Errorf("reading token response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return buildoorToken{}, fmt.Errorf( + "authenticatoor at %s returned HTTP %d (is the CF Access service token included in the auth app policy?)", + tokenURL, resp.StatusCode, + ) + } + + var payload struct { + Token string `json:"token"` + Expr int64 `json:"expr"` + } + + if err := json.Unmarshal(body, &payload); err != nil || payload.Token == "" { + return buildoorToken{}, fmt.Errorf("invalid token response from %s", tokenURL) + } + + expires := time.Unix(payload.Expr, 0) + if payload.Expr == 0 { + expires = time.Now().Add(5 * time.Minute) + } + + return buildoorToken{token: payload.Token, expires: expires}, nil +} + +// getOrCreateProxy returns a cached reverse proxy for the host. +func (h *BuildoorHandler) getOrCreateProxy(host string) *httputil.ReverseProxy { + h.proxyMu.RLock() + proxy, ok := h.proxies[host] + h.proxyMu.RUnlock() + + if ok { + return proxy + } + + h.proxyMu.Lock() + defer h.proxyMu.Unlock() + + if proxy, ok = h.proxies[host]; ok { + return proxy + } + + targetURL := &url.URL{Scheme: "https", Host: host} + + rp := &httputil.ReverseProxy{Transport: newProxyTransport(false)} + rp.Rewrite = func(pr *httputil.ProxyRequest) { + pr.SetURL(targetURL) + pr.SetXForwarded() + pr.Out.Host = host + + // The inbound Authorization is the caller's proxy bearer; replace it + // with the devnet token resolved in ServeHTTP. + pr.Out.Header.Del("Authorization") + + if bearer, _ := pr.In.Context().Value(buildoorBearerKey{}).(string); bearer != "" { + pr.Out.Header.Set("Authorization", "Bearer "+bearer) + } + } + + h.proxies[host] = rp + + return rp +} diff --git a/pkg/proxy/handlers/buildoor_test.go b/pkg/proxy/handlers/buildoor_test.go new file mode 100644 index 00000000..848f6083 --- /dev/null +++ b/pkg/proxy/handlers/buildoor_test.go @@ -0,0 +1,165 @@ +package handlers + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "sync/atomic" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newBuildoorTestLogger() logrus.FieldLogger { + log := logrus.New() + log.SetOutput(io.Discard) + + return log +} + +// stubBuildoorUpstream repoints the handler's cached reverse proxy for host at +// the given test server, keeping the handler's own Rewrite (auth injection). +func stubBuildoorUpstream(h *BuildoorHandler, host string, upstream *httptest.Server) { + target, _ := url.Parse(upstream.URL) + rp := h.getOrCreateProxy(host) + + original := rp.Rewrite + h.proxies[host] = &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + original(pr) + pr.Out.URL.Scheme = target.Scheme + pr.Out.URL.Host = target.Host + }, + } +} + +func TestBuildoorHandlerStaticTokenInjection(t *testing.T) { + t.Parallel() + + var gotAuth string + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + + assert.Equal(t, "/api/buildoor/action-plan", r.URL.Path) + _, _ = w.Write([]byte(`{"status":"updated"}`)) + })) + t.Cleanup(upstream.Close) + + h := NewBuildoorHandler(newBuildoorTestLogger(), BuildoorConfig{StaticToken: "static-secret"}) + stubBuildoorUpstream(h, "api-buildoor-prysm-ethrex-1.srv.testnet.ethpandaops.io", upstream) + + req := httptest.NewRequest(http.MethodPost, "/buildoor/testnet/prysm-ethrex-1/api/buildoor/action-plan", nil) + req.Header.Set("Authorization", "Bearer caller-proxy-token") + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + // The caller's proxy bearer must never reach the upstream. + assert.Equal(t, "Bearer static-secret", gotAuth) +} + +func TestBuildoorHandlerMintsAndCachesToken(t *testing.T) { + t.Parallel() + + var mints atomic.Int32 + + authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/auth/token", r.URL.Path) + assert.Equal(t, "svc-id", r.Header.Get("CF-Access-Client-Id")) + assert.Equal(t, "svc-secret", r.Header.Get("CF-Access-Client-Secret")) + + mints.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": "minted-jwt", + "user": "panda-proxy-svc", + "expr": time.Now().Add(30 * time.Minute).Unix(), + }) + })) + t.Cleanup(authSrv.Close) + + var gotAuth string + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(upstream.Close) + + h := NewBuildoorHandler(newBuildoorTestLogger(), BuildoorConfig{ + CFAccessClientID: "svc-id", + CFAccessClientSecret: "svc-secret", + }) + + // Point the authenticatoor call at the test server. + h.httpClient = &http.Client{Transport: rewriteHostTransport{target: authSrv.URL}} + stubBuildoorUpstream(h, "api-buildoor-prysm-ethrex-1.srv.testnet.ethpandaops.io", upstream) + + for range 3 { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/buildoor/testnet/prysm-ethrex-1/api/buildoor/overview", nil)) + require.Equal(t, http.StatusOK, rec.Code) + } + + assert.Equal(t, "Bearer minted-jwt", gotAuth) + // The JWT is cached until near expiry — one mint serves all three requests. + assert.Equal(t, int32(1), mints.Load()) +} + +func TestBuildoorHandlerRejectsBadPaths(t *testing.T) { + t.Parallel() + + h := NewBuildoorHandler(newBuildoorTestLogger(), BuildoorConfig{StaticToken: "x"}) + + for name, path := range map[string]string{ + "missing instance": "/buildoor/testnet", + "missing rest": "/buildoor/testnet/prysm-ethrex-1", + "non-api path": "/buildoor/testnet/prysm-ethrex-1/metrics", + "invalid network": "/buildoor/Bad_Network/prysm-ethrex-1/api/buildoor/overview", + "invalid instance": "/buildoor/testnet/UPPER/api/buildoor/overview", + "traversal segment": "/buildoor/testnet/../api/buildoor/overview", + } { + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + assert.Equal(t, http.StatusBadRequest, rec.Code, "path %q", path) + }) + } +} + +func TestBuildoorHandlerNoCredentialConfigured(t *testing.T) { + t.Parallel() + + h := NewBuildoorHandler(newBuildoorTestLogger(), BuildoorConfig{}) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/buildoor/testnet/prysm-ethrex-1/api/buildoor/overview", nil)) + + assert.Equal(t, http.StatusBadGateway, rec.Code) + assert.Contains(t, rec.Body.String(), "no buildoor credential configured") +} + +// rewriteHostTransport sends every request to the fixed target, preserving the +// request path — it stands in for DNS in tests. +type rewriteHostTransport struct { + target string +} + +func (t rewriteHostTransport) RoundTrip(r *http.Request) (*http.Response, error) { + target, err := url.Parse(t.target) + if err != nil { + return nil, err + } + + r.URL.Scheme = target.Scheme + r.URL.Host = target.Host + + return http.DefaultTransport.RoundTrip(r) +} diff --git a/pkg/proxy/metrics.go b/pkg/proxy/metrics.go index dcc883a9..24ceec11 100644 --- a/pkg/proxy/metrics.go +++ b/pkg/proxy/metrics.go @@ -290,6 +290,8 @@ func extractDatasourceType(path string) string { return "compute" case "workflow": return "workflow" + case "buildoor": + return "buildoor" case "github": return "github" case "uploads": diff --git a/pkg/proxy/oidc_auth.go b/pkg/proxy/oidc_auth.go index cd68ba90..b1b8cc3b 100644 --- a/pkg/proxy/oidc_auth.go +++ b/pkg/proxy/oidc_auth.go @@ -188,9 +188,10 @@ func (a *oidcAuthenticator) Middleware() func(http.Handler) http.Handler { username := firstNonEmpty(claims.PreferredUsername, claims.Email, claims.Name, subject) ctx := withAuthUser(r.Context(), &AuthUser{ - Subject: subject, - Username: username, - Groups: groups, + Subject: subject, + Username: username, + Groups: groups, + Audiences: append([]string(nil), token.Audience...), }) next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 63ab0221..5a714cbf 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -88,6 +88,14 @@ type WorkflowInfoProvider interface { WorkflowInfo() (enabled bool, webURL string) } +// BuildoorInfoProvider reports whether the proxy advertises credentialed +// devnet buildoor API access. Like WorkflowInfoProvider it is a capability +// interface resolved with a type assertion. +type BuildoorInfoProvider interface { + // BuildoorAvailable returns whether the /buildoor route is advertised. + BuildoorAvailable() bool +} + // ethNodeDatasourceInfo returns the ethnode datasource identity when available, // or nil. Ethnode is a single type-level datasource, not a discoverable list. func ethNodeDatasourceInfo(available bool) []types.DatasourceInfo { diff --git a/pkg/proxy/router.go b/pkg/proxy/router.go index c5314791..d42ea660 100644 --- a/pkg/proxy/router.go +++ b/pkg/proxy/router.go @@ -34,6 +34,10 @@ type Router interface { // and forwarded traffic never disagree. It is NOT necessarily Primary(): a // secondary route may carry the engine when the primary does not. WorkflowRoute() (Client, bool) + + // BuildoorRoute returns the proxy client for the first route, in priority + // order, that advertises credentialed buildoor access. + BuildoorRoute() (Client, bool) } // DatasourceOwner identifies the proxy that owns a datasource. @@ -356,6 +360,18 @@ func (r *routerClient) WorkflowRoute() (Client, bool) { return nil, false } +// BuildoorRoute returns the proxy client for the first route, in priority +// order, that advertises credentialed buildoor access. +func (r *routerClient) BuildoorRoute() (Client, bool) { + for i := range r.routes { + if provider, ok := r.routes[i].client.(BuildoorInfoProvider); ok && provider.BuildoorAvailable() { + return r.routes[i].client, true + } + } + + return nil, false +} + // selectWorkflowRoute returns the first wrapped client (in configured order) // that both implements WorkflowInfoProvider and advertises an enabled workflow // engine, along with the resolved web URL. diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go index 3f21a2e1..5683f9c2 100644 --- a/pkg/proxy/server.go +++ b/pkg/proxy/server.go @@ -80,6 +80,7 @@ type server struct { benchmarkoorHandler *handlers.BenchmarkoorHandler computeHandler *handlers.ComputeHandler workflowHandler *handlers.WorkflowHandler + buildoorHandler *handlers.BuildoorHandler uploadsHandler *handlers.UploadsHandler uploadsLimiter *RateLimiter embeddingService *EmbeddingService @@ -224,6 +225,15 @@ func newServer(log logrus.FieldLogger, cfg ServerConfig, hostURL, port string) ( s.workflowHandler = workflowHandler } + if cfg.Buildoor != nil { + s.buildoorHandler = handlers.NewBuildoorHandler(log, handlers.BuildoorConfig{ + CFAccessClientID: cfg.Buildoor.CFAccessClientID, + CFAccessClientSecret: cfg.Buildoor.CFAccessClientSecret, + StaticToken: cfg.Buildoor.StaticToken, + DomainSuffix: cfg.Buildoor.DomainSuffix, + }) + } + if uploadsCfg := cfg.ToUploadsHandlerConfig(); uploadsCfg != nil { uploadsHandler, err := handlers.NewUploadsHandler(log, *uploadsCfg) if err != nil { @@ -382,6 +392,10 @@ func (s *server) registerRoutes() { s.handleSubtreeRoute("/workflow", s.metricsMiddleware(chain(s.workflowHandler))) } + if s.buildoorHandler != nil { + s.handleSubtreeRoute("/buildoor", s.metricsMiddleware(chain(s.buildoorHandler))) + } + if s.githubHandler != nil { s.handleSubtreeRoute("/github", s.metricsMiddleware(chain(s.githubHandler))) } @@ -454,6 +468,16 @@ type DatasourcesResponse struct { // Workflow advertises the workflow engine as a capability. Nil when the // proxy does not expose one (or the caller is not authorized for it). Workflow *WorkflowAdvert `json:"workflow,omitempty"` + + // Buildoor advertises credentialed devnet buildoor API access. Nil when + // the proxy does not expose it (or the caller is not authorized for it). + Buildoor *BuildoorAdvert `json:"buildoor,omitempty"` +} + +// BuildoorAdvert advertises the buildoor capability in discovery. It carries +// only availability — never the CF Access credentials or minted tokens. +type BuildoorAdvert struct { + Enabled bool `json:"enabled"` } // WorkflowAdvert advertises the workflow engine capability in discovery. It @@ -479,6 +503,7 @@ type datasourcesResponseWire struct { EmbeddingAvailable bool `json:"embedding_available,omitempty"` EmbeddingModel string `json:"embedding_model,omitempty"` Workflow *WorkflowAdvert `json:"workflow,omitempty"` + Buildoor *BuildoorAdvert `json:"buildoor,omitempty"` } // MarshalJSON emits both the detailed *Info lists and the derived name-only @@ -497,6 +522,7 @@ func (d DatasourcesResponse) MarshalJSON() ([]byte, error) { EmbeddingAvailable: d.EmbeddingAvailable, EmbeddingModel: d.EmbeddingModel, Workflow: d.Workflow, + Buildoor: d.Buildoor, }) } @@ -517,6 +543,7 @@ func (d *DatasourcesResponse) UnmarshalJSON(data []byte) error { d.EmbeddingAvailable = wire.EmbeddingAvailable d.EmbeddingModel = wire.EmbeddingModel d.Workflow = wire.Workflow + d.Buildoor = wire.Buildoor return nil } @@ -560,6 +587,7 @@ func (s *server) handleDatasources(w http.ResponseWriter, r *http.Request) { EmbeddingAvailable: s.EmbeddingAvailable(), EmbeddingModel: s.EmbeddingModel(), Workflow: s.workflowAdvert(), + Buildoor: s.buildoorAdvert(), } if s.authorizer != nil { @@ -1251,6 +1279,14 @@ func (s *server) workflowAdvert() *WorkflowAdvert { // WorkflowInfo reports whether this proxy advertises a workflow engine and the // web URL for building human links to it, resolved from config. +func (s *server) buildoorAdvert() *BuildoorAdvert { + if s.cfg.Buildoor == nil { + return nil + } + + return &BuildoorAdvert{Enabled: true} +} + func (s *server) WorkflowInfo() (enabled bool, webURL string) { if s.cfg.Workflow == nil { return false, "" diff --git a/pkg/proxy/server_config.go b/pkg/proxy/server_config.go index 6e082eed..eb6add8e 100644 --- a/pkg/proxy/server_config.go +++ b/pkg/proxy/server_config.go @@ -64,6 +64,10 @@ type ServerConfig struct { // GitHub holds optional GitHub API configuration for triggering workflows. GitHub *GitHubAPIConfig `yaml:"github,omitempty"` + // Buildoor holds optional devnet buildoor API access configuration. Nil + // disables the /buildoor route. + Buildoor *BuildoorProxyConfig `yaml:"buildoor,omitempty"` + // Workflow holds optional workflow-engine passthrough configuration. Nil // disables the /workflow route entirely. Workflow *WorkflowConfig `yaml:"workflow,omitempty"` @@ -131,6 +135,32 @@ type GitHubAPIConfig struct { Token string `yaml:"token"` } +// BuildoorProxyConfig holds devnet buildoor API access configuration. The +// credentials live HERE, at the proxy layer, never on the panda server: a CF +// Access service token lets the proxy mint per-devnet authenticatoor JWTs +// (auth.. runs behind Cloudflare Access with +// allowServiceTokens), which it injects as the bearer on instance API calls. +type BuildoorProxyConfig struct { + // CFAccessClientID/Secret is the Cloudflare Access service token included + // in the devnet auth applications' Access policy. ${ENV}-substitutable. + CFAccessClientID string `yaml:"cf_access_client_id,omitempty"` + CFAccessClientSecret string `yaml:"cf_access_client_secret,omitempty"` + // StaticToken bypasses minting with a fixed bearer (local/ad-hoc setups). + StaticToken string `yaml:"static_token,omitempty"` + // DomainSuffix is the devnet DNS zone. Default: "ethpandaops.io". + DomainSuffix string `yaml:"domain_suffix,omitempty"` + // AllowedOrgs restricts /buildoor access to members of these orgs/groups + // (e.g. "ethpandaops:Core"). Empty leaves it open to all authenticated + // callers. + AllowedOrgs []string `yaml:"allowed_orgs,omitempty"` + // RequiredAudience additionally requires the caller's verified OIDC token + // to carry this audience (e.g. "buildoor"). The IdP cross-grants it per + // group (mirroring the workflow engine's audience pattern), so access is + // controlled centrally at the IdP even if allowed_orgs drifts. Only + // meaningful in oidc auth mode; ignored when the proxy runs unauthenticated. + RequiredAudience string `yaml:"required_audience,omitempty"` +} + // Workflow auth modes select how the proxy credentials the upstream workflow // engine on each request. const ( @@ -655,8 +685,8 @@ func (c *ServerConfig) Validate() error { // Validate the proxy serves something: at least one datasource, or the // workflow engine (a proxy dedicated to engine passthrough is valid). - if len(c.ClickHouse) == 0 && len(c.Prometheus) == 0 && len(c.Loki) == 0 && c.EthNode == nil && len(c.Benchmarkoor) == 0 && len(c.Compute) == 0 && c.Workflow == nil { - return fmt.Errorf("at least one datasource (clickhouse, prometheus, loki, ethnode, benchmarkoor, or compute) or the workflow engine must be configured") + if len(c.ClickHouse) == 0 && len(c.Prometheus) == 0 && len(c.Loki) == 0 && c.EthNode == nil && len(c.Benchmarkoor) == 0 && len(c.Compute) == 0 && c.Workflow == nil && c.Buildoor == nil { + return fmt.Errorf("at least one datasource (clickhouse, prometheus, loki, ethnode, benchmarkoor, or compute), the workflow engine, or buildoor must be configured") } // Validate ClickHouse configs. @@ -791,6 +821,32 @@ func (c *ServerConfig) Validate() error { return err } + if err := c.validateBuildoor(); err != nil { + return err + } + + return nil +} + +// validateBuildoor validates the optional buildoor block: it must carry either +// the CF Access service token pair or a static token, never a half pair. +func (c *ServerConfig) validateBuildoor() error { + if c.Buildoor == nil { + return nil + } + + hasID := strings.TrimSpace(c.Buildoor.CFAccessClientID) != "" + hasSecret := strings.TrimSpace(c.Buildoor.CFAccessClientSecret) != "" + hasStatic := strings.TrimSpace(c.Buildoor.StaticToken) != "" + + if hasID != hasSecret { + return errors.New("buildoor.cf_access_client_id and cf_access_client_secret must be provided together") + } + + if !hasID && !hasStatic { + return errors.New("buildoor requires cf_access_client_id/cf_access_client_secret or static_token") + } + return nil } diff --git a/pkg/proxy/server_config_workflow_test.go b/pkg/proxy/server_config_workflow_test.go index 0fa9f0cc..9839bd73 100644 --- a/pkg/proxy/server_config_workflow_test.go +++ b/pkg/proxy/server_config_workflow_test.go @@ -204,7 +204,7 @@ func TestWorkflowOnlyProxySatisfiesDatasourceRequirement(t *testing.T) { err := empty.Validate() require.Error(t, err) - assert.Contains(t, err.Error(), "or the workflow engine must be configured") + assert.Contains(t, err.Error(), "the workflow engine, or buildoor must be configured") } func TestWorkflowConfigResolvedHelpers(t *testing.T) { diff --git a/pkg/server/operations_buildoor.go b/pkg/server/operations_buildoor.go new file mode 100644 index 00000000..ee11c2bf --- /dev/null +++ b/pkg/server/operations_buildoor.go @@ -0,0 +1,547 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/ethpandaops/panda/pkg/operations" + "github.com/ethpandaops/panda/pkg/proxy" +) + +// buildoorRequestTimeout bounds every upstream buildoor call. +const buildoorRequestTimeout = 30 * time.Second + +// buildoorInstance is one buildoor builder instance behind a network's +// overview service. Name is the short instance identifier derived from the +// host (e.g. "lighthouse-geth-1"); URL is the instance API base URL. +type buildoorInstance struct { + Name string `json:"name"` + URL string `json:"url"` +} + +func (s *service) handleBuildoorOperation(operationID string, w http.ResponseWriter, r *http.Request) bool { + switch operationID { + case "buildoor.list_networks": + s.handleBuildoorListNetworks(w) + case "buildoor.list_instances": + s.handleBuildoorListInstances(w, r) + case "buildoor.get_overview": + s.handleBuildoorInstanceGet(w, r, "/api/buildoor/overview", nil) + case "buildoor.get_action_plan": + s.handleBuildoorSlotRangeGet(w, r, "/api/buildoor/action-plan") + case "buildoor.get_slot_results": + s.handleBuildoorSlotRangeGet(w, r, "/api/buildoor/slot-results") + case "buildoor.update_action_plan": + s.handleBuildoorUpdateActionPlan(w, r) + case "buildoor.test_transform": + s.handleBuildoorTestTransform(w, r) + default: + return false + } + + return true +} + +func (s *service) handleBuildoorListNetworks(w http.ResponseWriter) { + networks, err := s.buildoorNetworks() + if err != nil { + writeAPIError(w, http.StatusServiceUnavailable, err.Error()) + return + } + + items := make([]listItem, 0, len(networks)) + for name, baseURL := range networks { + items = append(items, listItem{ + Name: name, + URL: baseURL, + Type: "buildoor", + }) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].Name < items[j].Name + }) + + writeOperationResponse(s.log, w, http.StatusOK, operations.Response{ + Kind: operations.ResultKindObject, + Data: map[string]any{"networks": items}, + }) +} + +func (s *service) handleBuildoorListInstances(w http.ResponseWriter, r *http.Request) { + req, err := decodeOperationRequest(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + instances, status, err := s.buildoorInstances(r.Context(), req.Args) + if err != nil { + writeAPIError(w, status, err.Error()) + return + } + + items := make([]listItem, 0, len(instances)) + for _, instance := range instances { + items = append(items, listItem{ + Name: instance.Name, + URL: instance.URL, + Type: "buildoor", + }) + } + + writeOperationResponse(s.log, w, http.StatusOK, operations.Response{ + Kind: operations.ResultKindObject, + Data: map[string]any{"instances": items}, + }) +} + +// handleBuildoorInstanceGet proxies a GET on an instance API path. +func (s *service) handleBuildoorInstanceGet( + w http.ResponseWriter, + r *http.Request, + path string, + params url.Values, +) { + req, err := decodeOperationRequest(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + instance, status, err := s.resolveBuildoorInstance(r.Context(), req.Args) + if err != nil { + writeAPIError(w, status, err.Error()) + return + } + + s.buildoorPassthrough(r.Context(), w, http.MethodGet, instance.URL, path, params, nil, "") +} + +// handleBuildoorSlotRangeGet proxies a GET that takes the action-plan API's +// inclusive min_slot/max_slot range. +func (s *service) handleBuildoorSlotRangeGet(w http.ResponseWriter, r *http.Request, path string) { + req, err := decodeOperationRequest(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + instance, status, err := s.resolveBuildoorInstance(r.Context(), req.Args) + if err != nil { + writeAPIError(w, status, err.Error()) + return + } + + params := url.Values{} + + for _, key := range []string{"min_slot", "max_slot"} { + value, err := parseInt64Arg(req.Args[key], key) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + params.Set(key, fmt.Sprintf("%d", value)) + } + + s.buildoorPassthrough(r.Context(), w, http.MethodGet, instance.URL, path, params, nil, "") +} + +// handleBuildoorUpdateActionPlan forwards a bulk action-plan mutation to an +// instance. The updates array is passed through verbatim — buildoor owns the +// PlanUpdate schema and validates it (bad jq / unknown fields → 400, frozen +// or past slots → 409). +// +// Credentials, in precedence order: an explicit caller token (auth_token arg) +// goes direct to the instance and keeps per-user attribution in buildoor's +// audit log; otherwise the mutation routes through a proxy that advertises +// buildoor — the proxy is the credential boundary and mints the devnet +// authenticatoor JWT itself (the acting human stays attributed via the proxy's +// audit log). With neither, the direct call reaches buildoor unauthenticated +// and its 401 comes back with the remedy. +func (s *service) handleBuildoorUpdateActionPlan(w http.ResponseWriter, r *http.Request) { + req, err := decodeOperationRequest(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + instance, status, err := s.resolveBuildoorInstance(r.Context(), req.Args) + if err != nil { + writeAPIError(w, status, err.Error()) + return + } + + updates := optionalSliceArg(req.Args, "updates") + if len(updates) == 0 { + writeAPIError(w, http.StatusBadRequest, "updates is required") + return + } + + body, err := json.Marshal(map[string]any{"updates": updates}) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, fmt.Sprintf("encoding updates: %v", err)) + return + } + + token := optionalStringArg(req.Args, "auth_token") + + if token == "" { + if route, ok := s.buildoorRoute(); ok { + network := optionalStringArg(req.Args, "network") + path := fmt.Sprintf("/buildoor/%s/%s/api/buildoor/action-plan", + url.PathEscape(network), url.PathEscape(instance.Name)) + + data, proxyStatus, responseHeaders, err := s.proxyRequestWithService( + r.Context(), route, http.MethodPost, path, + bytes.NewReader(body), http.Header{"Content-Type": []string{"application/json"}}, + ) + if err != nil { + writeAPIError(w, http.StatusBadGateway, fmt.Sprintf("proxy request failed: %v", err)) + return + } + + if proxyStatus < 200 || proxyStatus >= 300 { + writeAPIError(w, proxyStatus, buildoorErrorMessage(proxyStatus, data)) + return + } + + writePassthroughResponse(w, http.StatusOK, responseHeaders.Get("Content-Type"), data) + + return + } + } + + s.buildoorPassthrough( + r.Context(), w, http.MethodPost, instance.URL, "/api/buildoor/action-plan", nil, body, token, + ) +} + +// buildoorRoute resolves the proxy route that advertises credentialed buildoor +// access, mirroring workflowRoute. +func (s *service) buildoorRoute() (proxy.Service, bool) { + if s.proxyService == nil { + return nil, false + } + + if router, ok := s.proxyService.(proxy.Router); ok { + client, found := router.BuildoorRoute() + if !found { + return nil, false + } + + return client, true + } + + if provider, ok := s.proxyService.(proxy.BuildoorInfoProvider); ok && provider.BuildoorAvailable() { + return s.proxyService, true + } + + return nil, false +} + +// handleBuildoorTestTransform evaluates a jq expression against a sample +// builder object on the instance (a captured artifact when sample_slot is +// given and available, otherwise a template). Read-only; no auth required. +func (s *service) handleBuildoorTestTransform(w http.ResponseWriter, r *http.Request) { + req, err := decodeOperationRequest(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + instance, status, err := s.resolveBuildoorInstance(r.Context(), req.Args) + if err != nil { + writeAPIError(w, status, err.Error()) + return + } + + target, err := requiredStringArg(req.Args, "target") + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + expression, err := requiredStringArg(req.Args, "expression") + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + payload := map[string]any{"target": target, "expression": expression} + if sampleSlot := optionalIntArg(req.Args, "sample_slot", 0); sampleSlot > 0 { + payload["sample_slot"] = sampleSlot + } + + body, err := json.Marshal(payload) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, fmt.Sprintf("encoding request: %v", err)) + return + } + + s.buildoorPassthrough( + r.Context(), w, http.MethodPost, instance.URL, "/api/buildoor/action-plan/test-transform", nil, body, "", + ) +} + +func (s *service) buildoorNetworks() (map[string]string, error) { + if s.cartographoorClient == nil { + return nil, fmt.Errorf("buildoor is unavailable") + } + + networks := make(map[string]string) + + for name, network := range s.cartographoorClient.GetActiveNetworks() { + if network.ServiceURLs != nil && network.ServiceURLs.Buildoor != "" { + networks[name] = network.ServiceURLs.Buildoor + } + } + + return networks, nil +} + +func (s *service) buildoorOverviewURL(args map[string]any) (string, int, error) { + network, err := requiredStringArg(args, "network") + if err != nil { + return "", http.StatusBadRequest, err + } + + networks, err := s.buildoorNetworks() + if err != nil { + return "", http.StatusServiceUnavailable, err + } + + baseURL, ok := networks[network] + if !ok { + names := make([]string, 0, len(networks)) + for name := range networks { + names = append(names, name) + } + + sort.Strings(names) + + return "", http.StatusNotFound, fmt.Errorf("no buildoor on network %q. Available: %v", network, names) + } + + return baseURL, http.StatusOK, nil +} + +// buildoorInstances resolves the network's per-instance API URLs from the +// overview service's host list. +func (s *service) buildoorInstances(ctx context.Context, args map[string]any) ([]buildoorInstance, int, error) { + overviewURL, status, err := s.buildoorOverviewURL(args) + if err != nil { + return nil, status, err + } + + requestCtx, cancel := context.WithTimeout(ctx, buildoorRequestTimeout) + defer cancel() + + req, err := http.NewRequestWithContext( + requestCtx, + http.MethodGet, + strings.TrimRight(overviewURL, "/")+"/api/overview/hosts", + nil, + ) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("creating buildoor request: %w", err) + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, http.StatusBadGateway, fmt.Errorf("querying buildoor overview: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, http.StatusBadGateway, fmt.Errorf("reading buildoor overview response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, http.StatusBadGateway, + fmt.Errorf("buildoor overview returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var payload struct { + Hosts []struct { + URL string `json:"url"` + Label string `json:"label"` + } `json:"hosts"` + } + + if err := json.Unmarshal(body, &payload); err != nil { + return nil, http.StatusBadGateway, fmt.Errorf("invalid buildoor overview response: %w", err) + } + + instances := make([]buildoorInstance, 0, len(payload.Hosts)) + for _, host := range payload.Hosts { + instances = append(instances, buildoorInstance{ + Name: buildoorInstanceName(host.Label, host.URL), + URL: strings.TrimRight(host.URL, "/"), + }) + } + + return instances, http.StatusOK, nil +} + +// resolveBuildoorInstance resolves the "instance" arg (short name, full label, +// or URL) against the network's instance list. +func (s *service) resolveBuildoorInstance( + ctx context.Context, args map[string]any, +) (buildoorInstance, int, error) { + instance, err := requiredStringArg(args, "instance") + if err != nil { + return buildoorInstance{}, http.StatusBadRequest, err + } + + instances, status, err := s.buildoorInstances(ctx, args) + if err != nil { + return buildoorInstance{}, status, err + } + + names := make([]string, 0, len(instances)) + + for _, candidate := range instances { + if instance == candidate.Name || strings.TrimRight(instance, "/") == candidate.URL { + return candidate, http.StatusOK, nil + } + + names = append(names, candidate.Name) + } + + return buildoorInstance{}, http.StatusNotFound, + fmt.Errorf("unknown buildoor instance %q. Available: %v", instance, names) +} + +// buildoorInstanceName derives the short instance identifier from an overview +// host entry: the first DNS label of the host, minus the "api-"/"buildoor-" +// deployment prefixes (e.g. "api-buildoor-lighthouse-geth-1.srv.…" → +// "lighthouse-geth-1"). IP-addressed hosts stay whole (host:port) — a +// first-label cut would collapse them all to the first octet. +func buildoorInstanceName(label, rawURL string) string { + host := label + if host == "" { + if parsed, err := url.Parse(rawURL); err == nil { + host = parsed.Host + } + } + + hostname := host + if h, _, err := net.SplitHostPort(host); err == nil { + hostname = h + } + + if net.ParseIP(hostname) != nil { + return host + } + + name, _, _ := strings.Cut(host, ".") + name = strings.TrimPrefix(name, "api-") + name = strings.TrimPrefix(name, "buildoor-") + + if name == "" { + return host + } + + return name +} + +// buildoorPassthrough executes one instance API request and forwards the +// upstream response verbatim — including non-2xx statuses, so buildoor's own +// validation errors (bad jq → 400, frozen slot → 409, bad token → 401) reach +// the caller with their original message. +func (s *service) buildoorPassthrough( + ctx context.Context, + w http.ResponseWriter, + method, instanceURL, path string, + params url.Values, + body []byte, + bearerToken string, +) { + requestURL := instanceURL + path + if len(params) > 0 { + requestURL += "?" + params.Encode() + } + + requestCtx, cancel := context.WithTimeout(ctx, buildoorRequestTimeout) + defer cancel() + + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + + req, err := http.NewRequestWithContext(requestCtx, method, requestURL, reader) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, fmt.Sprintf("creating buildoor request: %v", err)) + return + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + if bearerToken != "" { + req.Header.Set("Authorization", "Bearer "+bearerToken) + } + + resp, err := s.httpClient.Do(req) + if err != nil { + writeAPIError(w, http.StatusBadGateway, fmt.Sprintf("executing buildoor request: %v", err)) + return + } + defer func() { _ = resp.Body.Close() }() + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + writeAPIError(w, http.StatusBadGateway, fmt.Sprintf("reading buildoor response: %v", err)) + return + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + writeAPIError(w, resp.StatusCode, buildoorErrorMessage(resp.StatusCode, responseBody)) + return + } + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + + writePassthroughResponse(w, http.StatusOK, contentType, responseBody) +} + +// buildoorErrorMessage extracts buildoor's {"error": …} message and adds the +// operator hint for the two statuses with a known remedy. +func buildoorErrorMessage(status int, body []byte) string { + message := strings.TrimSpace(string(body)) + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err == nil { + if text, ok := payload["error"].(string); ok && text != "" { + message = text + } + } + + switch status { + case http.StatusUnauthorized: + return message + " (buildoor mutations need either a proxy that advertises buildoor" + + " — hosted panda-proxy with the buildoor credential configured —" + + " or a personal authenticatoor bearer token via --token)" + case http.StatusConflict: + return message + " (the slot is past or its plan is frozen — plans freeze ~1 slot ahead, target slots ≥2 ahead)" + default: + return message + } +} diff --git a/pkg/server/operations_buildoor_test.go b/pkg/server/operations_buildoor_test.go new file mode 100644 index 00000000..93f6ca9d --- /dev/null +++ b/pkg/server/operations_buildoor_test.go @@ -0,0 +1,320 @@ +package server + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethpandaops/cartographoor/pkg/discovery" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/panda/pkg/operations" +) + +// newBuildoorTestUpstream fakes a devnet: the overview service's host list +// pointing at one instance server, whose handler is supplied by the test. +func newBuildoorTestUpstream(t *testing.T, instanceHandler http.HandlerFunc) (overview, instance *httptest.Server) { + t.Helper() + + instance = httptest.NewServer(instanceHandler) + t.Cleanup(instance.Close) + + overview = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/overview/hosts" { + http.NotFound(w, r) + return + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "hosts": []map[string]any{ + {"id": 0, "url": instance.URL, "label": "api-buildoor-prysm-ethrex-1.srv.testnet.ethpandaops.io"}, + }, + }) + })) + t.Cleanup(overview.Close) + + return overview, instance +} + +func newBuildoorOperationService(overviewURL string) *service { + log := logrus.New() + log.SetOutput(io.Discard) + + return &service{ + log: log, + httpClient: http.DefaultClient, + cartographoorClient: doraOperationCartographoor{ + networks: map[string]discovery.Network{ + "testnet": { + Name: "testnet", + Status: "active", + ServiceURLs: &discovery.ServiceURLs{ + Buildoor: overviewURL, + }, + }, + }, + }, + } +} + +func newBuildoorOpRequest(t *testing.T, args map[string]any) *http.Request { + t.Helper() + + body, err := json.Marshal(operations.Request{Args: args}) + require.NoError(t, err) + + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/runtime/operations/buildoor", + io.NopCloser(bytes.NewReader(body)), + ) + req.Header.Set("Content-Type", "application/json") + + return req +} + +func TestBuildoorListInstancesDerivesShortNames(t *testing.T) { + t.Parallel() + + overview, instance := newBuildoorTestUpstream(t, http.NotFound) + + svc := newBuildoorOperationService(overview.URL) + rec := httptest.NewRecorder() + + handled := svc.handleBuildoorOperation( + "buildoor.list_instances", rec, newBuildoorOpRequest(t, map[string]any{"network": "testnet"}), + ) + require.True(t, handled) + require.Equal(t, http.StatusOK, rec.Code) + + var response operations.Response + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response)) + + data, ok := response.Data.(map[string]any) + require.True(t, ok) + + instances, ok := data["instances"].([]any) + require.True(t, ok) + require.Len(t, instances, 1) + + entry, ok := instances[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "prysm-ethrex-1", entry["name"]) + assert.Equal(t, instance.URL, entry["url"]) +} + +func TestBuildoorUpdateActionPlanForwardsTokenAndUpdates(t *testing.T) { + t.Parallel() + + var ( + gotAuth string + gotBody map[string]any + ) + + overview, _ := newBuildoorTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/api/buildoor/action-plan", r.URL.Path) + + gotAuth = r.Header.Get("Authorization") + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) + + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "updated", + "slots": []uint64{1234}, + "plans": []any{nil}, + }) + }) + + svc := newBuildoorOperationService(overview.URL) + rec := httptest.NewRecorder() + + handled := svc.handleBuildoorOperation("buildoor.update_action_plan", rec, newBuildoorOpRequest(t, map[string]any{ + "network": "testnet", + "instance": "prysm-ethrex-1", + "auth_token": "test-token", + "updates": []any{ + map[string]any{ + "slots": []any{float64(1234)}, + "set": map[string]any{"transforms.payload": ".gas_limit = 300000000"}, + }, + }, + })) + require.True(t, handled) + require.Equal(t, http.StatusOK, rec.Code) + + assert.Equal(t, "Bearer test-token", gotAuth) + + updates, ok := gotBody["updates"].([]any) + require.True(t, ok) + require.Len(t, updates, 1) + + update, ok := updates[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, + map[string]any{"transforms.payload": ".gas_limit = 300000000"}, + update["set"], + ) + + var response map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response)) + assert.Equal(t, "updated", response["status"]) +} + +func TestBuildoorUpdateActionPlanRequiresUpdates(t *testing.T) { + t.Parallel() + + overview, _ := newBuildoorTestUpstream(t, http.NotFound) + + svc := newBuildoorOperationService(overview.URL) + rec := httptest.NewRecorder() + + handled := svc.handleBuildoorOperation("buildoor.update_action_plan", rec, newBuildoorOpRequest(t, map[string]any{ + "network": "testnet", + "instance": "prysm-ethrex-1", + })) + require.True(t, handled) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "updates is required") +} + +func TestBuildoorUpdateActionPlanSurfacesUpstreamConflict(t *testing.T) { + t.Parallel() + + overview, _ := newBuildoorTestUpstream(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "slot 10 is frozen"}) + }) + + svc := newBuildoorOperationService(overview.URL) + rec := httptest.NewRecorder() + + handled := svc.handleBuildoorOperation("buildoor.update_action_plan", rec, newBuildoorOpRequest(t, map[string]any{ + "network": "testnet", + "instance": "prysm-ethrex-1", + "auth_token": "test-token", + "updates": []any{map[string]any{"slots": []any{float64(10)}, "transforms": nil}}, + })) + require.True(t, handled) + assert.Equal(t, http.StatusConflict, rec.Code) + assert.Contains(t, rec.Body.String(), "slot 10 is frozen") + assert.Contains(t, rec.Body.String(), "target slots ≥2 ahead") +} + +func TestBuildoorSlotRangeGetPassesRangeThrough(t *testing.T) { + t.Parallel() + + overview, _ := newBuildoorTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/buildoor/action-plan", r.URL.Path) + assert.Equal(t, "100", r.URL.Query().Get("min_slot")) + assert.Equal(t, "132", r.URL.Query().Get("max_slot")) + + _ = json.NewEncoder(w).Encode(map[string]any{"plans": []any{}, "min_slot": 100, "max_slot": 132}) + }) + + svc := newBuildoorOperationService(overview.URL) + rec := httptest.NewRecorder() + + handled := svc.handleBuildoorOperation("buildoor.get_action_plan", rec, newBuildoorOpRequest(t, map[string]any{ + "network": "testnet", + "instance": "prysm-ethrex-1", + "min_slot": float64(100), + "max_slot": float64(132), + })) + require.True(t, handled) + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "plans") +} + +func TestBuildoorTestTransformPostsExpression(t *testing.T) { + t.Parallel() + + var gotBody map[string]any + + overview, _ := newBuildoorTestUpstream(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/buildoor/action-plan/test-transform", r.URL.Path) + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) + + _ = json.NewEncoder(w).Encode(map[string]any{ + "target": "payload", + "input": "{}", + "input_source": "template", + "output": "{}", + }) + }) + + svc := newBuildoorOperationService(overview.URL) + rec := httptest.NewRecorder() + + handled := svc.handleBuildoorOperation("buildoor.test_transform", rec, newBuildoorOpRequest(t, map[string]any{ + "network": "testnet", + "instance": "prysm-ethrex-1", + "target": "payload", + "expression": ".gas_limit = 300000000", + "sample_slot": float64(42), + })) + require.True(t, handled) + require.Equal(t, http.StatusOK, rec.Code) + + assert.Equal(t, "payload", gotBody["target"]) + assert.Equal(t, ".gas_limit = 300000000", gotBody["expression"]) + assert.Equal(t, float64(42), gotBody["sample_slot"]) +} + +func TestBuildoorUnknownInstanceListsAvailable(t *testing.T) { + t.Parallel() + + overview, _ := newBuildoorTestUpstream(t, http.NotFound) + + svc := newBuildoorOperationService(overview.URL) + rec := httptest.NewRecorder() + + handled := svc.handleBuildoorOperation("buildoor.get_overview", rec, newBuildoorOpRequest(t, map[string]any{ + "network": "testnet", + "instance": "nope", + })) + require.True(t, handled) + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Contains(t, rec.Body.String(), "prysm-ethrex-1") +} + +func TestBuildoorUnknownNetworkListsAvailable(t *testing.T) { + t.Parallel() + + svc := newBuildoorOperationService("http://unused.invalid") + rec := httptest.NewRecorder() + + handled := svc.handleBuildoorOperation("buildoor.list_instances", rec, newBuildoorOpRequest(t, map[string]any{ + "network": "othernet", + })) + require.True(t, handled) + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Contains(t, rec.Body.String(), "testnet") +} + +func TestBuildoorInstanceName(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + label, url, want string + }{ + "devnet host": {"api-buildoor-lighthouse-geth-1.srv.devnet.ethpandaops.io", "", "lighthouse-geth-1"}, + "plain host": {"builder-a.example.com", "", "builder-a"}, + "ip host": {"10.0.0.1:8085", "", "10.0.0.1:8085"}, + "host with port": {"a:8082", "", "a:8082"}, + "label from url": {"", "http://10.0.0.1:8085", "10.0.0.1:8085"}, + "api only prefix": {"api-builder.example.com", "", "builder"}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, buildoorInstanceName(tc.label, tc.url)) + }) + } +} diff --git a/pkg/server/operations_dispatch.go b/pkg/server/operations_dispatch.go index 5a2ef261..2793630c 100644 --- a/pkg/server/operations_dispatch.go +++ b/pkg/server/operations_dispatch.go @@ -18,6 +18,7 @@ func (s *service) dispatchOperation(operationID string, w http.ResponseWriter, r s.handleEthNodeOperation, s.handleCBTOperation, s.handleBenchmarkoorOperation, + s.handleBuildoorOperation, s.handleComputeOperation, s.handleTracoorOperation, s.handleSpecsOperation, diff --git a/proxy-config.example.yaml b/proxy-config.example.yaml index bc16c4e8..924233c1 100644 --- a/proxy-config.example.yaml +++ b/proxy-config.example.yaml @@ -220,6 +220,28 @@ loki: # allowed_orgs: # - ethpandaops:Core +# Devnet buildoor API access (optional). Relays /buildoor/{network}/{instance}/api/* +# to api-buildoor-{instance}.srv.{network}.. The proxy is the +# credential boundary: it mints per-devnet authenticatoor JWTs by calling +# auth.{network}./auth/token with the CF Access service token +# below (the token must be included in the devnet auth applications' Access +# policy; the minted JWT's subject is the service token's common name, which +# appears in buildoor's audit log — the acting human stays attributed in this +# proxy's own audit log). static_token bypasses minting for local setups. +# buildoor: +# cf_access_client_id: "${BUILDOOR_CF_ACCESS_CLIENT_ID}" +# cf_access_client_secret: "${BUILDOOR_CF_ACCESS_CLIENT_SECRET}" +# # static_token: "" # fixed bearer instead of CF minting +# # domain_suffix: "ethpandaops.io" # devnet DNS zone +# allowed_orgs: +# - ethpandaops:Core +# # required_audience additionally requires the caller's OIDC token to carry +# # this audience. Authentik cross-grants it per group (mirror of the wagie +# # `workflows` pattern), so access stays centrally managed at the IdP even +# # if allowed_orgs drifts. Callers must request the matching scope at login +# # (proxies[].auth.scopes in their panda config). +# # required_audience: "buildoor" + # Ethereum node API access (beacon and execution nodes) # Single credential pair for all bn-*.srv.*.ethpandaops.io and rpc-*.srv.*.ethpandaops.io endpoints # ethnode: diff --git a/runbooks/buildoor_action_plan.md b/runbooks/buildoor_action_plan.md new file mode 100644 index 00000000..c7c69125 --- /dev/null +++ b/runbooks/buildoor_action_plan.md @@ -0,0 +1,110 @@ +--- +name: Drive Buildoor Per-Slot Action Plans +description: Script a devnet buildoor builder instance's behavior for specific future slots — hot-patch the built execution payload with a jq transform (e.g. .gas_limit = 300000000), rewrite bid or envelope messages before re-signing, override bid values, withhold reveals — then verify what each slot actually did. Covers discovering which buildoor instances a devnet runs, validating expressions with test-transform before applying, plan freeze semantics (409 conflict on past or frozen slots, target at least 2 slots ahead), authenticatoor bearer tokens for mutations (401 unauthorized), and reading per-slot results. +tags: [buildoor, devnet, epbs, builder, action-plan, jq] +triggers: + - hot patch buildoor payload gas limit on a devnet slot + - set a jq transform on future slots in buildoor + - buildoor action plan update 409 slot frozen or in the past + - which buildoor builder instances run on this devnet + - buildoor 401 unauthorized bearer token action plan + - did the transformed slot build a bid check slot results +--- + +Owns scripting a buildoor builder instance's per-slot action plan from the CLI and +verifying the outcome. The action plan is the sanctioned way to alter what a builder +produces on chosen slots — a jq transform on a future slot replaces any temptation to +patch payload handling into the builder itself. Judging whether the resulting network +behavior is healthy stays with `runbooks://debug_ethereum_network`. + +## Model + +A devnet runs one buildoor instance per builder host (named `--N`, e.g. +`prysm-ethrex-1`). Each instance keeps a sparse per-slot **action plan**: absent +slots inherit the instance's global config; a planned slot overrides it. Transforms +are operator-supplied jq programs applied to the object's JSON form when the slot +executes: + +| Transform | Applies to | Effect | +| ---------- | ------------------------------- | ------------------------------------------------------------------ | +| `payload` | built execution payload | feeds both the bid commitment and the reveal (block hash re-synced) | +| `bid` | bid message just before signing | re-signed after rewrite — validly signed but customized | +| `envelope` | envelope message before signing | re-signed; lets the reveal diverge from the bid commitment | + +Plans **freeze** when execution for their slot can begin, roughly 1 slot ahead; +edits to past or frozen slots fail with 409. **Target slots at least 2 ahead** +(`--slots +2` or later). + +The same operations serve every surface: the CLI commands shown below, and +sandbox Python via `from ethpandaops import buildoor` (`list_instances`, +`test_transform`, `set_transforms`, `get_slot_results` — see the buildoor +examples index for filled scripts). + +## Procedure + +1. **Discover.** `panda buildoor networks` lists devnets with a buildoor + deployment; `panda buildoor instances ` lists that devnet's builder + instances live from its overview service. Commands address instances by the + short name shown there. +2. **Anchor slots.** Slot arguments accept absolute numbers or offsets against the + instance's current slot: `+N` ahead, `+-N` behind. Offsets resolve from the + instance's own overview; an unsynced instance reporting `current_slot=0` + rejects offsets — pass absolute slots or pick a healthy instance + (`panda buildoor overview ` shows `current_slot`). +3. **Validate the expression first.** test-transform runs the exact production jq + path against a sample object without touching any plan — a captured artifact + when `--sample-slot` names one, else a template: + + ```bash + panda buildoor test-transform payload '.gas_limit = 300000000' + ``` + +4. **Apply to future slots.** Mutations need a bearer token (see Auth): + + ```bash + panda buildoor transform --slots +2,+3 \ + --payload '.gas_limit = 300000000' + ``` + + `--from +2 --to +10` targets a range; `--payload ''` clears that one + expression; `--clear` removes all three. For non-transform plan categories + (bid values, reveal gating, builder-api overrides), pass raw PlanUpdate JSON: + `panda buildoor plan-update --updates '[{"slots":[N],"set":{"bid.bid_value_gwei":5000}}]'`. +5. **Verify.** `panda buildoor plan ` shows planned slots + (defaults to current−8..current+24); after the slot passes, + `panda buildoor results --min-slot N --max-slot M` + returns the attempt-level outcome — build, bids, submissions, reveals, + inclusion — plus the frozen plan the slot actually ran with. A runtime + transform failure fails that slot's construction loudly and is recorded there. + +## Auth + +Reads are open — no credential involved. Mutations are credentialed one of two +ways: + +1. **Proxy-credentialed (default).** When a connected panda proxy advertises + buildoor, mutations route through it with no flags: the proxy holds a CF + Access service token, mints a JWT from the devnet's authenticatoor + (`auth.`), and injects it. Buildoor's audit log shows the service + identity; the acting human stays attributed in the proxy's audit log. +2. **Personal token (override).** Pass `--token` (or `PANDA_BUILDOOR_TOKEN`) + with a bearer minted in a browser at + `https://auth..ethpandaops.io/auth/token` (SSO-gated). This goes + direct to the instance and puts your own identity in buildoor's audit log. + +## Failure modes + +| Symptom | Cause and remedy | +| ---------------------------------------------- | ------------------------------------------------------------------------------------ | +| 409 "slot … frozen" / "in the past" | plan froze (~1 slot ahead); retarget ≥2 slots ahead | +| 401 unauthorized | no proxy advertises buildoor and no/expired personal token — see Auth | +| 400 on apply | invalid jq or unknown PlanUpdate field; reproduce with test-transform | +| "current_slot=0 — cannot resolve relative" | that instance's beacon node is unsynced; use absolute slots or another instance | +| slot passed, no bid in results | transform may have failed at construction — read the slot's result record | + +## Self-Check + +- Expression validated with test-transform before applying. +- Target slots ≥2 ahead of the instance's current slot at apply time. +- After execution, the slot's result record shows the frozen plan you set and the + expected outcome (or the recorded failure). diff --git a/sandbox/ethpandaops/ethpandaops/__init__.py b/sandbox/ethpandaops/ethpandaops/__init__.py index e991cb75..b901c106 100644 --- a/sandbox/ethpandaops/ethpandaops/__init__.py +++ b/sandbox/ethpandaops/ethpandaops/__init__.py @@ -39,6 +39,7 @@ def __getattr__(name): if name in ( "benchmarkoor", "block_archive", + "buildoor", "cbt", "clickhouse", "compute",