Skip to content
Open
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
9 changes: 9 additions & 0 deletions pkg/api/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/depot/cli/pkg/proto/depot/cli/v1/cliv1connect"
"github.com/depot/cli/pkg/proto/depot/cli/v1beta1/cliv1beta1connect"
cliCorev1connect "github.com/depot/cli/pkg/proto/depot/core/v1/corev1connect"
"github.com/depot/cli/pkg/proto/depot/sandbox/v1/sandboxv1connect"
"golang.org/x/net/http2"
)

Expand Down Expand Up @@ -55,6 +56,14 @@ func NewSandboxClient() agentv1connect.SandboxServiceClient {
return agentv1connect.NewSandboxServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent())
}

// NewSandboxV0Client returns a connect client for the depot.sandbox.v1
// SandboxService — the v0 customer-surface sandbox wire that the @depot/sandbox
// TS SDK consumes (M33). All `depot sandbox <verb>` commands in M34 use this
// client (NewSandboxClient is preserved for legacy DEP-4395 callers).
func NewSandboxV0Client() sandboxv1connect.SandboxServiceClient {
return sandboxv1connect.NewSandboxServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent())
}

func NewRegistryClient() buildv1connect.RegistryServiceClient {
return buildv1connect.NewRegistryServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent())
}
Expand Down
95 changes: 95 additions & 0 deletions pkg/cmd/sandbox/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package sandbox

import (
"context"
"errors"
"fmt"
"io"

"connectrpc.com/connect"
"github.com/depot/cli/pkg/config"
"github.com/depot/cli/pkg/helpers"
sandboxv1 "github.com/depot/cli/pkg/proto/depot/sandbox/v1"
"github.com/spf13/cobra"
)

// resolveAuthAndOrg pulls the persistent --token / --org flags off cmd and
// returns the fully-resolved DEPOT_TOKEN + organization id every sandbox
// subcommand needs.
func resolveAuthAndOrg(ctx context.Context, cmd *cobra.Command) (token, orgID string, err error) {
token, _ = cmd.Flags().GetString("token")
token, err = helpers.ResolveOrgAuth(ctx, token)
if err != nil {
return "", "", fmt.Errorf("resolve token: %w", err)
}
orgID, _ = cmd.Flags().GetString("org")
if orgID == "" {
orgID = config.GetCurrentOrganization()
}
return token, orgID, nil
}

// sandboxRef wraps a sandbox id in the depot.sandbox.v1 selector oneof.
// Every `<sandbox-id>` positional argument that hits the v0 wire ends up in
// SandboxRef{Selector: SandboxRef_Id{Id: id}} — this is the single helper.
func sandboxRef(id string) *sandboxv1.SandboxRef {
return &sandboxv1.SandboxRef{
Selector: &sandboxv1.SandboxRef_Id{Id: id},
}
}

// consumeCommandEventStream drains a depot.sandbox.v1 CommandEvent stream
// into stdout/stderr and returns the final exit code from Finished. The
// stream shape mirrors RunCommand / RunCommandPipe / AttachCommand /
// RunHook: Started -> Stdout/Stderr/Error/EvictedEarlyData* -> Finished.
//
// EvictedEarlyData is reported on stderr as a single line so log consumers
// see the gap; the stream continues afterward. Error frames abort so callers
// do not treat degraded output as a successful command.
func consumeCommandEventStream(
stream *connect.ServerStreamForClient[sandboxv1.SandboxCommandExecutionEvent],
stdout, stderr io.Writer,
) (exitCode int32, err error) {
defer func() { _ = stream.Close() }()
for stream.Receive() {
msg := stream.Msg()
switch ev := msg.Event.(type) {
case *sandboxv1.SandboxCommandExecutionEvent_Started_:
// metadata only — nothing to print
case *sandboxv1.SandboxCommandExecutionEvent_Stdout:
if ev.Stdout != nil && len(ev.Stdout.Data) > 0 {
if _, err := stdout.Write(ev.Stdout.Data); err != nil {
return 0, fmt.Errorf("write stdout: %w", err)
}
}
case *sandboxv1.SandboxCommandExecutionEvent_Stderr:
if ev.Stderr != nil && len(ev.Stderr.Data) > 0 {
if _, err := stderr.Write(ev.Stderr.Data); err != nil {
return 0, fmt.Errorf("write stderr: %w", err)
}
}
case *sandboxv1.SandboxCommandExecutionEvent_Finished_:
if ev.Finished != nil {
return ev.Finished.ExitCode, nil
}
case *sandboxv1.SandboxCommandExecutionEvent_Error_:
if ev.Error != nil {
if _, err := fmt.Fprintf(stderr, "[command-error] %s\n", ev.Error.Reason); err != nil {
return 0, fmt.Errorf("write stderr: %w", err)
}
return 0, fmt.Errorf("command error: %s", ev.Error.Reason)
}
case *sandboxv1.SandboxCommandExecutionEvent_Evicted:
if ev.Evicted != nil {
if _, err := fmt.Fprintf(stderr, "[evicted-early-data] dropped %d bytes stdout / %d bytes stderr\n",
ev.Evicted.DroppedBytesStdout, ev.Evicted.DroppedBytesStderr); err != nil {
return 0, fmt.Errorf("write stderr: %w", err)
}
}
}
}
if err := stream.Err(); err != nil && !errors.Is(err, io.EOF) {
return 0, fmt.Errorf("command stream: %w", err)
}
return 0, fmt.Errorf("command stream closed without Finished event")
Comment thread
cursor[bot] marked this conversation as resolved.
}
186 changes: 186 additions & 0 deletions pkg/cmd/sandbox/common_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package sandbox

