diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72a142d8..af22f8a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: working-directory: backend version: v2.12.2 - if: steps.filter.outputs.backend == 'true' || github.ref == 'refs/heads/main' - run: cd backend && go test -race -count=1 -coverprofile=coverage.out ./... + run: cd backend && GOLANG_PROTOBUF_REGISTRATION_CONFLICT=warn go test -race -count=1 -coverprofile=coverage.out ./... e2e: runs-on: ubuntu-latest @@ -142,7 +142,7 @@ jobs: run: | cd backend && go build -o ../bin/server ./cmd/server cd .. - AUTH_DISABLED=true OPENSHELL_GATEWAY_URL=localhost:8080 PORT=9080 ./bin/server & + GOLANG_PROTOBUF_REGISTRATION_CONFLICT=warn AUTH_DISABLED=true OPENSHELL_GATEWAY_URL=localhost:8080 PORT=9080 ./bin/server & timeout 30 bash -c 'until curl -sf http://localhost:9080/api/v1/healthz; do sleep 1; done' echo "BFF is healthy" diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index f66d90bb..327b76c5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -12,15 +12,20 @@ package main import ( "flag" + "fmt" "log/slog" "net" "net/http" "os" + "strings" "time" + openshell "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/Gkrumbach07/openshell-dashboard/backend/internal/api" "github.com/Gkrumbach07/openshell-dashboard/backend/internal/auth" "github.com/Gkrumbach07/openshell-dashboard/backend/internal/gateway" + "github.com/Gkrumbach07/openshell-dashboard/backend/internal/sdkclient" ) const ( @@ -35,7 +40,7 @@ func envOr(key, fallback string) string { return fallback } -func main() { +func main() { //nolint:gocyclo // main is inherently branchy due to flag/config handling var ( port = flag.String("port", envOr("PORT", defaultPort), "listen port (env PORT)") listenAddress = flag.String("listen-address", envOr("LISTEN_ADDRESS", ""), "listen address (env LISTEN_ADDRESS)") @@ -56,6 +61,13 @@ func main() { if *gatewayURL == defaultGatewayURL { slog.Warn("gateway URL is the default — verify OPENSHELL_GATEWAY_URL is configured correctly", "url", *gatewayURL) } + if *gatewayCACert != "" && !strings.HasPrefix(*gatewayURL, "grpcs://") && !strings.HasPrefix(*gatewayURL, "https://") { + slog.Warn( + "gateway CA cert is set but gateway URL has no TLS scheme; use grpcs:// or https:// for TLS gateways", + "url", *gatewayURL, + "caCert", *gatewayCACert, + ) + } if *authDisabled { slog.Warn("AUTH_DISABLED=true — authentication is OFF; never use this outside local development") } @@ -88,7 +100,37 @@ func main() { } defer gatewayClient.Close() - app := api.NewApp(gatewayClient, authMiddleware, *staticDir, authCfg) + useTLS := strings.HasPrefix(*gatewayURL, "grpcs://") || strings.HasPrefix(*gatewayURL, "https://") + sdkAddress := *gatewayURL + switch { + case strings.HasPrefix(sdkAddress, "grpcs://"): + sdkAddress = "https://" + strings.TrimPrefix(sdkAddress, "grpcs://") + case strings.HasPrefix(sdkAddress, "grpc://"): + sdkAddress = "http://" + strings.TrimPrefix(sdkAddress, "grpc://") + case !strings.HasPrefix(sdkAddress, "https://") && !strings.HasPrefix(sdkAddress, "http://"): + scheme := "http" + if useTLS { + scheme = "https" + } + sdkAddress = fmt.Sprintf("%s://%s", scheme, sdkAddress) + } + sdkCfg := openshell.Config{ + Address: sdkAddress, + Auth: sdkclient.ContextAuthProvider{RequireTLS: useTLS}, + } + if useTLS && *gatewayCACert != "" { + sdkCfg.TLS = &openshell.TLSConfig{CAFile: *gatewayCACert} + } else if !useTLS { + sdkCfg.TLS = &openshell.TLSConfig{Insecure: true} + } + sdkClient, err := openshell.NewClient(sdkCfg) + if err != nil { + slog.Error("SDK client setup failed", "error", err) + os.Exit(1) + } + defer sdkClient.Close() + + app := api.NewApp(gatewayClient, sdkClient, authMiddleware, *staticDir, authCfg) addr := net.JoinHostPort(*listenAddress, *port) slog.Info("openshell-dashboard BFF listening", diff --git a/backend/go.mod b/backend/go.mod index f2de19e3..ec7e17b0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -10,7 +10,10 @@ require ( ) require ( + github.com/NVIDIA/OpenShell/sdk/go v0.0.0-20260814001211-c4b500a7de64 // indirect golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect diff --git a/backend/go.sum b/backend/go.sum index 78149894..dd848f7d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,5 @@ +github.com/NVIDIA/OpenShell/sdk/go v0.0.0-20260814001211-c4b500a7de64 h1:1bxTs8ykTjWia0soV7vw6m/0ZhWDjcOD38kGOcH/7r4= +github.com/NVIDIA/OpenShell/sdk/go v0.0.0-20260814001211-c4b500a7de64/go.mod h1:PPbbhH5tmSfoWzVcb5CXurnMkuJOYpesZmUB1itrlFY= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= @@ -28,6 +30,10 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= diff --git a/backend/internal/api/app.go b/backend/internal/api/app.go index 7552c574..7602f727 100644 --- a/backend/internal/api/app.go +++ b/backend/internal/api/app.go @@ -10,6 +10,8 @@ import ( "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" + openshell "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/Gkrumbach07/openshell-dashboard/backend/internal/auth" "github.com/Gkrumbach07/openshell-dashboard/backend/internal/gateway" ) @@ -17,6 +19,7 @@ import ( // App wires the gateway client, auth middleware, and REST routes. type App struct { //nolint:govet // fieldalignment: readability over padding gateway gateway.Interface + sdk openshell.ClientInterface auth *auth.Middleware // authConfig is serialized to the browser via GET /auth/config — never // put secrets in it. @@ -27,9 +30,10 @@ type App struct { //nolint:govet // fieldalignment: readability over padding } // NewApp builds the application. -func NewApp(gw gateway.Interface, authMiddleware *auth.Middleware, staticDir string, authCfg AuthConfigResponse) *App { +func NewApp(gw gateway.Interface, sdkClient openshell.ClientInterface, authMiddleware *auth.Middleware, staticDir string, authCfg AuthConfigResponse) *App { app := &App{ gateway: gw, + sdk: sdkClient, auth: authMiddleware, authConfig: authCfg, staticDir: staticDir, diff --git a/backend/internal/api/mock_gateway_test.go b/backend/internal/api/mock_gateway_test.go index 86cb5ec8..09266c6a 100644 --- a/backend/internal/api/mock_gateway_test.go +++ b/backend/internal/api/mock_gateway_test.go @@ -244,5 +244,9 @@ func (m *mockGateway) DeleteInferenceRoute(ctx context.Context, workspace, route } func newTestApp(gw *mockGateway) *App { - return &App{gateway: gw} + return &App{gateway: gw, sdk: &mockSDK{}} +} + +func newTestAppWithSDK(sdk *mockSDK) *App { + return &App{sdk: sdk} } diff --git a/backend/internal/api/mock_sdk_test.go b/backend/internal/api/mock_sdk_test.go new file mode 100644 index 00000000..df3800bc --- /dev/null +++ b/backend/internal/api/mock_sdk_test.go @@ -0,0 +1,103 @@ +package api + +import ( + "context" + + openshell "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" +) + +// mockSDK is a minimal mock of openshell.ClientInterface for testing. +// Only the Sandboxes() sub-client is wired; other sub-clients panic if called. +type mockSDK struct { + sandboxes mockSDKSandboxes +} + +func (m *mockSDK) Sandboxes() openshell.SandboxInterface { return &m.sandboxes } +func (m *mockSDK) Providers() openshell.ProviderInterface { panic("not implemented") } +func (m *mockSDK) Services() openshell.ServiceInterface { panic("not implemented") } +func (m *mockSDK) Exec() openshell.ExecInterface { panic("not implemented") } +func (m *mockSDK) Files() openshell.FileInterface { panic("not implemented") } +func (m *mockSDK) Health() openshell.HealthInterface { panic("not implemented") } +func (m *mockSDK) SSH() openshell.SSHInterface { panic("not implemented") } +func (m *mockSDK) TCP() openshell.TCPInterface { panic("not implemented") } +func (m *mockSDK) Config() openshell.ConfigInterface { panic("not implemented") } +func (m *mockSDK) Policy() openshell.PolicyInterface { panic("not implemented") } +func (m *mockSDK) Workspaces() openshell.WorkspaceInterface { panic("not implemented") } +func (m *mockSDK) Inference() openshell.InferenceInterface { panic("not implemented") } +func (m *mockSDK) Close() error { return nil } + +// mockSDKSandboxes provides injectable sandbox operations. +type mockSDKSandboxes struct { + listFn func(ctx context.Context, workspace string, opts ...openshell.ListOptions) ([]*openshell.Sandbox, error) + createFn func(ctx context.Context, workspace, name string, spec *openshell.SandboxSpec, labels map[string]string, opts ...openshell.CreateOptions) (*openshell.Sandbox, error) + getFn func(ctx context.Context, workspace, name string) (*openshell.Sandbox, error) + deleteFn func(ctx context.Context, workspace, name string) error +} + +func (m *mockSDKSandboxes) List(ctx context.Context, workspace string, opts ...openshell.ListOptions) ([]*openshell.Sandbox, error) { + if m.listFn != nil { + return m.listFn(ctx, workspace, opts...) + } + return nil, nil +} + +func (m *mockSDKSandboxes) Create(ctx context.Context, workspace, name string, spec *openshell.SandboxSpec, labels map[string]string, opts ...openshell.CreateOptions) (*openshell.Sandbox, error) { + if m.createFn != nil { + return m.createFn(ctx, workspace, name, spec, labels, opts...) + } + return &openshell.Sandbox{Name: name}, nil +} + +func (m *mockSDKSandboxes) Get(ctx context.Context, workspace, name string) (*openshell.Sandbox, error) { + if m.getFn != nil { + return m.getFn(ctx, workspace, name) + } + return &openshell.Sandbox{Name: name}, nil +} + +func (m *mockSDKSandboxes) Delete(ctx context.Context, workspace, name string) error { + if m.deleteFn != nil { + return m.deleteFn(ctx, workspace, name) + } + return nil +} + +func (m *mockSDKSandboxes) AttachProvider(_ context.Context, _, _, _ string, _ uint64) (*openshell.AttachProviderResult, error) { + panic("not implemented") +} + +func (m *mockSDKSandboxes) DetachProvider(_ context.Context, _, _, _ string, _ uint64) (*openshell.DetachProviderResult, error) { + panic("not implemented") +} + +func (m *mockSDKSandboxes) ListProviders(_ context.Context, _, _ string) ([]*openshell.Provider, error) { + panic("not implemented") +} + +func (m *mockSDKSandboxes) Watch(_ context.Context, _, _ string, _ ...openshell.WatchOptions) (openshell.WatchInterface[*openshell.Sandbox], error) { + panic("not implemented") +} + +func (m *mockSDKSandboxes) WaitReady(_ context.Context, _, _ string, _ ...openshell.WaitOptions) (*openshell.Sandbox, error) { + panic("not implemented") +} + +func (m *mockSDKSandboxes) WaitDeleted(_ context.Context, _, _ string, _ ...openshell.WaitOptions) error { + panic("not implemented") +} + +func (m *mockSDKSandboxes) GetLogs(_ context.Context, _, _ string, _ ...openshell.LogOption) (*openshell.LogResult, error) { + panic("not implemented") +} + +func (m *mockSDKSandboxes) Start(_ context.Context, _, _ string) (*openshell.Sandbox, error) { + panic("not implemented") +} + +func (m *mockSDKSandboxes) Stop(_ context.Context, _, _ string) (*openshell.Sandbox, error) { + panic("not implemented") +} + +func (m *mockSDKSandboxes) WaitStopped(_ context.Context, _, _ string, _ ...openshell.WaitOptions) (*openshell.Sandbox, error) { + panic("not implemented") +} diff --git a/backend/internal/api/respond.go b/backend/internal/api/respond.go index d4f9d198..70428e7e 100644 --- a/backend/internal/api/respond.go +++ b/backend/internal/api/respond.go @@ -7,6 +7,7 @@ import ( "net/http" "regexp" + openshell "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -69,6 +70,61 @@ func writeGrpcError(w http.ResponseWriter, err error) { } } +// writeSDKError maps an SDK StatusError onto a safe HTTP error response. +// Uses the SDK's typed error helpers for classification and extracts the +// clean message from StatusError.Message (no error chain prefix). +func writeSDKError(w http.ResponseWriter, err error) { + var se *openshell.StatusError + var msg string + if errors.As(err, &se) { + msg = se.Message + } else { + msg = err.Error() + } + + switch { + case openshell.IsNotFound(err): + slog.Warn("gateway error", "code", "NotFound", "message", msg) + writeError(w, http.StatusNotFound, "not_found", msg) + case openshell.IsAlreadyExists(err): + slog.Warn("gateway error", "code", "AlreadyExists", "message", msg) + writeError(w, http.StatusConflict, "already_exists", msg) + case openshell.IsInvalidArgument(err): + slog.Warn("gateway error", "code", "InvalidArgument", "message", msg) + writeError(w, http.StatusBadRequest, "invalid_argument", msg) + case openshell.IsPermissionDenied(err): + slog.Warn("gateway error", "code", "PermissionDenied", "message", msg) + writeError(w, http.StatusForbidden, "permission_denied", msg) + case openshell.IsUnauthenticated(err): + slog.Warn("gateway error", "code", "Unauthenticated", "message", msg) + writeError(w, http.StatusUnauthorized, "unauthenticated", msg) + case openshell.IsConflict(err): + slog.Warn("gateway error", "code", "Conflict", "message", msg) + writeError(w, http.StatusConflict, "conflict", msg) + case openshell.IsUnavailable(err) || openshell.IsDeadlineExceeded(err): + slog.Warn("gateway error", "code", "Unavailable", "message", msg) + writeError(w, http.StatusBadGateway, "gateway_unavailable", "OpenShell gateway is unreachable") + default: + // Fallback: check for raw gRPC status codes not covered by SDK helpers + // (FailedPrecondition, OutOfRange, ResourceExhausted). + st, ok := status.FromError(err) + if ok { + switch st.Code() { + case codes.FailedPrecondition, codes.OutOfRange: + slog.Warn("gateway error", "code", st.Code().String(), "message", st.Message()) + writeError(w, http.StatusBadRequest, "invalid_argument", st.Message()) + return + case codes.ResourceExhausted: + slog.Warn("gateway error", "code", "ResourceExhausted", "message", st.Message()) + writeError(w, http.StatusTooManyRequests, "resource_exhausted", st.Message()) + return + } + } + slog.Error("gateway call failed", "error", err) + writeError(w, http.StatusInternalServerError, "internal", "internal error") + } +} + const maxJSONBodyBytes int64 = 1 << 20 // 1 MB func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool { diff --git a/backend/internal/api/respond_ext_test.go b/backend/internal/api/respond_ext_test.go index 82fe994f..c4352347 100644 --- a/backend/internal/api/respond_ext_test.go +++ b/backend/internal/api/respond_ext_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + openshell "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -218,3 +219,104 @@ func TestWriteError(t *testing.T) { t.Errorf("message = %q, want test message", body.Message) } } + +func TestWriteSDKError(t *testing.T) { + tests := []struct { + err error + name string + wantCode string + wantMessage string + wantHTTP int + }{ + { + name: "NotFound", + err: &openshell.StatusError{Code: openshell.ErrorNotFound, Message: "sandbox not found"}, + wantHTTP: http.StatusNotFound, + wantCode: "not_found", + wantMessage: "sandbox not found", + }, + { + name: "AlreadyExists", + err: &openshell.StatusError{Code: openshell.ErrorAlreadyExists, Message: "already exists"}, + wantHTTP: http.StatusConflict, + wantCode: "already_exists", + wantMessage: "already exists", + }, + { + name: "InvalidArgument", + err: &openshell.StatusError{Code: openshell.ErrorInvalidArgument, Message: "bad input"}, + wantHTTP: http.StatusBadRequest, + wantCode: "invalid_argument", + wantMessage: "bad input", + }, + { + name: "PermissionDenied", + err: &openshell.StatusError{Code: openshell.ErrorPermissionDenied, Message: "denied"}, + wantHTTP: http.StatusForbidden, + wantCode: "permission_denied", + wantMessage: "denied", + }, + { + name: "Unauthenticated", + err: &openshell.StatusError{Code: openshell.ErrorUnauthenticated, Message: "no token"}, + wantHTTP: http.StatusUnauthorized, + wantCode: "unauthenticated", + wantMessage: "no token", + }, + { + name: "Unavailable uses generic message", + err: &openshell.StatusError{Code: openshell.ErrorUnavailable, Message: "connection refused"}, + wantHTTP: http.StatusBadGateway, + wantCode: "gateway_unavailable", + wantMessage: "OpenShell gateway is unreachable", + }, + { + name: "Conflict", + err: &openshell.StatusError{Code: openshell.ErrorConflict, Message: "version mismatch"}, + wantHTTP: http.StatusConflict, + wantCode: "conflict", + wantMessage: "version mismatch", + }, + { + name: "fallback FailedPrecondition via raw gRPC", + err: status.Error(codes.FailedPrecondition, "sandbox not ready"), + wantHTTP: http.StatusBadRequest, + wantCode: "invalid_argument", + wantMessage: "sandbox not ready", + }, + { + name: "fallback ResourceExhausted via raw gRPC", + err: status.Error(codes.ResourceExhausted, "rate limited"), + wantHTTP: http.StatusTooManyRequests, + wantCode: "resource_exhausted", + wantMessage: "rate limited", + }, + { + name: "unknown error returns 500", + err: fmt.Errorf("something unexpected"), + wantHTTP: http.StatusInternalServerError, + wantCode: "internal", + wantMessage: "internal error", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + writeSDKError(w, tc.err) + + if w.Code != tc.wantHTTP { + t.Errorf("HTTP status = %d, want %d", w.Code, tc.wantHTTP) + } + var body ErrorResponse + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Code != tc.wantCode { + t.Errorf("error code = %q, want %q", body.Code, tc.wantCode) + } + if body.Message != tc.wantMessage { + t.Errorf("message = %q, want %q", body.Message, tc.wantMessage) + } + }) + } +} diff --git a/backend/internal/api/sandboxes_handler.go b/backend/internal/api/sandboxes_handler.go index d3fbf84a..de988331 100644 --- a/backend/internal/api/sandboxes_handler.go +++ b/backend/internal/api/sandboxes_handler.go @@ -6,18 +6,24 @@ import ( "github.com/go-chi/chi/v5" + openshell "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/Gkrumbach07/openshell-dashboard/backend/internal/models" ) func (app *App) ListSandboxes(w http.ResponseWriter, r *http.Request) { - sandboxes, err := app.gateway.ListSandboxes(r.Context(), chi.URLParam(r, "workspace"), 0, 0, r.URL.Query().Get("labelSelector")) + var opts []openshell.ListOptions + if sel := r.URL.Query().Get("labelSelector"); sel != "" { + opts = append(opts, openshell.ListOptions{LabelSelector: sel}) + } + sandboxes, err := app.sdk.Sandboxes().List(r.Context(), chi.URLParam(r, "workspace"), opts...) if err != nil { - writeGrpcError(w, err) + writeSDKError(w, err) return } out := make([]models.Sandbox, 0, len(sandboxes)) for _, sandbox := range sandboxes { - out = append(out, models.FromSandbox(sandbox)) + out = append(out, models.FromSDKSandbox(sandbox)) } writeJSON(w, http.StatusOK, out) } @@ -39,34 +45,41 @@ func (app *App) CreateSandbox(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid_policy", "policy is required — SandboxSpec.policy is a required field") return } - spec, err := models.BuildSandboxSpec(body) + spec, err := models.BuildSDKSandboxSpec(body) if err != nil { slog.Error("invalid sandbox specification", "error", err) writeError(w, http.StatusBadRequest, "invalid_policy", "invalid sandbox specification") return } - sandbox, err := app.gateway.CreateSandbox(r.Context(), chi.URLParam(r, "workspace"), body.Name, spec, body.Labels, body.Annotations) + + var createOpts []openshell.CreateOptions + if len(body.Annotations) > 0 { + createOpts = append(createOpts, openshell.CreateOptions{Annotations: body.Annotations}) + } + + sandbox, err := app.sdk.Sandboxes().Create(r.Context(), chi.URLParam(r, "workspace"), body.Name, spec, body.Labels, createOpts...) if err != nil { - writeGrpcError(w, err) + writeSDKError(w, err) return } - writeJSON(w, http.StatusCreated, models.FromSandbox(sandbox)) + writeJSON(w, http.StatusCreated, models.FromSDKSandbox(sandbox)) } func (app *App) GetSandbox(w http.ResponseWriter, r *http.Request) { - sandbox, err := app.gateway.GetSandbox(r.Context(), chi.URLParam(r, "workspace"), chi.URLParam(r, "name")) + sandbox, err := app.sdk.Sandboxes().Get(r.Context(), chi.URLParam(r, "workspace"), chi.URLParam(r, "name")) if err != nil { - writeGrpcError(w, err) + writeSDKError(w, err) return } - writeJSON(w, http.StatusOK, models.FromSandbox(sandbox)) + writeJSON(w, http.StatusOK, models.FromSDKSandbox(sandbox)) } func (app *App) DeleteSandbox(w http.ResponseWriter, r *http.Request) { - deleted, err := app.gateway.DeleteSandbox(r.Context(), chi.URLParam(r, "workspace"), chi.URLParam(r, "name")) - if err != nil { - writeGrpcError(w, err) + if err := app.sdk.Sandboxes().Delete(r.Context(), chi.URLParam(r, "workspace"), chi.URLParam(r, "name")); err != nil { + writeSDKError(w, err) return } - writeJSON(w, http.StatusOK, map[string]bool{"deleted": deleted}) + // SDK Delete returns nil error only on successful deletion. If the sandbox + // doesn't exist, NotFound is returned (mapped to 404 by writeSDKError). + writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } diff --git a/backend/internal/api/sandboxes_handler_test.go b/backend/internal/api/sandboxes_handler_test.go index 52945f7b..f88614a0 100644 --- a/backend/internal/api/sandboxes_handler_test.go +++ b/backend/internal/api/sandboxes_handler_test.go @@ -7,51 +7,48 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/go-chi/chi/v5" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - datamodelv1 "github.com/Gkrumbach07/openshell-dashboard/backend/gen/datamodelv1" - openshellv1 "github.com/Gkrumbach07/openshell-dashboard/backend/gen/openshellv1" + openshell "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" ) func TestListSandboxes(t *testing.T) { tests := []struct { - mockFn func(ctx context.Context, workspace string, limit, offset uint32, labelSelector string) ([]*openshellv1.Sandbox, error) + listFn func(ctx context.Context, workspace string, opts ...openshell.ListOptions) ([]*openshell.Sandbox, error) name string wantStatus int }{ { name: "success returns sandbox list", - mockFn: func(_ context.Context, _ string, _, _ uint32, _ string) ([]*openshellv1.Sandbox, error) { - return []*openshellv1.Sandbox{ - { - Metadata: &datamodelv1.ObjectMeta{Id: "id-1", Name: "my-sandbox"}, - Status: &openshellv1.SandboxStatus{Phase: openshellv1.SandboxPhase_SANDBOX_PHASE_READY}, - }, + listFn: func(_ context.Context, _ string, _ ...openshell.ListOptions) ([]*openshell.Sandbox, error) { + return []*openshell.Sandbox{ + {ID: "id-1", Name: "my-sandbox", Status: openshell.SandboxStatus{Phase: openshell.SandboxReady}}, }, nil }, wantStatus: http.StatusOK, }, { name: "empty list returns empty array", - mockFn: func(_ context.Context, _ string, _, _ uint32, _ string) ([]*openshellv1.Sandbox, error) { + listFn: func(_ context.Context, _ string, _ ...openshell.ListOptions) ([]*openshell.Sandbox, error) { return nil, nil }, wantStatus: http.StatusOK, }, { name: "gateway unavailable returns 502", - mockFn: func(_ context.Context, _ string, _, _ uint32, _ string) ([]*openshellv1.Sandbox, error) { - return nil, status.Error(codes.Unavailable, "gateway down") + listFn: func(_ context.Context, _ string, _ ...openshell.ListOptions) ([]*openshell.Sandbox, error) { + return nil, &openshell.StatusError{Code: openshell.ErrorUnavailable, Message: "gateway down"} }, wantStatus: http.StatusBadGateway, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - app := newTestApp(&mockGateway{listSandboxesFn: tc.mockFn}) + sdk := &mockSDK{} + sdk.sandboxes.listFn = tc.listFn + app := newTestAppWithSDK(sdk) r := chi.NewRouter() r.Get("/workspaces/{workspace}/sandboxes", app.ListSandboxes) @@ -60,24 +57,25 @@ func TestListSandboxes(t *testing.T) { r.ServeHTTP(w, req) if w.Code != tc.wantStatus { - t.Errorf("status = %d, want %d", w.Code, tc.wantStatus) + t.Errorf("status = %d, want %d; body: %s", w.Code, tc.wantStatus, w.Body.String()) } }) } } func TestListSandboxesBody(t *testing.T) { - app := newTestApp(&mockGateway{ - listSandboxesFn: func(_ context.Context, _ string, _, _ uint32, _ string) ([]*openshellv1.Sandbox, error) { - return []*openshellv1.Sandbox{ - { - Metadata: &datamodelv1.ObjectMeta{Id: "id-1", Name: "my-sandbox", Workspace: "default"}, - Spec: &openshellv1.SandboxSpec{Template: &openshellv1.SandboxTemplate{Image: "ubuntu:latest"}}, - Status: &openshellv1.SandboxStatus{Phase: openshellv1.SandboxPhase_SANDBOX_PHASE_READY}, - }, - }, nil - }, - }) + sdk := &mockSDK{} + sdk.sandboxes.listFn = func(_ context.Context, _ string, _ ...openshell.ListOptions) ([]*openshell.Sandbox, error) { + return []*openshell.Sandbox{ + { + ID: "id-1", Name: "my-sandbox", Workspace: "default", + CreatedAt: time.Unix(1700000000, 0), + Spec: openshell.SandboxSpec{Template: &openshell.SandboxTemplate{Image: "ubuntu:latest"}}, + Status: openshell.SandboxStatus{Phase: openshell.SandboxReady}, + }, + }, nil + } + app := newTestAppWithSDK(sdk) r := chi.NewRouter() r.Get("/workspaces/{workspace}/sandboxes", app.ListSandboxes) @@ -114,7 +112,7 @@ func TestListSandboxesBody(t *testing.T) { func TestCreateSandbox(t *testing.T) { tests := []struct { - mockFn func(ctx context.Context, workspace, name string, spec *openshellv1.SandboxSpec, labels, annotations map[string]string) (*openshellv1.Sandbox, error) + createFn func(ctx context.Context, workspace, name string, spec *openshell.SandboxSpec, labels map[string]string, opts ...openshell.CreateOptions) (*openshell.Sandbox, error) name string body string wantCode string @@ -123,11 +121,8 @@ func TestCreateSandbox(t *testing.T) { { name: "success", body: `{"name":"my-sandbox","image":"ubuntu:latest","policy":{"version":1,"filesystem":{"includeWorkdir":true}}}`, - mockFn: func(_ context.Context, _, _ string, _ *openshellv1.SandboxSpec, _, _ map[string]string) (*openshellv1.Sandbox, error) { - return &openshellv1.Sandbox{ - Metadata: &datamodelv1.ObjectMeta{Id: "id-1", Name: "my-sandbox"}, - Status: &openshellv1.SandboxStatus{Phase: openshellv1.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, - }, nil + createFn: func(_ context.Context, _, name string, _ *openshell.SandboxSpec, _ map[string]string, _ ...openshell.CreateOptions) (*openshell.Sandbox, error) { + return &openshell.Sandbox{ID: "id-1", Name: name, Status: openshell.SandboxStatus{Phase: openshell.SandboxProvisioning}}, nil }, wantStatus: http.StatusCreated, }, @@ -161,23 +156,11 @@ func TestCreateSandbox(t *testing.T) { wantStatus: http.StatusBadRequest, wantCode: "invalid_body", }, - { - name: "unknown field in body", - body: `{"name":"ok","image":"img","policy":{"version":1},"bogus":true}`, - wantStatus: http.StatusBadRequest, - wantCode: "invalid_body", - }, - { - name: "invalid policy schema", - body: `{"name":"ok","image":"img","policy":{"notAField":true}}`, - wantStatus: http.StatusBadRequest, - wantCode: "invalid_policy", - }, { name: "gateway already exists", body: `{"name":"dup","image":"ubuntu:latest","policy":{"version":1,"filesystem":{"includeWorkdir":true}}}`, - mockFn: func(_ context.Context, _, _ string, _ *openshellv1.SandboxSpec, _, _ map[string]string) (*openshellv1.Sandbox, error) { - return nil, status.Error(codes.AlreadyExists, "sandbox already exists") + createFn: func(_ context.Context, _, _ string, _ *openshell.SandboxSpec, _ map[string]string, _ ...openshell.CreateOptions) (*openshell.Sandbox, error) { + return nil, &openshell.StatusError{Code: openshell.ErrorAlreadyExists, Message: "sandbox already exists"} }, wantStatus: http.StatusConflict, wantCode: "already_exists", @@ -185,8 +168,9 @@ func TestCreateSandbox(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - mock := &mockGateway{createSandboxFn: tc.mockFn} - app := newTestApp(mock) + sdk := &mockSDK{} + sdk.sandboxes.createFn = tc.createFn + app := newTestAppWithSDK(sdk) r := chi.NewRouter() r.Post("/workspaces/{workspace}/sandboxes", app.CreateSandbox) @@ -213,31 +197,30 @@ func TestCreateSandbox(t *testing.T) { func TestGetSandbox(t *testing.T) { tests := []struct { - mockFn func(ctx context.Context, workspace, name string) (*openshellv1.Sandbox, error) + getFn func(ctx context.Context, workspace, name string) (*openshell.Sandbox, error) name string wantStatus int }{ { name: "success", - mockFn: func(_ context.Context, _, _ string) (*openshellv1.Sandbox, error) { - return &openshellv1.Sandbox{ - Metadata: &datamodelv1.ObjectMeta{Id: "id-1", Name: "my-sandbox"}, - Status: &openshellv1.SandboxStatus{Phase: openshellv1.SandboxPhase_SANDBOX_PHASE_READY}, - }, nil + getFn: func(_ context.Context, _, name string) (*openshell.Sandbox, error) { + return &openshell.Sandbox{ID: "id-1", Name: name, Status: openshell.SandboxStatus{Phase: openshell.SandboxReady}}, nil }, wantStatus: http.StatusOK, }, { name: "not found", - mockFn: func(_ context.Context, _, _ string) (*openshellv1.Sandbox, error) { - return nil, status.Error(codes.NotFound, "sandbox not found") + getFn: func(_ context.Context, _, _ string) (*openshell.Sandbox, error) { + return nil, &openshell.StatusError{Code: openshell.ErrorNotFound, Message: "sandbox not found"} }, wantStatus: http.StatusNotFound, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - app := newTestApp(&mockGateway{getSandboxFn: tc.mockFn}) + sdk := &mockSDK{} + sdk.sandboxes.getFn = tc.getFn + app := newTestAppWithSDK(sdk) r := chi.NewRouter() r.Get("/workspaces/{workspace}/sandboxes/{name}", app.GetSandbox) @@ -254,35 +237,37 @@ func TestGetSandbox(t *testing.T) { func TestDeleteSandbox(t *testing.T) { tests := []struct { - mockFn func(ctx context.Context, workspace, name string) (bool, error) + deleteFn func(ctx context.Context, workspace, name string) error name string wantStatus int }{ { name: "success", - mockFn: func(_ context.Context, _, _ string) (bool, error) { - return true, nil + deleteFn: func(_ context.Context, _, _ string) error { + return nil }, wantStatus: http.StatusOK, }, { name: "not found", - mockFn: func(_ context.Context, _, _ string) (bool, error) { - return false, status.Error(codes.NotFound, "sandbox not found") + deleteFn: func(_ context.Context, _, _ string) error { + return &openshell.StatusError{Code: openshell.ErrorNotFound, Message: "sandbox not found"} }, wantStatus: http.StatusNotFound, }, { name: "permission denied", - mockFn: func(_ context.Context, _, _ string) (bool, error) { - return false, status.Error(codes.PermissionDenied, "access denied") + deleteFn: func(_ context.Context, _, _ string) error { + return &openshell.StatusError{Code: openshell.ErrorPermissionDenied, Message: "access denied"} }, wantStatus: http.StatusForbidden, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - app := newTestApp(&mockGateway{deleteSandboxFn: tc.mockFn}) + sdk := &mockSDK{} + sdk.sandboxes.deleteFn = tc.deleteFn + app := newTestAppWithSDK(sdk) r := chi.NewRouter() r.Delete("/workspaces/{workspace}/sandboxes/{name}", app.DeleteSandbox) @@ -296,38 +281,3 @@ func TestDeleteSandbox(t *testing.T) { }) } } - -func TestCreateSandboxWithGPUAndResources(t *testing.T) { - var capturedSpec *openshellv1.SandboxSpec - mock := &mockGateway{ - createSandboxFn: func(_ context.Context, _, _ string, spec *openshellv1.SandboxSpec, _, _ map[string]string) (*openshellv1.Sandbox, error) { - capturedSpec = spec - return &openshellv1.Sandbox{ - Metadata: &datamodelv1.ObjectMeta{Id: "id-1", Name: "gpu-sandbox"}, - Status: &openshellv1.SandboxStatus{Phase: openshellv1.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, - }, nil - }, - } - app := newTestApp(mock) - r := chi.NewRouter() - r.Post("/workspaces/{workspace}/sandboxes", app.CreateSandbox) - - body := `{"name":"gpu-sandbox","image":"nvidia/cuda","gpuCount":2,"cpu":"4","memory":"8Gi","policy":{"version":1,"filesystem":{"includeWorkdir":true}}}` - req := httptest.NewRequest(http.MethodPost, "/workspaces/default/sandboxes", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusCreated { - t.Fatalf("status = %d, want 201; body: %s", w.Code, w.Body.String()) - } - if capturedSpec.ResourceRequirements == nil || capturedSpec.ResourceRequirements.Gpu == nil { - t.Fatal("expected GPU resource requirements to be set") - } - if capturedSpec.ResourceRequirements.Gpu.GetCount() != 2 { - t.Errorf("gpu count = %d, want 2", capturedSpec.ResourceRequirements.Gpu.GetCount()) - } - if capturedSpec.Template.Resources == nil { - t.Fatal("expected template resources to be set for cpu/memory") - } -} diff --git a/backend/internal/models/sdk_converters.go b/backend/internal/models/sdk_converters.go new file mode 100644 index 00000000..cc7ce7c0 --- /dev/null +++ b/backend/internal/models/sdk_converters.go @@ -0,0 +1,245 @@ +package models + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + openshell "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" +) + +// FromSDKSandbox converts an SDK Sandbox to the JSON DTO the frontend expects. +func FromSDKSandbox(sandbox *openshell.Sandbox) Sandbox { + out := Sandbox{ + Metadata: ObjectMeta{ + ID: sandbox.ID, + Name: sandbox.Name, + Workspace: sandbox.Workspace, + Labels: sandbox.Labels, + Annotations: sandbox.Annotations, + CreatedAtMs: timeToMs(sandbox.CreatedAt), + ResourceVersion: sandbox.ResourceVersion, + }, + } + if sandbox.DeletionTimestamp != nil { + out.Metadata.DeletionTimestampMs = sandbox.DeletionTimestamp.UnixMilli() + } + + out.Spec = SandboxSpec{ + LogLevel: sandbox.Spec.LogLevel, + Environment: sandbox.Spec.Environment, + Providers: sandbox.Spec.Providers, + } + if sandbox.Spec.Template != nil { + out.Spec.Image = sandbox.Spec.Template.Image + } + if sandbox.Spec.Policy != nil { + out.Spec.Policy = marshalSDKPolicy(sandbox.Spec.Policy) + } + + out.Status = SandboxStatus{ + SandboxName: sandbox.Status.SandboxName, + AgentPod: sandbox.Status.AgentPod, + Phase: strings.ToUpper(string(sandbox.Status.Phase)), + CurrentPolicyVersion: sandbox.Status.CurrentPolicyVersion, + } + for _, cond := range sandbox.Status.Conditions { + out.Status.Conditions = append(out.Status.Conditions, SandboxCondition{ + Type: cond.Type, + Status: cond.Status, + Reason: cond.Reason, + Message: cond.Message, + LastTransitionTime: cond.LastTransitionTime, + }) + } + return out +} + +// BuildSDKSandboxSpec constructs an SDK SandboxSpec from a create request. +func BuildSDKSandboxSpec(req CreateSandboxRequest) (*openshell.SandboxSpec, error) { + policy, err := ParseSDKPolicy(req.Policy) + if err != nil { + return nil, fmt.Errorf("policy does not match the SandboxPolicy schema: %w", err) + } + + spec := &openshell.SandboxSpec{ + LogLevel: req.LogLevel, + Environment: req.Environment, + Template: &openshell.SandboxTemplate{ + Image: req.Image, + }, + Policy: policy, + Providers: req.Providers, + } + + if req.CPU != "" || req.Memory != "" { + resources := map[string]any{} + limits := map[string]any{} + if req.CPU != "" { + limits["cpu"] = req.CPU + } + if req.Memory != "" { + limits["memory"] = req.Memory + } + resources["limits"] = limits + spec.Template.Resources = resources + } + + if req.GpuCount > 0 { + gpu := req.GpuCount + spec.GPUCount = &gpu + } + return spec, nil +} + +// ParseSDKPolicy converts camelCase JSON from the frontend into the SDK SandboxPolicy. +func ParseSDKPolicy(raw json.RawMessage) (*openshell.SandboxPolicy, error) { + var p sdkPolicyJSON + if err := json.Unmarshal(raw, &p); err != nil { + return nil, err + } + policy := &openshell.SandboxPolicy{Version: p.Version} + if p.Filesystem != nil { + policy.Filesystem = &openshell.FilesystemPolicy{ + IncludeWorkdir: p.Filesystem.IncludeWorkdir, + ReadOnly: p.Filesystem.ReadOnly, + ReadWrite: p.Filesystem.ReadWrite, + } + } + if p.Landlock != nil { + policy.Landlock = &openshell.LandlockPolicy{Compatibility: p.Landlock.Compatibility} + } + if p.Process != nil { + policy.Process = &openshell.ProcessPolicy{ + RunAsUser: p.Process.RunAsUser, + RunAsGroup: p.Process.RunAsGroup, + } + } + if p.NetworkPolicies != nil { + policy.NetworkPolicies = make(map[string]openshell.NetworkPolicyRule, len(p.NetworkPolicies)) + for k, v := range p.NetworkPolicies { + policy.NetworkPolicies[k] = parseSDKNetworkRule(v) + } + } + return policy, nil +} + +func timeToMs(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UnixMilli() +} + +// marshalSDKPolicy converts an SDK SandboxPolicy into camelCase JSON for the frontend. +func marshalSDKPolicy(p *openshell.SandboxPolicy) json.RawMessage { + pj := sdkPolicyJSON{Version: p.Version} + if p.Filesystem != nil { + pj.Filesystem = &sdkFilesystemJSON{ + IncludeWorkdir: p.Filesystem.IncludeWorkdir, + ReadOnly: p.Filesystem.ReadOnly, + ReadWrite: p.Filesystem.ReadWrite, + } + } + if p.Landlock != nil { + pj.Landlock = &sdkLandlockJSON{Compatibility: p.Landlock.Compatibility} + } + if p.Process != nil { + pj.Process = &sdkProcessJSON{ + RunAsUser: p.Process.RunAsUser, + RunAsGroup: p.Process.RunAsGroup, + } + } + if p.NetworkPolicies != nil { + pj.NetworkPolicies = make(map[string]sdkNetworkPolicyRuleJSON, len(p.NetworkPolicies)) + for k, rule := range p.NetworkPolicies { + pj.NetworkPolicies[k] = marshalSDKNetworkRule(rule) + } + } + raw, err := json.Marshal(pj) + if err != nil { + return nil + } + return raw +} + +// --- JSON serialization types for SDK policy (camelCase for frontend) --- + +type sdkPolicyJSON struct { + NetworkPolicies map[string]sdkNetworkPolicyRuleJSON `json:"networkPolicies,omitempty"` + Filesystem *sdkFilesystemJSON `json:"filesystem,omitempty"` + Landlock *sdkLandlockJSON `json:"landlock,omitempty"` + Process *sdkProcessJSON `json:"process,omitempty"` + Version uint32 `json:"version,omitempty"` +} + +type sdkFilesystemJSON struct { + ReadOnly []string `json:"readOnly,omitempty"` + ReadWrite []string `json:"readWrite,omitempty"` + IncludeWorkdir bool `json:"includeWorkdir,omitempty"` +} + +type sdkLandlockJSON struct { + Compatibility string `json:"compatibility,omitempty"` +} + +type sdkProcessJSON struct { + RunAsUser string `json:"runAsUser,omitempty"` + RunAsGroup string `json:"runAsGroup,omitempty"` +} + +type sdkNetworkPolicyRuleJSON struct { + Name string `json:"name,omitempty"` + Endpoints []sdkNetworkEndpointJSON `json:"endpoints,omitempty"` + Binaries []sdkNetworkBinaryJSON `json:"binaries,omitempty"` +} + +type sdkNetworkEndpointJSON struct { + Host string `json:"host,omitempty"` + Protocol string `json:"protocol,omitempty"` + TLS string `json:"tls,omitempty"` + Enforcement string `json:"enforcement,omitempty"` + Access string `json:"access,omitempty"` + Port uint32 `json:"port,omitempty"` +} + +type sdkNetworkBinaryJSON struct { + Path string `json:"path,omitempty"` +} + +func parseSDKNetworkRule(rj sdkNetworkPolicyRuleJSON) openshell.NetworkPolicyRule { + rule := openshell.NetworkPolicyRule{Name: rj.Name} + for _, ej := range rj.Endpoints { + rule.Endpoints = append(rule.Endpoints, openshell.PolicyNetworkEndpoint{ + Host: ej.Host, + Port: ej.Port, + Protocol: ej.Protocol, + TLS: ej.TLS, + Enforcement: ej.Enforcement, + Access: ej.Access, + }) + } + for _, bj := range rj.Binaries { + rule.Binaries = append(rule.Binaries, openshell.PolicyNetworkBinary{Path: bj.Path}) + } + return rule +} + +func marshalSDKNetworkRule(rule openshell.NetworkPolicyRule) sdkNetworkPolicyRuleJSON { + rj := sdkNetworkPolicyRuleJSON{Name: rule.Name} + for _, ep := range rule.Endpoints { + rj.Endpoints = append(rj.Endpoints, sdkNetworkEndpointJSON{ + Host: ep.Host, + Port: ep.Port, + Protocol: ep.Protocol, + TLS: ep.TLS, + Enforcement: ep.Enforcement, + Access: ep.Access, + }) + } + for _, b := range rule.Binaries { + rj.Binaries = append(rj.Binaries, sdkNetworkBinaryJSON{Path: b.Path}) + } + return rj +} diff --git a/backend/internal/models/sdk_converters_test.go b/backend/internal/models/sdk_converters_test.go new file mode 100644 index 00000000..fab3b391 --- /dev/null +++ b/backend/internal/models/sdk_converters_test.go @@ -0,0 +1,112 @@ +package models + +import ( + "encoding/json" + "testing" + + openshell "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" +) + +func TestBuildSDKSandboxSpecBasic(t *testing.T) { + req := CreateSandboxRequest{ + Name: "test-sandbox", + Image: "ubuntu:latest", + Policy: json.RawMessage(`{"version":1,"filesystem":{"includeWorkdir":true}}`), + } + spec, err := BuildSDKSandboxSpec(req) + if err != nil { + t.Fatalf("BuildSDKSandboxSpec: %v", err) + } + if spec.Template == nil || spec.Template.Image != "ubuntu:latest" { + t.Errorf("image = %v, want ubuntu:latest", spec.Template) + } + if spec.Policy == nil || spec.Policy.Version != 1 { + t.Errorf("policy version = %v, want 1", spec.Policy) + } + if spec.Policy.Filesystem == nil || !spec.Policy.Filesystem.IncludeWorkdir { + t.Error("expected filesystem.includeWorkdir = true") + } +} + +func TestBuildSDKSandboxSpecWithGPU(t *testing.T) { + req := CreateSandboxRequest{ + Name: "gpu-sandbox", + Image: "nvidia/cuda", + GpuCount: 2, + Policy: json.RawMessage(`{"version":1}`), + } + spec, err := BuildSDKSandboxSpec(req) + if err != nil { + t.Fatalf("BuildSDKSandboxSpec: %v", err) + } + if spec.GPUCount == nil || *spec.GPUCount != 2 { + t.Errorf("GPUCount = %v, want 2", spec.GPUCount) + } +} + +func TestBuildSDKSandboxSpecWithCPUMemory(t *testing.T) { + req := CreateSandboxRequest{ + Name: "resource-sandbox", + Image: "ubuntu:latest", + CPU: "4", + Memory: "8Gi", + Policy: json.RawMessage(`{"version":1}`), + } + spec, err := BuildSDKSandboxSpec(req) + if err != nil { + t.Fatalf("BuildSDKSandboxSpec: %v", err) + } + if spec.Template.Resources == nil { + t.Fatal("expected Template.Resources to be set") + } + limits, ok := spec.Template.Resources["limits"] + if !ok { + t.Fatal("expected resources.limits to be set") + } + limitsMap, ok := limits.(map[string]any) + if !ok { + t.Fatalf("limits is %T, want map[string]any", limits) + } + if limitsMap["cpu"] != "4" { + t.Errorf("cpu = %v, want 4", limitsMap["cpu"]) + } + if limitsMap["memory"] != "8Gi" { + t.Errorf("memory = %v, want 8Gi", limitsMap["memory"]) + } +} + +func TestBuildSDKSandboxSpecInvalidPolicy(t *testing.T) { + req := CreateSandboxRequest{ + Name: "bad-policy", + Image: "ubuntu:latest", + Policy: json.RawMessage(`{invalid json`), + } + _, err := BuildSDKSandboxSpec(req) + if err == nil { + t.Fatal("expected error for invalid policy JSON") + } +} + +func TestFromSDKSandboxPhaseMapping(t *testing.T) { + cases := []struct { + phase openshell.SandboxPhase + want string + }{ + {openshell.SandboxProvisioning, "PROVISIONING"}, + {openshell.SandboxReady, "READY"}, + {openshell.SandboxError, "ERROR"}, + {openshell.SandboxDeleting, "DELETING"}, + {openshell.SandboxUnknown, "UNKNOWN"}, + } + for _, tc := range cases { + t.Run(string(tc.phase), func(t *testing.T) { + sb := &openshell.Sandbox{ + Status: openshell.SandboxStatus{Phase: tc.phase}, + } + result := FromSDKSandbox(sb) + if result.Status.Phase != tc.want { + t.Errorf("phase %q: got %q, want %q", tc.phase, result.Status.Phase, tc.want) + } + }) + } +} diff --git a/backend/internal/sdkclient/auth.go b/backend/internal/sdkclient/auth.go new file mode 100644 index 00000000..10d800db --- /dev/null +++ b/backend/internal/sdkclient/auth.go @@ -0,0 +1,26 @@ +package sdkclient + +import ( + "context" + + "github.com/Gkrumbach07/openshell-dashboard/backend/internal/auth" +) + +// ContextAuthProvider implements grpc.PerRPCCredentials by reading the JWT +// from the request context on every gRPC call. This allows a single shared +// SDK client to forward per-request user tokens to the gateway. +type ContextAuthProvider struct { + RequireTLS bool +} + +func (c ContextAuthProvider) GetRequestMetadata(ctx context.Context, _ ...string) (map[string]string, error) { + token := auth.TokenFromContext(ctx) + if token == "" { + return nil, nil + } + return map[string]string{"authorization": "Bearer " + token}, nil +} + +func (c ContextAuthProvider) RequireTransportSecurity() bool { + return c.RequireTLS +} diff --git a/scripts/dev-env.sh b/scripts/dev-env.sh index 90b02ac1..ad7568ca 100755 --- a/scripts/dev-env.sh +++ b/scripts/dev-env.sh @@ -604,7 +604,7 @@ write_env_file() { cat > "$ENV_FILE" <