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
2 changes: 2 additions & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
- [Policy](api/policy.md)
- [Gateway](api/gateway.md)
- [OIDC](api/oidc.md)
- [Edge](api/edge.md)
- [Fake](api/fake.md)

# Guides

Expand Down
91 changes: 91 additions & 0 deletions docs/src/api/edge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Edge

Package: `openshell/v1/edge`

The edge package provides utilities for connecting to OpenShell gateways
through edge proxies such as Cloudflare Access. It includes auth wrappers
for edge proxy headers and a WebSocket tunnel proxy for gRPC transport
through HTTP/1.1-only proxies.

## Cloudflare Access

Wrap any `AuthProvider` with Cloudflare Access headers
(`cf-access-jwt-assertion` and `CF_Authorization` cookie):

```go
import "github.com/rhuss/openshell-sdk-go/openshell/v1/edge"

base := v1.StaticToken("my-gateway-token")
auth, err := edge.CloudflareAccess(base, os.Getenv("CF_ACCESS_TOKEN"))
if err != nil {
log.Fatal(err)
}
client, err := v1.NewClient(v1.Config{
Address: "gateway.example.com:443",
Auth: auth,
})
```

CloudflareAccess composes with any auth provider, including `RefreshableToken`
for automatic token refresh:

```go
tokenSource := oauth2Config.TokenSource(ctx, initialToken)
refreshAuth, err := v1.RefreshableToken(tokenSource)
if err != nil {
log.Fatal(err)
}
auth, err := edge.CloudflareAccess(refreshAuth, cfToken)
```

## WebSocket Tunnel

`TunnelProxy` bridges gRPC connections over a WebSocket tunnel for edge
proxies that reject standard HTTP/2 POST requests. The tunnel carries
its own edge token for proxy authentication, independent of the
application-level auth provider.

```go
tunnel, err := edge.NewTunnelProxy(
"wss://gateway.example.com/ws",
os.Getenv("CF_ACCESS_TOKEN"),
)
if err != nil {
log.Fatal(err)
}
defer tunnel.Close()

auth := v1.StaticToken("my-gateway-token")
client, err := v1.NewClient(v1.Config{
Address: tunnel.Addr(),
Auth: auth,
TLS: &v1.TLSConfig{Insecure: true}, // local tunnel
})
```

## Functions

| Function | Description |
|----------|-------------|
| `CloudflareAccess(base, edgeToken)` | Wrap an AuthProvider with Cloudflare Access headers |
| `NewTunnelProxy(url, edgeToken, opts...)` | Create a WebSocket tunnel proxy for gRPC-over-HTTP/1.1 |

## TunnelProxy Methods

| Method | Description |
|--------|-------------|
| `Addr()` | Local listener address for gRPC client to dial |
| `Close()` | Gracefully drain in-flight connections and shut down |

## TunnelOption

| Constructor | Effect |
|-------------|--------|
| `WithTunnelTLS(cfg)` | Configure TLS for the WebSocket connection |
| `WithTunnelLogger(l)` | Set a logger for tunnel events |
| `WithCloseTimeout(d)` | Override the graceful shutdown timeout (default 5s) |

## Thread Safety

All exported functions and methods are safe for concurrent use.
`Close` is idempotent and safe to call multiple times.
112 changes: 112 additions & 0 deletions docs/src/api/fake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Fake

Package: `openshell/v1/fake`

The fake package provides an in-memory fake implementation of all SDK
client interfaces for use in consumer test suites. It follows the
`client-go/kubernetes/fake` pattern: in-memory stores, watch event
broadcasting, and matching `StatusError` codes for equivalent error
conditions (`NotFound`, `AlreadyExists`, `Unavailable`, `Unimplemented`).

## Quick Start

```go
import "github.com/rhuss/openshell-sdk-go/openshell/v1/fake"

func TestSandboxLifecycle(t *testing.T) {
client := fake.NewClient()
defer client.Close()

ctx := context.Background()

sb, err := client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{}, nil)
require.NoError(t, err)
assert.Equal(t, types.SandboxProvisioning, sb.Status.Phase)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

sb, err = client.Sandboxes().WaitReady(ctx, "default", "my-sandbox")
require.NoError(t, err)
assert.Equal(t, types.SandboxReady, sb.Status.Phase)

require.NoError(t, client.Sandboxes().Delete(ctx, "default", "my-sandbox"))
}
```

## Creating a Client

```go
func NewClient(opts ...ClientOption) *Client
```

Returns a fake client implementing `v1.ClientInterface` with all
sub-clients wired up. Default health result is healthy. Use options
to customize initial state:

