Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions sdk/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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)

Expand Down
35 changes: 35 additions & 0 deletions sdk/sei/imports_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
15 changes: 12 additions & 3 deletions sdk/sei/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 0 additions & 53 deletions sdk/sei/provider/local/local.go

This file was deleted.

32 changes: 0 additions & 32 deletions sdk/sei/provider/local/local_test.go

This file was deleted.

19 changes: 10 additions & 9 deletions sdk/sei/provider/registry_drift_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
7 changes: 4 additions & 3 deletions sdk/sei/readiness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
21 changes: 9 additions & 12 deletions sdk/sei/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 {
Expand All @@ -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")
}
Expand Down
36 changes: 23 additions & 13 deletions sdk/sei/sei.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -61,17 +73,14 @@ 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)
}
switch len(present) {
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, ", "))
}
Expand Down Expand Up @@ -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.
Expand Down
Loading