Skip to content

Commit eac94eb

Browse files
committed
refactor(go-client): adopt handle pattern
1 parent 5dc4ecd commit eac94eb

22 files changed

+3694
-2633
lines changed

clients/go/README.md

Lines changed: 93 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ The client operates in three modes:
1515
1. **Production (Gateway Mode):** Traffic flows from the Client -> Cloud Load Balancer (Gateway)
1616
-> Router Service -> Sandbox Pod. The client watches the Gateway resource for an external IP.
1717
2. **Development (Port-Forward Mode):** Traffic flows from the Client -> SPDY tunnel -> Router
18-
Service -> Sandbox Pod. Uses `client-go/tools/portforward` natively no `kubectl` required.
18+
Service -> Sandbox Pod. Uses `client-go/tools/portforward` natively, no `kubectl` required.
1919
3. **Advanced / Internal Mode:** The client connects directly to a provided `APIURL`, bypassing
2020
discovery. Useful for in-cluster agents or custom domains.
2121

@@ -25,7 +25,7 @@ The client operates in three modes:
2525
- The [**Agent Sandbox Controller**](https://github.com/kubernetes-sigs/agent-sandbox?tab=readme-ov-file#installation) installed.
2626
- The **Sandbox Router** deployed in the target namespace (`sandbox-router-svc`).
2727
- A `SandboxTemplate` created in the target namespace.
28-
- Go 1.26+.
28+
- Go 1.26.1+.
2929

3030
## Installation
3131

@@ -41,7 +41,7 @@ Use this when running against a cluster with a public Gateway IP. The client aut
4141
discovers the Gateway address.
4242

4343
```go
44-
client, err := sandbox.NewClient(sandbox.Options{
44+
client, err := sandbox.New(ctx, sandbox.Options{
4545
TemplateName: "my-sandbox-template",
4646
GatewayName: "external-http-gateway",
4747
GatewayNamespace: "default",
@@ -64,7 +64,7 @@ Use this for local development or CI. If you omit `GatewayName` and `APIURL`, th
6464
automatically establishes an SPDY port-forward tunnel to the Router Service.
6565

6666
```go
67-
client, err := sandbox.NewClient(sandbox.Options{
67+
client, err := sandbox.New(ctx, sandbox.Options{
6868
TemplateName: "my-sandbox-template",
6969
Namespace: "default",
7070
})
@@ -87,7 +87,7 @@ Use `APIURL` to bypass discovery entirely. Useful for:
8787
- **Custom Domains:** Connecting via HTTPS (e.g., `https://sandbox.example.com`).
8888

8989
```go
90-
client, err := sandbox.NewClient(sandbox.Options{
90+
client, err := sandbox.New(ctx, sandbox.Options{
9191
TemplateName: "my-sandbox-template",
9292
APIURL: "http://sandbox-router-svc.default.svc.cluster.local:8080",
9393
Namespace: "default",
@@ -108,7 +108,7 @@ fmt.Println(entries)
108108
If your sandbox runtime listens on a port other than 8888, specify `ServerPort`.
109109

110110
```go
111-
client, err := sandbox.NewClient(sandbox.Options{
111+
client, err := sandbox.New(ctx, sandbox.Options{
112112
TemplateName: "my-sandbox-template",
113113
ServerPort: 3000,
114114
})
@@ -117,8 +117,8 @@ client, err := sandbox.NewClient(sandbox.Options{
117117
### File Operations
118118

119119
```go
120-
// Write a file (only the base filename is sent; directory components are discarded).
121-
// Paths like "", ".", "..", and "/" are rejected with an error.
120+
// Write a file (must be a plain filename, no directory separators).
121+
// Paths like "dir/script.py" are rejected with an error.
122122
err := client.Write(ctx, "script.py", []byte("print('hello')"))
123123

124124
// Read a file
@@ -128,13 +128,15 @@ data, err := client.Read(ctx, "script.py")
128128
exists, err := client.Exists(ctx, "script.py")
129129
```
130130

131+
`Run()` responses are capped at 16 MB; `List()`/`Exists()` at 8 MB.
132+
131133
### 5. Custom TLS / Transport
132134

133135
If your Gateway uses HTTPS with a private CA, provide a custom transport:
134136

135137
```go
136138
tlsConfig := &tls.Config{RootCAs: myCAPool}
137-
client, err := sandbox.NewClient(sandbox.Options{
139+
client, err := sandbox.New(ctx, sandbox.Options{
138140
TemplateName: "my-sandbox-template",
139141
GatewayName: "external-https-gateway",
140142
GatewayScheme: "https",
@@ -147,37 +149,89 @@ client, err := sandbox.NewClient(sandbox.Options{
147149
All options are documented on the `Options` struct in
148150
[options.go](sandbox/options.go). Key fields:
149151

150-
- `TemplateName` *(required)* name of the `SandboxTemplate`.
151-
- `GatewayName` set to enable Gateway mode.
152-
- `APIURL` set for Direct URL mode (takes precedence over `GatewayName`).
153-
- `EnableTracing` / `TracerProvider` OpenTelemetry integration.
152+
- `TemplateName` *(required)*: name of the `SandboxTemplate`.
153+
- `GatewayName`: set to enable Gateway mode.
154+
- `APIURL`: set for Direct URL mode (takes precedence over `GatewayName`).
155+
- `EnableTracing` / `TracerProvider`: OpenTelemetry integration.
154156

155-
Any operation accepts `WithTimeout` to override the default request timeout:
157+
Any operation accepts `WithTimeout` to override the default request timeout,
158+
or `WithMaxAttempts` to control retry behavior:
156159

157160
```go
158161
result, err := client.Run(ctx, "make build", sandbox.WithTimeout(10*time.Minute))
159162
```
160163

161164
## Retry Behavior
162165

163-
Operations are automatically retried on 5xx responses and connection errors with
164-
exponential backoff. See constants in [transport.go](sandbox/transport.go) for details.
166+
File operations (`Read`, `Write`, `List`, `Exists`) are automatically retried (up to
167+
6 attempts) on 500/502/503/504 responses and connection errors with exponential backoff.
168+
169+
**Important:** `Run()` defaults to a single attempt (no retries) because command
170+
execution is not idempotent. Use `WithMaxAttempts` to opt in to retries for
171+
idempotent commands:
172+
173+
```go
174+
result, err := client.Run(ctx, "cat /etc/hostname", sandbox.WithMaxAttempts(6))
175+
```
176+
177+
## Disconnect / Reconnect
178+
179+
`Disconnect()` closes the transport connection **without deleting** the
180+
SandboxClaim. The sandbox stays alive on the server. Call `Open()` to
181+
reconnect to the same sandbox:
182+
183+
```go
184+
client.Disconnect(ctx) // transport closed, claim preserved
185+
// ... later ...
186+
client.Open(ctx) // reconnects to the same sandbox
187+
```
188+
189+
This is useful for suspending a session (e.g., between user requests in a
190+
web service) while keeping the sandbox warm. `Close()` deletes the claim;
191+
`Disconnect(ctx)` preserves it.
192+
193+
## Timeouts and Context
194+
195+
`Open()` executes several sequential phases (claim creation, sandbox
196+
readiness, transport connection), each bounded by its own timeout
197+
(`SandboxReadyTimeout`, `GatewayReadyTimeout`, `PortForwardReadyTimeout`).
198+
**Pass a context with a deadline** to bound the total `Open()` duration:
199+
200+
```go
201+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
202+
defer cancel()
203+
if err := client.Open(ctx); err != nil { ... }
204+
```
205+
206+
| Option | Default | Governs |
207+
|--------|---------|---------|
208+
| `SandboxReadyTimeout` | 180 s | Waiting for the sandbox to become ready |
209+
| `GatewayReadyTimeout` | 180 s | Waiting for the gateway IP |
210+
| `PortForwardReadyTimeout` | 30 s | Establishing the SPDY tunnel |
211+
| `CleanupTimeout` | 30 s | Claim deletion during rollback / Close |
212+
| `RequestTimeout` | 180 s | Total timeout per SDK method call (Run, Read, …) |
213+
| `PerAttemptTimeout` | 60 s | Time to receive response headers per attempt |
214+
| `MaxUploadSize` | 256 MB | Maximum content size for `Write()` |
215+
| `MaxDownloadSize` | 256 MB | Maximum response body size for `Read()` |
165216

166217
## Port-Forward Recovery
167218

168219
In port-forward mode, a background monitor detects tunnel death and clears the
169220
client's ready state. Subsequent operations fail immediately with `ErrNotReady`
170221
(wrapping `ErrPortForwardDied`) instead of timing out.
171222

172-
To recover, call `Open()` again — the client will verify the claim and sandbox
223+
To recover, call `Open()` again. The client will verify the claim and sandbox
173224
still exist, then establish a new tunnel:
174225

175226
```go
176227
result, err := client.Run(ctx, "echo hi")
177228
if errors.Is(err, sandbox.ErrNotReady) {
178229
// Port-forward died; reconnect.
179230
if reconnErr := client.Open(ctx); reconnErr != nil {
180-
if errors.Is(reconnErr, sandbox.ErrOrphanedClaim) {
231+
if errors.Is(reconnErr, sandbox.ErrSandboxDeleted) {
232+
// Claim was deleted externally; start fresh.
233+
reconnErr = client.Open(ctx)
234+
} else if errors.Is(reconnErr, sandbox.ErrOrphanedClaim) {
181235
// Sandbox no longer ready or verification failed; clean up and start fresh.
182236
client.Close(ctx)
183237
reconnErr = client.Open(ctx)
@@ -194,7 +248,7 @@ If `Close()` fails to delete the claim (e.g., API server unavailable), the clien
194248
preserves the claim name so `Close()` can be retried to clean up the orphaned claim.
195249
Calling `Open()` on a client with an orphaned claim returns `ErrOrphanedClaim`.
196250

197-
## Error Sentinel Reference
251+
## Error Reference
198252

199253
| Error | Meaning |
200254
|-------|---------|
@@ -208,20 +262,31 @@ Calling `Open()` on a client with an orphaned claim returns `ErrOrphanedClaim`.
208262
| `ErrSandboxDeleted` | The Sandbox was deleted before becoming ready. |
209263
| `ErrGatewayDeleted` | The Gateway was deleted during address discovery. |
210264

265+
Non-OK HTTP responses are wrapped in `*HTTPError`, which can be extracted
266+
with `errors.As` to inspect the status code:
267+
268+
```go
269+
var httpErr *sandbox.HTTPError
270+
if errors.As(err, &httpErr) {
271+
fmt.Printf("status %d: %s\n", httpErr.StatusCode, httpErr.Body)
272+
}
273+
```
274+
211275
## Testing / Mocking
212276

213277
The package exports two interfaces:
214278

215-
- **`Client`** — the core API (`Open`, `Close`, `Run`, `Read`, `Write`, `List`,
216-
`Exists`, `IsReady`). Accept this in your APIs to enable testing with fakes.
217-
- **`SandboxInfo`** — read-only identity accessors (`ClaimName`, `SandboxName`,
218-
`PodName`, `Annotations`). These are on the concrete `*SandboxClient` (and the
219-
`SandboxInfo` interface) rather than `Client`, so adding new accessors is not
279+
- **`Handle`**: the core API (`Open`, `Close`, `Disconnect(ctx)`, `Run`, `Read`, `Write`,
280+
`List`, `Exists`, `IsReady`). Accept this in your APIs to enable testing with fakes. For
281+
sub-object access (`Commands()`, `Files()`), use the concrete `*Sandbox` type directly.
282+
- **`Info`**: read-only identity accessors (`ClaimName`, `SandboxName`,
283+
`PodName`, `Annotations`). These are on the concrete `*Sandbox` (and the
284+
`Info` interface) rather than `Handle`, so adding new accessors is not
220285
a breaking change for mock implementors.
221286

222287
```go
223-
// Accept the narrow Client interface for testability.
224-
func ProcessInSandbox(ctx context.Context, sb sandbox.Client) error {
288+
// Accept the narrow Handle interface for testability.
289+
func ProcessInSandbox(ctx context.Context, sb sandbox.Handle) error {
225290
if err := sb.Open(ctx); err != nil {
226291
return err
227292
}
@@ -230,8 +295,8 @@ func ProcessInSandbox(ctx context.Context, sb sandbox.Client) error {
230295
// ...
231296
}
232297

233-
// When you need identity metadata, accept the concrete type or SandboxInfo.
234-
func LogSandboxIdentity(info sandbox.SandboxInfo) {
298+
// When you need identity metadata, accept the concrete type or Info.
299+
func LogSandboxIdentity(info sandbox.Info) {
235300
log.Printf("claim=%s sandbox=%s pod=%s", info.ClaimName(), info.SandboxName(), info.PodName())
236301
}
237302
```

clients/go/examples/basic/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
)
2626

2727
func main() {
28-
client, err := sandbox.NewClient(sandbox.Options{
28+
client, err := sandbox.New(context.Background(), sandbox.Options{
2929
TemplateName: "my-sandbox-template",
3030
Namespace: "default",
3131
})

clients/go/examples/gateway/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
)
2626

2727
func main() {
28-
client, err := sandbox.NewClient(sandbox.Options{
28+
client, err := sandbox.New(context.Background(), sandbox.Options{
2929
TemplateName: "my-sandbox-template",
3030
Namespace: "default",
3131
GatewayName: "external-http-gateway",

0 commit comments

Comments
 (0)