import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"connectrpc.com/connect"
sandboxv1 "github.com/depot/cli/pkg/proto/depot/sandbox/v1"
)

// Every sandbox id passed over the wire flows through sandboxRef(). This pins
// the wire shape so a future refactor cannot silently regress to a bare string
// id without the oneof envelope.
func TestSandboxRef_PinnedShape(t *testing.T) {
r := sandboxRef("cs-abc123")
if r == nil {
t.Fatal("sandboxRef returned nil")
}
sel, ok := r.Selector.(*sandboxv1.SandboxRef_Id)
if !ok {
t.Fatalf("expected SandboxRef_Id selector, got %T", r.Selector)
}
if sel.Id != "cs-abc123" {
t.Errorf("id = %q, want cs-abc123", sel.Id)
}
}

func TestConsumeCommandEventStreamReturnsWriterErrors(t *testing.T) {
for _, tc := range []struct {
name string
events []*sandboxv1.SandboxCommandExecutionEvent
stdout io.Writer
stderr io.Writer
wantError string
}{
{
name: "stdout",
events: []*sandboxv1.SandboxCommandExecutionEvent{{
Event: &sandboxv1.SandboxCommandExecutionEvent_Stdout{
Stdout: &sandboxv1.SandboxCommandExecutionEvent_StdoutBytes{Data: []byte("out")},
},
}},
stdout: errWriter{},
stderr: io.Discard,
wantError: "write stdout",
},
{
name: "stderr",
events: []*sandboxv1.SandboxCommandExecutionEvent{{
Event: &sandboxv1.SandboxCommandExecutionEvent_Stderr{
Stderr: &sandboxv1.SandboxCommandExecutionEvent_StderrBytes{Data: []byte("err")},
},
}},
stdout: io.Discard,
stderr: errWriter{},
wantError: "write stderr",
},
{
name: "command error",
events: []*sandboxv1.SandboxCommandExecutionEvent{{
Event: &sandboxv1.SandboxCommandExecutionEvent_Error_{
Error: &sandboxv1.SandboxCommandExecutionEvent_Error{Reason: "degraded"},
},
}},
stdout: io.Discard,
stderr: errWriter{},
wantError: "write stderr",
},
{
name: "evicted",
events: []*sandboxv1.SandboxCommandExecutionEvent{{
Event: &sandboxv1.SandboxCommandExecutionEvent_Evicted{
Evicted: &sandboxv1.SandboxCommandExecutionEvent_EvictedEarlyData{
DroppedBytesStdout: 1,
DroppedBytesStderr: 2,
},
},
}},
stdout: io.Discard,
stderr: errWriter{},
wantError: "write stderr",
},
} {
t.Run(tc.name, func(t *testing.T) {
stream := sandboxCommandEventStream(t, tc.events)
_, err := consumeCommandEventStream(stream, tc.stdout, tc.stderr)
if err == nil {
t.Fatal("expected writer error")
}
if !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("error = %q, want %q", err, tc.wantError)
}
})
}
}

func TestConsumeCommandEventStreamSuccess(t *testing.T) {
stream := sandboxCommandEventStream(t, []*sandboxv1.SandboxCommandExecutionEvent{
{
Event: &sandboxv1.SandboxCommandExecutionEvent_Stdout{
Stdout: &sandboxv1.SandboxCommandExecutionEvent_StdoutBytes{Data: []byte("out")},
},
},
{
Event: &sandboxv1.SandboxCommandExecutionEvent_Finished_{
Finished: &sandboxv1.SandboxCommandExecutionEvent_Finished{ExitCode: 7},
},
},
})
var stdout bytes.Buffer
exit, err := consumeCommandEventStream(stream, &stdout, io.Discard)
if err != nil {
t.Fatalf("consumeCommandEventStream returned error: %v", err)
}
if exit != 7 {
t.Fatalf("exit = %d, want 7", exit)
}
if stdout.String() != "out" {
t.Fatalf("stdout = %q, want out", stdout.String())
}
}

