Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down
46 changes: 44 additions & 2 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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)")
Expand All @@ -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")
}
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down Expand Up @@ -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=
Expand Down
6 changes: 5 additions & 1 deletion backend/internal/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ 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"
)

// 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.
Expand All @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion backend/internal/api/mock_gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
103 changes: 103 additions & 0 deletions backend/internal/api/mock_sdk_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
56 changes: 56 additions & 0 deletions backend/internal/api/respond.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading