From ba8ccf78fd1b57c2fa58ab2e3d99c6e963ca7a6c Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 24 Jun 2026 06:43:06 -0700 Subject: [PATCH] refactor(sdk): reframe sei SDK as a wire-client product (Phase A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK ships WIRE providers (k8s, docker) that speak RPC/CRDs to an already-running chain. An in-process ("local") provider is a category error: it would link sei-chain's Go source (uninstallable in the controller; co-versioned with the chain). The in-process harness lives in sei-chain and conforms to these handle interfaces by shape, registered/constructed consumer-side. - delete the local ErrNotImplemented stub package - drop the built-in "local" mode + SEI_LOCAL from env resolution (registry stays generic — a consumer may still Register("local", …)) - name the contract seam: Provider/*Handle + *Spec + registry stay free of controller-runtime/apimachinery/CRD imports - add a go-list import-discipline test enforcing that seam - pin built-in modes to exactly {k8s, docker}; refresh docs docker is untouched. No interface signatures change. Co-Authored-By: Claude Opus 4.8 --- sdk/CLAUDE.md | 34 +++++++++++++--- sdk/sei/imports_test.go | 35 ++++++++++++++++ sdk/sei/provider.go | 15 +++++-- sdk/sei/provider/local/local.go | 53 ------------------------- sdk/sei/provider/local/local_test.go | 32 --------------- sdk/sei/provider/registry_drift_test.go | 19 ++++----- sdk/sei/readiness.go | 7 ++-- sdk/sei/registry_test.go | 21 +++++----- sdk/sei/sei.go | 36 +++++++++++------ 9 files changed, 121 insertions(+), 131 deletions(-) create mode 100644 sdk/sei/imports_test.go delete mode 100644 sdk/sei/provider/local/local.go delete mode 100644 sdk/sei/provider/local/local_test.go diff --git a/sdk/CLAUDE.md b/sdk/CLAUDE.md index 0d10b20c..11bc538a 100644 --- a/sdk/CLAUDE.md +++ b/sdk/CLAUDE.md @@ -4,6 +4,13 @@ A thin, typed, **stateless**, **multi-mode** Go-native API for SeiNetwork/SeiNod lifecycle. Mirrors `database/sql`: a provider registers in `init()`, the consumer blank-imports it, and `Open(ctx, mode)` selects the mode by name (or env). +The SDK is a **wire-client product**: it ships providers that speak RPC/CRDs to an +already-running chain (k8s, docker), so pinning it to a chain release is fine. It +does **not** host an in-process provider — that would link sei-chain's Go source +(uninstallable in the controller, and co-versioned with the chain). An in-process +harness lives in sei-chain, conforms to these same handle interfaces by shape, and +is registered/constructed consumer-side. + The flow a harness drives: create network -> `WaitReady` -> create RPC nodes as peers -> `WaitReady` -> run tests against the returned Go handles -> `Delete`. @@ -34,9 +41,11 @@ Comments and PRs are why-not-what; names carry intent. No ticket IDs in code `*Client` is safe for sequential use only — not goroutine-safe across calls. **Mode selection.** `Open(ctx, mode)`: explicit `mode` wins; with `""`, exactly -one of `SEI_NODE_CLUSTER` (=> k8s), `SEI_LOCAL` (=> local), `SEI_DOCKER` (=> -docker) must be set — more than one, or none, is an error. Providers self-register -via blank import (`_ ".../sdk/sei/provider/k8s"`). +one of `SEI_NODE_CLUSTER` (=> k8s), `SEI_DOCKER` (=> docker) must be set — more +than one, or none, is an error. Env resolution covers the SDK's built-in wire +modes only; a consumer-registered provider is selected by passing its name to +`Open` explicitly. Providers self-register via blank import +(`_ ".../sdk/sei/provider/k8s"`). **`WaitReady` = phase + LIGHT serve-probe.** Network: phase Ready + one TM `/status` liveness check. Node: phase Running + one serve-probe (EVM @@ -57,7 +66,15 @@ is success). The SDK never auto-deletes — including on a failed `WaitReady`. **Raw-CR escape.** `Network.Object()` / `Node.Object()` return `any`; in k8s mode the caller type-asserts to `*v1alpha1.SeiNetwork` / `*v1alpha1.SeiNode`. Keeps the -k8s type off the mode-agnostic surface; local/docker stubs return nil. +k8s type off the mode-agnostic surface; a provider with no native object returns +nil. + +**Contract seam.** The `Provider`/`*Handle` interfaces, the `*Spec` structs, and +the registry are the SDK's stable, dependency-light contract — the surface a +consumer's in-process harness conforms to by shape, and the seam a future leaf +module would extract. It MUST stay free of controller-runtime / apimachinery / CRD +imports; those live in the k8s provider package alone (the concrete object reaches +callers only via `Object() any`). ## Source of truth @@ -75,8 +92,13 @@ authors them once. endpoints. The node object lives at `NodeSpec.Namespace`; the peer selector searches `NodeSpec.NetworkNamespace` (where the genesis validators live), defaulting to the node's namespace when empty — the co-located common case. -- **local**, **docker** — registered STUBS. `Open` resolves them, but every verb - returns `ErrNotImplemented` so a mode picked by env fails clearly. +- **docker** — registered wire STUB. `Open` resolves it, but every verb returns + `ErrNotImplemented` so a mode picked by env fails clearly. + +The SDK ships no in-process ("local") provider. A consumer that needs one (e.g. +sei-chain's in-repo harness) implements the same handle interfaces by shape and +calls `provider.Register("local", …)` / `Open(ctx, "local")` itself — the SDK's +generic registry permits this without hosting the adapter. ## One-way doors (need sign-off to change; additive is safe) diff --git a/sdk/sei/imports_test.go b/sdk/sei/imports_test.go new file mode 100644 index 00000000..b86c78d0 --- /dev/null +++ b/sdk/sei/imports_test.go @@ -0,0 +1,35 @@ +package sei_test + +import ( + "context" + "os/exec" + "strings" + "testing" +) + +// TestCoreContractStaysDependencyLight enforces the contract-seam invariant: the +// core sei package (Provider/*Handle interfaces, *Spec structs, registry) is the +// dependency-light surface a consumer's in-process harness conforms to by shape, +// so it must not pull controller-runtime / apimachinery / CRD code into its +// transitive imports. Those belong to the k8s provider package alone; the +// concrete object reaches callers only via Object() any. A drifted import here +// fails the build instead of silently coupling the seam to k8s. +func TestCoreContractStaysDependencyLight(t *testing.T) { + out, err := exec.CommandContext(context.Background(), "go", "list", "-deps", "github.com/sei-protocol/sei-k8s-controller/sdk/sei").Output() + if err != nil { + t.Fatalf("go list -deps: %v", err) + } + banned := []string{ + "sigs.k8s.io/controller-runtime", + "k8s.io/apimachinery", + "k8s.io/client-go", + "github.com/sei-protocol/sei-k8s-controller/api", + } + for dep := range strings.FieldsSeq(string(out)) { + for _, b := range banned { + if dep == b || strings.HasPrefix(dep, b+"/") { + t.Errorf("core sei package transitively imports %q (banned: %q) — the contract seam must stay dependency-light", dep, b) + } + } + } +} diff --git a/sdk/sei/provider.go b/sdk/sei/provider.go index 7a6125d3..27b07131 100644 --- a/sdk/sei/provider.go +++ b/sdk/sei/provider.go @@ -6,10 +6,19 @@ import ( "sync" ) +// The Provider/*Handle interfaces, the *Spec structs (spec.go), and the registry +// are the SDK's stable, dependency-light CONTRACT — the seam a consumer's +// in-process harness conforms to by shape, and the boundary a future leaf module +// would extract. This contract surface MUST stay free of controller-runtime / +// apimachinery / CRD imports; those belong to the k8s provider package alone, the +// concrete object surfacing only through Object() any. + // Provider is the flavor contract — a thin, stateless CRUD driver. The core -// *Client is a typed facade over exactly these methods. k8s implements it; local -// and docker are registered stubs. It lives in core so providers depend on core, -// not vice-versa; the public provider.Provider is an alias of this type. +// *Client is a typed facade over exactly these methods. k8s implements it as a +// real wire provider; docker is a registered wire stub. A consumer may register +// its own provider (e.g. sei-chain's in-process harness) — the SDK ships none +// beyond the wire modes. It lives in core so providers depend on core, not +// vice-versa; the public provider.Provider is an alias of this type. // // A provider holds only the mode connection/config (kube client, etc.) — never a // registry, cache, or tracking of provisioned resources. The runtime owns diff --git a/sdk/sei/provider/local/local.go b/sdk/sei/provider/local/local.go deleted file mode 100644 index fcda4346..00000000 --- a/sdk/sei/provider/local/local.go +++ /dev/null @@ -1,53 +0,0 @@ -// Package local is the registered stub for the SEI_LOCAL in-process mode. The -// interface is shaped so an in-process provider can be added without touching -// core or the k8s provider, but the implementation is not built. Every verb -// returns a clear "mode not implemented" error so a harness that selects "local" -// by env fails clearly rather than silently no-op'ing. -package local - -import ( - "context" - "errors" - - "github.com/sei-protocol/sei-k8s-controller/sdk/sei" - "github.com/sei-protocol/sei-k8s-controller/sdk/sei/provider" -) - -func init() { provider.Register("local", New) } - -// ErrNotImplemented is returned by every local-mode verb. Callers can errors.Is -// on it to detect the unimplemented mode. -var ErrNotImplemented = errors.New("local mode not implemented — only the k8s mode ships; use SEI_NODE_CLUSTER / the k8s mode") - -// Provider is the local stub. -type Provider struct{} - -// New is the registered Factory. It succeeds (so Open resolves "local") but every -// verb fails with ErrNotImplemented — the cut is honest, not a crash. -func New(context.Context) (provider.Provider, error) { return &Provider{}, nil } - -func (*Provider) Name() string { return "local" } - -func (*Provider) CreateNetwork(context.Context, sei.NetworkSpec) (sei.NetworkHandle, error) { - return nil, ErrNotImplemented -} - -func (*Provider) GetNetwork(context.Context, string, string) (sei.NetworkHandle, error) { - return nil, ErrNotImplemented -} - -func (*Provider) CreateNode(context.Context, sei.NodeSpec) (sei.NodeHandle, error) { - return nil, ErrNotImplemented -} - -func (*Provider) GetNode(context.Context, string, string) (sei.NodeHandle, error) { - return nil, ErrNotImplemented -} - -func (*Provider) RunTask(context.Context, sei.TaskSpec) (sei.TaskHandle, error) { - return nil, ErrNotImplemented -} - -func (*Provider) GetTask(context.Context, string, string) (sei.TaskHandle, error) { - return nil, ErrNotImplemented -} diff --git a/sdk/sei/provider/local/local_test.go b/sdk/sei/provider/local/local_test.go deleted file mode 100644 index 1fa33382..00000000 --- a/sdk/sei/provider/local/local_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package local - -import ( - "context" - "errors" - "testing" - - "github.com/sei-protocol/sei-k8s-controller/sdk/sei" -) - -func TestStub_RegistersButVerbsNotImplemented(t *testing.T) { - p, err := New(context.Background()) - if err != nil { - t.Fatalf("New: %v", err) - } - if p.Name() != "local" { - t.Fatalf("Name = %q, want local", p.Name()) - } - - if _, err := p.CreateNetwork(context.Background(), sei.NetworkSpec{}); !errors.Is(err, ErrNotImplemented) { - t.Errorf("CreateNetwork err = %v, want ErrNotImplemented", err) - } - if _, err := p.CreateNode(context.Background(), sei.NodeSpec{}); !errors.Is(err, ErrNotImplemented) { - t.Errorf("CreateNode err = %v, want ErrNotImplemented", err) - } - if _, err := p.GetNetwork(context.Background(), "n", "ns"); !errors.Is(err, ErrNotImplemented) { - t.Errorf("GetNetwork err = %v, want ErrNotImplemented", err) - } - if _, err := p.GetNode(context.Background(), "n", "ns"); !errors.Is(err, ErrNotImplemented) { - t.Errorf("GetNode err = %v, want ErrNotImplemented", err) - } -} diff --git a/sdk/sei/provider/registry_drift_test.go b/sdk/sei/provider/registry_drift_test.go index 7c8c31fa..b51f6743 100644 --- a/sdk/sei/provider/registry_drift_test.go +++ b/sdk/sei/provider/registry_drift_test.go @@ -11,18 +11,19 @@ import ( _ "github.com/sei-protocol/sei-k8s-controller/sdk/sei/provider/docker" _ "github.com/sei-protocol/sei-k8s-controller/sdk/sei/provider/k8s" - _ "github.com/sei-protocol/sei-k8s-controller/sdk/sei/provider/local" ) -// TestProviderKeysMatchOpenModes pins each provider's Register key to the mode -// literal Open resolves to ("k8s"/"local"/"docker"). Open's doc notes this match -// is by duplicated literal with nothing mechanical enforcing it; this is that -// enforcement — a drifted key fails the build instead of failing Open at runtime. +// TestProviderKeysMatchOpenModes pins the SDK's built-in wire providers to +// exactly the modes Open resolves by env ("k8s", "docker"). Open's doc notes the +// key match is a duplicated literal with nothing mechanical enforcing it; this is +// that enforcement — a drifted or extra built-in key fails the build instead of +// failing Open at runtime. Consumer-registered modes are out of scope here: they +// are not blank-imported by the SDK and are selected by explicit name, not env. func TestProviderKeysMatchOpenModes(t *testing.T) { registered := sei.RegisteredProviders() - for _, mode := range []string{"k8s", "local", "docker"} { - if !slices.Contains(registered, mode) { - t.Errorf("mode %q resolvable by Open but no provider Registered under it; registered: %v", mode, registered) - } + want := []string{"docker", "k8s"} + slices.Sort(registered) + if !slices.Equal(registered, want) { + t.Errorf("SDK built-in providers = %v, want exactly %v", registered, want) } } diff --git a/sdk/sei/readiness.go b/sdk/sei/readiness.go index abc241bd..7065d058 100644 --- a/sdk/sei/readiness.go +++ b/sdk/sei/readiness.go @@ -14,9 +14,10 @@ import ( // Readiness probes are the generally-useful chain-provisioning lifecycle piece: // "the node has joined consensus and is actually serving," not merely "the pod is // Running." They are mode-agnostic — they take a published endpoint URL and speak -// HTTP, so the k8s/local/docker providers and external callers all share one -// implementation. Kept stdlib-only (no apimachinery) so -// the core package stays dependency-free for lightweight external consumers. +// HTTP, so every provider (the SDK's wire modes and any consumer-registered one) +// and external callers all share one implementation. Kept stdlib-only (no +// apimachinery) so the core package stays dependency-free for lightweight +// external consumers. // probeInterval is the readiness poll cadence; a var so tests can shrink it // (matching provider/k8s/probe.go's unexported probeInterval). diff --git a/sdk/sei/registry_test.go b/sdk/sei/registry_test.go index adb898c5..2bdfa869 100644 --- a/sdk/sei/registry_test.go +++ b/sdk/sei/registry_test.go @@ -10,7 +10,6 @@ import ( // Shared test constants for the sei package tests (registry/Open env selection). const ( envNodeCluster = "SEI_NODE_CLUSTER" - envLocal = "SEI_LOCAL" envDocker = "SEI_DOCKER" ) @@ -101,15 +100,13 @@ func TestResolveMode_EnvSelection(t *testing.T) { wantErr bool }{ {"SEI_NODE_CLUSTER => k8s", map[string]string{envNodeCluster: "1"}, modeK8s, false}, - {"SEI_LOCAL => local", map[string]string{envLocal: "1"}, modeLocal, false}, {"SEI_DOCKER => docker", map[string]string{envDocker: "1"}, modeDocker, false}, - {"two set => ambiguous", map[string]string{envNodeCluster: "1", envLocal: "1"}, "", true}, - {"all set => ambiguous", map[string]string{envNodeCluster: "1", envLocal: "1", envDocker: "1"}, "", true}, + {"both set => ambiguous", map[string]string{envNodeCluster: "1", envDocker: "1"}, "", true}, {"none set => fail", map[string]string{}, "", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - for _, k := range []string{envNodeCluster, envLocal, envDocker} { + for _, k := range []string{envNodeCluster, envDocker} { _ = os.Unsetenv(k) } for k, v := range tc.env { @@ -128,25 +125,25 @@ func TestResolveMode_EnvSelection(t *testing.T) { func TestResolveMode_ExplicitBeatsEnv(t *testing.T) { t.Setenv(envNodeCluster, "1") - t.Setenv(envLocal, "1") // ambiguous env... - got, err := resolveMode(modeDocker) + t.Setenv(envDocker, "1") // ambiguous env... + got, err := resolveMode("consumer-mode") if err != nil { t.Fatalf("explicit mode should bypass env ambiguity: %v", err) } - if got != modeDocker { - t.Fatalf("resolved %q, want docker", got) + if got != "consumer-mode" { + t.Fatalf("resolved %q, want consumer-mode", got) } } func TestOpen_AmbiguousEnv_FailFastThroughOpen(t *testing.T) { resetRegistry(t) registerStub(modeK8s) - registerStub(modeLocal) - for _, k := range []string{envNodeCluster, envLocal, envDocker} { + registerStub(modeDocker) + for _, k := range []string{envNodeCluster, envDocker} { _ = os.Unsetenv(k) } t.Setenv(envNodeCluster, "1") - t.Setenv(envLocal, "1") + t.Setenv(envDocker, "1") if _, err := Open(context.Background(), ""); err == nil { t.Fatal("ambiguous env should fail fast through Open") } diff --git a/sdk/sei/sei.go b/sdk/sei/sei.go index 8aec62b2..0d842123 100644 --- a/sdk/sei/sei.go +++ b/sdk/sei/sei.go @@ -2,6 +2,15 @@ // SeiNetwork/SeiNode lifecycle. It mirrors database/sql: a provider registers in // init(), the consumer blank-imports it, and Open selects the mode by name. // +// The SDK ships WIRE providers — they speak RPC/CRDs to an already-running chain +// (k8s over the controller's CRDs; docker over container-per-node RPC), so +// pinning the SDK to a chain release is fine. It deliberately does NOT host an +// in-process ("local") provider: that would link sei-chain's Go source, which the +// controller cannot transitively build and which would co-version the harness +// with the chain. An in-process harness lives in sei-chain instead; it satisfies +// the Provider/*Handle interfaces by shape (structural typing) and is registered +// or constructed consumer-side via RegisterProvider — the SDK does not ship it. +// // The SDK is a CRUD layer, NOT an orchestrator. The flow is: create a network -> // wait ready -> create RPC nodes as peers -> wait ready -> run tests against the // returned handles. Orchestration — cleanup, GC, rollback, composition — is the @@ -20,9 +29,11 @@ import ( // package provider). // // With mode == "", Open resolves from env presence: SEI_NODE_CLUSTER => "k8s", -// SEI_LOCAL => "local", SEI_DOCKER => "docker". More than one present (or none) -// is an error — never guess. The k8s provider resolves its config from the -// ambient kubeconfig chain; there is no caller-supplied connection string. +// SEI_DOCKER => "docker". More than one present (or none) is an error — never +// guess. The k8s provider resolves its config from the ambient kubeconfig chain; +// there is no caller-supplied connection string. A consumer that registers its +// own provider (e.g. an in-process harness) passes its mode name explicitly; +// only the SDK's built-in wire modes participate in env resolution. func Open(ctx context.Context, mode string) (*Client, error) { resolved, err := resolveMode(mode) if err != nil { @@ -42,17 +53,18 @@ func Open(ctx context.Context, mode string) (*Client, error) { return &Client{provider: p}, nil } -// Mode names the core matches in env-presence detection. Kept in sync with the -// keys the provider packages pass to Register (the literals are duplicated there; -// nothing mechanical enforces the match). +// Mode names the core matches in env-presence detection — the SDK's built-in +// wire modes only. Kept in sync with the keys the provider packages pass to +// Register (the literals are duplicated there; nothing mechanical enforces the +// match). A consumer-registered mode is selected by passing its name to Open, not +// by env. const ( modeK8s = "k8s" - modeLocal = "local" modeDocker = "docker" ) // resolveMode picks the mode: explicit arg wins, else env presence (exactly one -// of SEI_NODE_CLUSTER / SEI_LOCAL / SEI_DOCKER). +// of SEI_NODE_CLUSTER / SEI_DOCKER). func resolveMode(mode string) (string, error) { if mode != "" { return mode, nil @@ -61,9 +73,6 @@ func resolveMode(mode string) (string, error) { if _, ok := os.LookupEnv("SEI_NODE_CLUSTER"); ok { present = append(present, modeK8s) } - if _, ok := os.LookupEnv("SEI_LOCAL"); ok { - present = append(present, modeLocal) - } if _, ok := os.LookupEnv("SEI_DOCKER"); ok { present = append(present, modeDocker) } @@ -71,7 +80,7 @@ func resolveMode(mode string) (string, error) { case 1: return present[0], nil case 0: - return "", usageErr("no mode selected — pass a mode to Open, or set exactly one of SEI_NODE_CLUSTER / SEI_LOCAL / SEI_DOCKER") + return "", usageErr("no mode selected — pass a mode to Open, or set exactly one of SEI_NODE_CLUSTER / SEI_DOCKER") default: return "", usageErr("mode is ambiguous: %s all set — pass an explicit mode to Open", strings.Join(present, ", ")) } @@ -156,7 +165,8 @@ func (n *Network) Delete(ctx context.Context) error { return n.handle.Delete(ctx // Object returns the mode-specific raw resource (k8s: *v1alpha1.SeiNetwork) for // callers that need fields the mode-agnostic surface does not expose. The caller -// type-asserts; local/docker stubs return nil. +// type-asserts; a provider with no native object (e.g. a non-k8s wire mode) +// returns nil. func (n *Network) Object() any { return n.handle.Object() } // Node is a handle to a SeiNode.