func TestConsumeCommandEventStreamReturnsCommandErrorFrame(t *testing.T) {
stream := sandboxCommandEventStream(t, []*sandboxv1.SandboxCommandExecutionEvent{
{
Event: &sandboxv1.SandboxCommandExecutionEvent_Error_{
Error: &sandboxv1.SandboxCommandExecutionEvent_Error{Reason: "degraded"},
},
},
{
Event: &sandboxv1.SandboxCommandExecutionEvent_Finished_{
Finished: &sandboxv1.SandboxCommandExecutionEvent_Finished{ExitCode: 0},
},
},
})
var stderr bytes.Buffer
_, err := consumeCommandEventStream(stream, io.Discard, &stderr)
if err == nil {
t.Fatal("expected command error frame to fail")
}
if !strings.Contains(err.Error(), "command error: degraded") {
t.Fatalf("error = %q, want command error", err)
}
if !strings.Contains(stderr.String(), "[command-error] degraded") {
t.Fatalf("stderr = %q, want command diagnostic", stderr.String())
}
}

func sandboxCommandEventStream(t *testing.T, events []*sandboxv1.SandboxCommandExecutionEvent) *connect.ServerStreamForClient[sandboxv1.SandboxCommandExecutionEvent] {
t.Helper()
const procedure = "/test.Sandbox/Stream"
handler := connect.NewServerStreamHandler[sandboxv1.RunCommandRequest, sandboxv1.SandboxCommandExecutionEvent](
procedure,
func(_ context.Context, _ *connect.Request[sandboxv1.RunCommandRequest], stream *connect.ServerStream[sandboxv1.SandboxCommandExecutionEvent]) error {
for _, event := range events {
if err := stream.Send(event); err != nil {
return err
}
}
return nil
},
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler.ServeHTTP(w, r)
}))
t.Cleanup(server.Close)

client := connect.NewClient[sandboxv1.RunCommandRequest, sandboxv1.SandboxCommandExecutionEvent](server.Client(), server.URL+procedure)
stream, err := client.CallServerStream(context.Background(), connect.NewRequest(&sandboxv1.RunCommandRequest{}))
if err != nil {
t.Fatalf("CallServerStream: %v", err)
}
return stream
}

type errWriter struct{}

func (errWriter) Write([]byte) (int, error) {
return 0, errors.New("writer failed")
}
76 changes: 76 additions & 0 deletions pkg/cmd/sandbox/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package sandbox

import (
"fmt"

"connectrpc.com/connect"
"github.com/depot/cli/pkg/api"
sandboxv1 "github.com/depot/cli/pkg/proto/depot/sandbox/v1"
"github.com/spf13/cobra"
)

// newSandboxCreate builds the `create` command, which creates a new sandbox.
// Both the name and the image ref are optional; the server fills in defaults
// when they are omitted.
func newSandboxCreate() *cobra.Command {
cmd := &cobra.Command{
Use: "create [flags]",
Short: "Create a new sandbox",
Long: `Create a new sandbox via depot.sandbox.v1.CreateSandbox.

Names are optional but recommended for discoverability in 'depot sandbox list'.
The runtime image defaults to the server-side default when --image is omitted.`,
Example: `
# Minimal — defaults everywhere
depot sandbox create

# Name + custom image
depot sandbox create --name dev --image ghcr.io/myorg/dev:latest
`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

token, orgID, err := resolveAuthAndOrg(ctx, cmd)
if err != nil {
return err
}

name, _ := cmd.Flags().GetString("name")
image, _ := cmd.Flags().GetString("image")

req := &sandboxv1.CreateSandboxRequest{}
if name != "" {
req.Name = &name
}
if image != "" {
req.Runtime = &sandboxv1.Runtime{
Runtime: &sandboxv1.Runtime_ImageRef{ImageRef: image},
}
}

client := api.NewSandboxV0Client()
res, err := client.CreateSandbox(ctx, api.WithAuthenticationAndOrg(connect.NewRequest(req), token, orgID))
if err != nil {
return fmt.Errorf("create sandbox: %w", err)
}

sb := res.Msg.Sandbox
if sb == nil {
return fmt.Errorf("create sandbox: server returned no sandbox")
}
label := ""
if sb.Name != nil && *sb.Name != "" {
label = fmt.Sprintf(" %q", *sb.Name)
}
fmt.Fprintf(cmd.OutOrStdout(), "Sandbox %s%s created (org %s, status %s)\n",
sb.SandboxId, label, sb.OrganizationId, sb.Status.String())
fmt.Fprintf(cmd.OutOrStdout(), "Exec: depot sandbox exec %s -- <command>\n", sb.SandboxId)
fmt.Fprintf(cmd.OutOrStdout(), "Kill: depot sandbox kill %s\n", sb.SandboxId)
return nil
},
}

cmd.Flags().String("name", "", "Human label for the sandbox (org-scoped)")
cmd.Flags().String("image", "", "OCI image ref for the sandbox runtime (default: server-side default)")
return cmd
}
Loading
Loading