mirror of
https://github.com/hanzoai/base.git
synced 2026-08-07 00:05:53 +00:00
Why: consolidate the Python bootnode backend (bootnode/api/, ~100 .py files)
onto Hanzo Base, leveraging Base's IAM client and per-org/per-user tenant
infrastructure instead of reimplementing auth, sessions, and multi-tenancy.
This is the structural foundation + the 5 most-important modules, not the whole
port.
What landed
- plugins/bootnode/: blockchain developer platform mounted under /v1.
Five modules ported end-to-end:
1. auth — multi-network OAuth2 callback (lux/pars/zoo/hanzo share the
lux-web3 IAM app; client id derived from redirect_uri) + bn_
project API keys (salted SHA-256, raw key shown once, verified
in constant time). Accepts IAM JWTs and pk-/sk-/hk- keys by
reusing github.com/hanzoai/base/iam — no IAM logic duplicated.
2. team — org/member CRUD scoped to the caller's project; invited emails
resolved against IAM (active) or held pending with an invite
token.
3. networks — applies bootno.de/v1 Network CRs (white-label brand, tier,
region, validator fleet). Replaces the Python's kubectl + raw
nginx-Ingress templating with a declarative CR for the
bootno.de operator to reconcile.
4. nodes — applies bootno.de/v1 NodeFleet CRs (CRD-driven cloud path;
the Python docker provider was a local-dev concern).
5. keys — applies bootno.de/v1 KMSSecret CRs by KMS path. NO plaintext
key material ever touches this service; the request and
response carry none, and a guard rejects any private-key field.
- plugins/bootnode/kube/: dependency-free Kubernetes REST client (net/http
server-side apply). No client-go, no CGO. In-cluster SA or KUBE_APISERVER.
- plugins/commerce/: typed Hanzo Commerce (Square billing) client behind a
Client interface. bootnode depends on the interface; commerce never depends
on bootnode/iam.
- 11 SQLAlchemy models -> Base collections (models/collections.go). No `users`
collection: IAM owns identity; bootnode references IAM user ids as text.
OrgCluster is the canonical org->k8s-cluster mapping.
- examples/base/main.go: platform plugin now runs PrincipalIsolation="sqlite"
(per-org + per-user encrypted SQLite); bootnode registered (BOOTNODE_ENABLED).
Modules pending (15, tracked in PR body): chat, zap, billing-http, bundler,
fleets, gas, infra, launch, lux, mpc, nfts, observability, rpc, tokens,
transfers, wallets, webhooks-http. (chains is also ported as a bonus 6th.)
Tests (20 functions, all green incl -race)
- auth: token classification, key gen/hash/verify (tamper + wrong-salt),
redirect->clientId derivation.
- kube: server-side-apply shape (PATCH + apply-patch+yaml + fieldManager),
error propagation, 404 get/delete idempotency.
- commerce: disabled no-op, get-or-create (create + existing), usage error
propagation, immediate cancel.
- workers: HMAC-signed delivery, non-2xx-is-failure, unreachable-is-failure.
- bootnode: full end-to-end against a fake IAM + fake apiserver — /me 401 then
200, project + bn_ key, team invite/list, Network + NodeFleet CR apply/get,
KMSSecret plaintext-rejection + apply + status, public chains. Plus
fail-fast on the insecure default salt against production IAM.
Verified: `go build ./...` exits 0; `go test -race ./plugins/bootnode/...
./plugins/commerce/...` passes; live binary serves /v1/chains (200) and gates
/v1/auth/me, /v1/networks (401); per-org SQLite isolation logged active; all
10 _bootnode_ collections created on boot.
Co-authored-by: zeekay <z@zeekay.io>
171 lines
5.5 KiB
Go
171 lines
5.5 KiB
Go
package bootnode
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/plugins/bootnode/kube"
|
|
)
|
|
|
|
// nodePresets maps a simple-mode preset to a fleet configuration. The Go port
|
|
// targets bootno.de/v1 NodeFleet CRs (operator-reconciled) rather than the
|
|
// Python's direct Docker container orchestration — fleets run in the cluster,
|
|
// not on the API host.
|
|
var nodePresets = map[string]map[string]any{
|
|
"rpc": {
|
|
"executionClient": "geth", "consensusClient": "", "syncMode": "light",
|
|
"enableMEV": false, "enableValidator": false,
|
|
},
|
|
"full": {
|
|
"executionClient": "geth", "consensusClient": "lighthouse", "syncMode": "snap",
|
|
"enableMEV": false, "enableValidator": false,
|
|
},
|
|
"staking": {
|
|
"executionClient": "geth", "consensusClient": "lighthouse", "syncMode": "snap",
|
|
"enableMEV": true, "enableValidator": true,
|
|
},
|
|
"archive": {
|
|
"executionClient": "erigon", "consensusClient": "lighthouse", "syncMode": "archive",
|
|
"enableMEV": false, "enableValidator": false,
|
|
},
|
|
}
|
|
|
|
// handleCreateNodeFleet applies a bootno.de/v1 NodeFleet custom resource. Ports
|
|
// POST /nodes — the CRD-driven, production path (the Python's docker provider
|
|
// is a local-dev concern outside Base's remit; cloud provisioning was always
|
|
// the intended production target).
|
|
func (p *plugin) handleCreateNodeFleet(e *core.RequestEvent) error {
|
|
id, err := p.requireUser(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if id.ReadOnly {
|
|
return e.ForbiddenError("publishable keys are read-only", nil)
|
|
}
|
|
if !p.kube.Available() {
|
|
return e.JSON(http.StatusServiceUnavailable, map[string]any{
|
|
"error": "no Kubernetes cluster configured for node provisioning",
|
|
})
|
|
}
|
|
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Chain string `json:"chain"`
|
|
Network string `json:"network"`
|
|
Mode string `json:"mode"`
|
|
Preset string `json:"preset"`
|
|
Replicas int `json:"replicas"`
|
|
ExecutionClient string `json:"executionClient"`
|
|
ConsensusClient string `json:"consensusClient"`
|
|
SyncMode string `json:"syncMode"`
|
|
EnableMEV bool `json:"enableMev"`
|
|
EnableValidator bool `json:"enableValidator"`
|
|
FeeRecipient string `json:"feeRecipient"`
|
|
}
|
|
if err := e.BindBody(&body); err != nil {
|
|
return e.BadRequestError("invalid request body", err)
|
|
}
|
|
body.Name = strings.TrimSpace(strings.ToLower(body.Name))
|
|
if !isCRName(body.Name) {
|
|
return e.BadRequestError("name must be a lowercase DNS-1123 label", nil)
|
|
}
|
|
if body.Chain == "" {
|
|
return e.BadRequestError("chain is required", nil)
|
|
}
|
|
network := body.Network
|
|
if network == "" {
|
|
network = "mainnet"
|
|
}
|
|
|
|
// Resolve preset for simple mode (Go port of the Python preset expansion).
|
|
execClient, consClient, syncMode := body.ExecutionClient, body.ConsensusClient, body.SyncMode
|
|
enableMEV, enableValidator := body.EnableMEV, body.EnableValidator
|
|
if body.Mode == "simple" && body.Preset != "" {
|
|
preset, ok := nodePresets[body.Preset]
|
|
if !ok {
|
|
return e.BadRequestError("unknown preset (rpc, full, staking, archive)", nil)
|
|
}
|
|
execClient, _ = preset["executionClient"].(string)
|
|
consClient, _ = preset["consensusClient"].(string)
|
|
syncMode, _ = preset["syncMode"].(string)
|
|
enableMEV, _ = preset["enableMEV"].(bool)
|
|
enableValidator, _ = preset["enableValidator"].(bool)
|
|
}
|
|
if execClient == "" {
|
|
execClient = "geth"
|
|
}
|
|
if syncMode == "" {
|
|
syncMode = "snap"
|
|
}
|
|
replicas := body.Replicas
|
|
if replicas <= 0 {
|
|
replicas = 1
|
|
}
|
|
|
|
spec := map[string]any{
|
|
"chain": strings.ToLower(body.Chain),
|
|
"network": strings.ToLower(network),
|
|
"replicas": replicas,
|
|
"executionClient": execClient,
|
|
"consensusClient": consClient,
|
|
"syncMode": syncMode,
|
|
"enableMev": enableMEV,
|
|
"enableValidator": enableValidator,
|
|
"createdBy": id.UserID,
|
|
"org": id.Org,
|
|
}
|
|
if body.FeeRecipient != "" {
|
|
spec["feeRecipient"] = body.FeeRecipient
|
|
}
|
|
|
|
if _, err := p.kube.Apply(e.Request.Context(), kube.NodeFleetGVR, body.Name, orgLabels(id.Org), spec); err != nil {
|
|
return e.InternalServerError("failed to apply NodeFleet resource", err)
|
|
}
|
|
|
|
return e.JSON(http.StatusCreated, map[string]any{
|
|
"id": body.Name,
|
|
"name": body.Name,
|
|
"chain": spec["chain"],
|
|
"network": spec["network"],
|
|
"replicas": replicas,
|
|
"executionClient": execClient,
|
|
"status": "provisioning",
|
|
"namespace": p.kube.Namespace(),
|
|
})
|
|
}
|
|
|
|
// handleListNodeFleets lists NodeFleet CRs for the caller's org. Ports
|
|
// GET /nodes.
|
|
func (p *plugin) handleListNodeFleets(e *core.RequestEvent) error {
|
|
id, err := p.requireUser(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return p.listCRs(e, kube.NodeFleetGVR, id.Org)
|
|
}
|
|
|
|
// handleGetNodeFleet returns one NodeFleet CR. Ports GET /nodes/{id}.
|
|
func (p *plugin) handleGetNodeFleet(e *core.RequestEvent) error {
|
|
if _, err := p.requireUser(e); err != nil {
|
|
return err
|
|
}
|
|
return p.getCR(e, kube.NodeFleetGVR, e.Request.PathValue("id"))
|
|
}
|
|
|
|
// handleDeleteNodeFleet tears down a NodeFleet CR. Ports DELETE /nodes/{id}.
|
|
func (p *plugin) handleDeleteNodeFleet(e *core.RequestEvent) error {
|
|
id, err := p.requireUser(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if id.ReadOnly {
|
|
return e.ForbiddenError("publishable keys are read-only", nil)
|
|
}
|
|
name := e.Request.PathValue("id")
|
|
if err := p.kube.Delete(e.Request.Context(), kube.NodeFleetGVR, name); err != nil {
|
|
return e.InternalServerError("failed to delete NodeFleet resource", err)
|
|
}
|
|
return e.JSON(http.StatusOK, map[string]any{"status": "deleted", "id": name})
|
|
}
|