```go
client := fake.NewClient(
fake.WithHealthResult(&types.HealthResult{Healthy: false}),
fake.WithCurrentUser(&types.CurrentUser{Subject: "test-user"}),
fake.WithGatewayInfo(&types.GatewayInfo{Version: "1.0.0"}),
)
```

## Pre-populating State

Seed objects directly into the fake stores for test setup:

```go
client := fake.NewClient()

client.AddSandbox("default", &types.Sandbox{
Name: "pre-existing",
Status: types.SandboxStatus{Phase: types.SandboxReady},
})

client.AddProvider("default", &types.Provider{
Name: "my-provider",
Spec: types.ProviderSpec{Type: "docker"},
})

client.AddWorkspace(&types.Workspace{Name: "staging"})
client.AddMember("staging", &types.WorkspaceMember{
PrincipalSubject: "subject-123",
Role: types.WorkspaceRoleAdmin,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

All `Add*` methods deep-copy their arguments; mutating the input after
insertion does not affect the stored object.

## Sub-Client Coverage

The fake client implements every interface in `v1.ClientInterface`:

| Accessor | Interface | Behavior |
|----------|-----------|----------|
| `Sandboxes()` | `SandboxInterface` | Full CRUD, Watch, WaitReady |
| `Providers()` | `ProviderInterface` | Full CRUD, Ensure |
| `Workspaces()` | `WorkspaceInterface` | Full CRUD, Members |
| `Health()` | `HealthInterface` | Configurable result |
| `Inference()` | `InferenceInterface` | Route CRUD |
| `Policy()` | `PolicyInterface` | List, GetStatus (draft ops return Unimplemented) |
| `Exec()` | `ExecInterface` | Returns Unimplemented |
| `Files()` | `FileInterface` | Returns Unimplemented |
| `Services()` | `ServiceInterface` | Returns Unimplemented |
| `SSH()` | `SSHInterface` | Input validation, then Unimplemented |
| `TCP()` | `TCPInterface` | Input validation, then Unimplemented |
| `Config()` | `ConfigInterface` | Returns Unimplemented |

## ClientOption

| Constructor | Effect |
|-------------|--------|
| `WithHealthResult(r)` | Set the health check return value |
| `WithCurrentUser(u)` | Set the current user return value |
| `WithGatewayInfo(i)` | Set the gateway info return value |

## Thread Safety

All operations are safe for concurrent use from multiple goroutines.
`Close` is idempotent and causes all subsequent operations to return
`Unavailable`.

See also: [Testing Guide](../testing.md)
2 changes: 1 addition & 1 deletion openshell/v1/exec_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func (r *stubSandboxResolver) Get(_ context.Context, _, name string) (*Sandbox,
return &Sandbox{ID: "sb-" + name, Name: name}, nil
}

func (r *stubSandboxResolver) Create(context.Context, string, string, *SandboxSpec, map[string]string) (*Sandbox, error) {
func (r *stubSandboxResolver) Create(context.Context, string, string, *SandboxSpec, map[string]string, ...CreateOptions) (*Sandbox, error) {
panic("not implemented")
}
func (r *stubSandboxResolver) List(context.Context, string, ...ListOptions) ([]*Sandbox, error) {
Expand Down
36 changes: 35 additions & 1 deletion openshell/v1/fake/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,37 @@ func copySandboxTemplate(t types.SandboxTemplate) types.SandboxTemplate {
v := *t.UserNamespaces
t.UserNamespaces = &v
}
t.Resources = copyAnyMap(t.Resources)
t.DriverConfig = copyAnyMap(t.DriverConfig)
return t
}

func copyAnyMap(m map[string]any) map[string]any {
if m == nil {
return nil
}
cp := make(map[string]any, len(m))
for k, v := range m {
cp[k] = copyAnyValue(v)
}
return cp
}

func copyAnyValue(v any) any {
switch val := v.(type) {
case map[string]any:
return copyAnyMap(val)
case []any:
s := make([]any, len(val))
for i, elem := range val {
s[i] = copyAnyValue(elem)
}
return s
default:
return v
}
}

func copySandboxStatus(s types.SandboxStatus) types.SandboxStatus {
if s.Conditions != nil {
conds := make([]types.SandboxCondition, len(s.Conditions))
Expand Down Expand Up @@ -211,7 +239,7 @@ func newFakeSandboxClient(
}

// Create creates a new sandbox with Provisioning phase.
func (c *fakeSandboxClient) Create(_ context.Context, workspace, name string, spec *types.SandboxSpec, labels map[string]string) (*types.Sandbox, error) {
func (c *fakeSandboxClient) Create(_ context.Context, workspace, name string, spec *types.SandboxSpec, labels map[string]string, opts ...types.CreateOptions) (*types.Sandbox, error) {
if c.closedFunc() {
return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"}
}
Expand All @@ -220,11 +248,17 @@ func (c *fakeSandboxClient) Create(_ context.Context, workspace, name string, sp
spec = &types.SandboxSpec{}
}

var annotations map[string]string
if len(opts) > 0 {
annotations = copyStringMap(opts[0].Annotations)
}

sb := &types.Sandbox{
Name: name,
Workspace: workspace,
CreatedAt: time.Now(),
Labels: copyStringMap(labels),
Annotations: annotations,
ResourceVersion: 1,
Spec: copySandboxSpec(*spec),
Status: types.SandboxStatus{
Expand Down
104 changes: 104 additions & 0 deletions openshell/v1/fake/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,110 @@ func TestSandbox_Create_AlreadyExists(t *testing.T) {
assert.True(t, types.IsAlreadyExists(err))
}

func TestSandbox_Create_WithAnnotations(t *testing.T) {
sc := newTestSandboxClient()
ctx := context.Background()

sb, err := sc.Create(ctx, "default", "annotated", &types.SandboxSpec{}, nil,
types.CreateOptions{Annotations: map[string]string{"source": "cli", "user": "admin"}})
require.NoError(t, err)
assert.Equal(t, "cli", sb.Annotations["source"])
assert.Equal(t, "admin", sb.Annotations["user"])

got, err := sc.Get(ctx, "default", "annotated")
require.NoError(t, err)
assert.Equal(t, "cli", got.Annotations["source"])
}

func TestSandbox_Create_WithAnnotationsDeepCopy(t *testing.T) {
sc := newTestSandboxClient()
ctx := context.Background()

input := map[string]string{"key": "original"}
sb, err := sc.Create(ctx, "default", "dc-test", &types.SandboxSpec{}, nil,
types.CreateOptions{Annotations: input})
require.NoError(t, err)

input["key"] = "MUTATED"
assert.Equal(t, "original", sb.Annotations["key"], "annotations must be deep copied")
}

func TestSandbox_Create_NoAnnotations(t *testing.T) {
sc := newTestSandboxClient()
ctx := context.Background()

sb, err := sc.Create(ctx, "default", "no-ann", &types.SandboxSpec{}, nil)
require.NoError(t, err)
assert.Nil(t, sb.Annotations)
}

func TestCopyAnyMap(t *testing.T) {
t.Run("nil", func(t *testing.T) {
assert.Nil(t, copyAnyMap(nil))
})

t.Run("flat", func(t *testing.T) {
original := map[string]any{"cpu": "2", "memory": "4Gi"}
copied := copyAnyMap(original)
assert.Equal(t, original, copied)

original["cpu"] = "MUTATED"
assert.Equal(t, "2", copied["cpu"])
})

t.Run("nested map", func(t *testing.T) {
original := map[string]any{
"limits": map[string]any{"cpu": "4", "memory": "8Gi"},
}
copied := copyAnyMap(original)

nested := original["limits"].(map[string]any)
nested["cpu"] = "MUTATED"

copiedNested := copied["limits"].(map[string]any)
assert.Equal(t, "4", copiedNested["cpu"])
})

t.Run("nested slice", func(t *testing.T) {
original := map[string]any{
"ports": []any{float64(80), float64(443)},
}
copied := copyAnyMap(original)

original["ports"].([]any)[0] = float64(9999)
assert.Equal(t, float64(80), copied["ports"].([]any)[0])
})

t.Run("scalar types", func(t *testing.T) {
original := map[string]any{
"str": "hello", "num": float64(42), "flag": true, "null": nil,
}
copied := copyAnyMap(original)
assert.Equal(t, original, copied)
})
}

func TestCopySandboxTemplate_ResourcesDeepCopy(t *testing.T) {
tmpl := types.SandboxTemplate{
Image: "img:v1",
Resources: map[string]any{"cpu": "2", "nested": map[string]any{"key": "val"}},
DriverConfig: map[string]any{"runtime": "kata"},
}

copied := copySandboxTemplate(tmpl)

tmpl.Resources["cpu"] = "MUTATED"
assert.Equal(t, "2", copied.Resources["cpu"])

tmpl.DriverConfig["runtime"] = "MUTATED"
assert.Equal(t, "kata", copied.DriverConfig["runtime"])

nested := tmpl.Resources["nested"].(map[string]any)
nested["key"] = "MUTATED"
copiedNested := copied.Resources["nested"].(map[string]any)
assert.Equal(t, "val", copiedNested["key"])
}

func TestSandbox_Create_NilSpec(t *testing.T) {
sc := newTestSandboxClient()
ctx := context.Background()
Expand Down
Loading
Loading