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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ frontend/dist/

# Go
backend/bin/
backend/server

# Editor / OS
.DS_Store
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
48 changes: 31 additions & 17 deletions backend/internal/api/logs_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,14 @@ func (app *App) GetSandboxLogs(w http.ResponseWriter, r *http.Request) {

// ListSandboxProviders lists provider records attached to a sandbox.
func (app *App) ListSandboxProviders(w http.ResponseWriter, r *http.Request) {
resp, err := app.gateway.ListSandboxProviders(r.Context(), chi.URLParam(r, "workspace"), chi.URLParam(r, "name"))
providers, err := app.sdk.Sandboxes().ListProviders(r.Context(), chi.URLParam(r, "workspace"), chi.URLParam(r, "name"))
if err != nil {
writeGrpcError(w, err)
writeSDKError(w, err)
return
}
out := make([]models.Provider, 0, len(resp.GetProviders()))
for _, provider := range resp.GetProviders() {
out = append(out, models.FromProvider(provider))
out := make([]models.Provider, 0, len(providers))
for _, provider := range providers {
out = append(out, models.FromSDKProvider(provider))
}
writeJSON(w, http.StatusOK, out)
}
Expand All @@ -77,15 +77,22 @@ func (app *App) AttachSandboxProvider(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > 0 && !decodeBody(w, r, &body) {
return
}
resp, err := app.gateway.AttachSandboxProvider(r.Context(), chi.URLParam(r, "workspace"), chi.URLParam(r, "name"), chi.URLParam(r, "provider"), body.ExpectedResourceVersion)
result, err := app.sdk.Sandboxes().AttachProvider(
r.Context(),
chi.URLParam(r, "workspace"),
chi.URLParam(r, "name"),
chi.URLParam(r, "provider"),
body.ExpectedResourceVersion,
)
if err != nil {
writeGrpcError(w, err)
writeSDKError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"attached": resp.GetAttached(),
"sandbox": models.FromSandbox(resp.GetSandbox()),
})
out := map[string]any{"attached": result.Attached}
if result.Sandbox != nil {
out["sandbox"] = models.FromSDKSandbox(result.Sandbox)
}
writeJSON(w, http.StatusOK, out)
}

// DetachSandboxProvider detaches a provider from a sandbox.
Expand All @@ -94,13 +101,20 @@ func (app *App) DetachSandboxProvider(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > 0 && !decodeBody(w, r, &body) {
return
}
resp, err := app.gateway.DetachSandboxProvider(r.Context(), chi.URLParam(r, "workspace"), chi.URLParam(r, "name"), chi.URLParam(r, "provider"), body.ExpectedResourceVersion)
result, err := app.sdk.Sandboxes().DetachProvider(
r.Context(),
chi.URLParam(r, "workspace"),
chi.URLParam(r, "name"),
chi.URLParam(r, "provider"),
body.ExpectedResourceVersion,
)
if err != nil {
writeGrpcError(w, err)
writeSDKError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"detached": resp.GetDetached(),
"sandbox": models.FromSandbox(resp.GetSandbox()),
})
out := map[string]any{"detached": result.Detached}
if result.Sandbox != nil {
out["sandbox"] = models.FromSDKSandbox(result.Sandbox)
}
writeJSON(w, http.StatusOK, out)
}
91 changes: 90 additions & 1 deletion backend/internal/api/logs_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/go-chi/chi/v5"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

openshell "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"

datamodelv1 "github.com/Gkrumbach07/openshell-dashboard/backend/gen/datamodelv1"
openshellv1 "github.com/Gkrumbach07/openshell-dashboard/backend/gen/openshellv1"
)
Expand Down Expand Up @@ -100,7 +104,7 @@ func TestGetSandboxLogs(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
app := newTestApp(&mockGateway{
getSandboxFn: tc.getSandboxFn,
getSandboxFn: tc.getSandboxFn,
getSandboxLogsFn: tc.getLogsFn,
})
r := chi.NewRouter()
Expand Down Expand Up @@ -176,3 +180,88 @@ func TestGetSandboxLogsBody(t *testing.T) {
t.Errorf("dst_host = %v", fields["dst_host"])
}
}

func TestListSandboxProviders(t *testing.T) {
sdk := &mockSDK{}
sdk.sandboxes.listProvidersFn = func(_ context.Context, _, _ string) ([]*openshell.Provider, error) {
return []*openshell.Provider{
{
Name: "claude-prov",
Type: "claude",
Spec: openshell.ProviderSpec{
CredentialHandles: map[string]types.CredentialHandle{
"api_key": {Driver: "vault", Handle: "vault://must-not-leak"},
},
},
},
}, nil
}
app := newTestAppWithSDK(sdk)
r := chi.NewRouter()
r.Get("/workspaces/{workspace}/sandboxes/{name}/providers", app.ListSandboxProviders)

req := httptest.NewRequest(http.MethodGet, "/workspaces/default/sandboxes/my-sandbox/providers", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if strings.Contains(body, "must-not-leak") {
t.Errorf("leaked credential handle: %s", body)
}
var got []map[string]any
if err := json.Unmarshal([]byte(body), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d providers, want 1", len(got))
}
names, _ := got[0]["credentialNames"].([]any)
if len(names) != 1 || names[0] != "api_key" {
t.Errorf("credentialNames = %v, want [api_key]", got[0]["credentialNames"])
}
}

func TestAttachDetachSandboxProvider(t *testing.T) {
sdk := &mockSDK{}
sdk.sandboxes.attachFn = func(_ context.Context, workspace, sandboxName, providerName string, expectedRV uint64) (*openshell.AttachProviderResult, error) {
if workspace != "default" || sandboxName != "my-sandbox" || providerName != "claude-prov" {
t.Errorf("attach args = %s/%s/%s", workspace, sandboxName, providerName)
}
if expectedRV != 42 {
t.Errorf("expectedResourceVersion = %d, want 42", expectedRV)
}
return &openshell.AttachProviderResult{Attached: true, Sandbox: &openshell.Sandbox{Name: sandboxName}}, nil
}
sdk.sandboxes.detachFn = func(_ context.Context, _, sandboxName, _ string, _ uint64) (*openshell.DetachProviderResult, error) {
return &openshell.DetachProviderResult{Detached: true, Sandbox: &openshell.Sandbox{Name: sandboxName}}, nil
}
app := newTestAppWithSDK(sdk)
r := chi.NewRouter()
r.Post("/workspaces/{workspace}/sandboxes/{name}/providers/{provider}", app.AttachSandboxProvider)
r.Delete("/workspaces/{workspace}/sandboxes/{name}/providers/{provider}", app.DetachSandboxProvider)

req := httptest.NewRequest(http.MethodPost, "/workspaces/default/sandboxes/my-sandbox/providers/claude-prov", strings.NewReader(`{"expectedResourceVersion":42}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("attach status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var attach map[string]any
if err := json.NewDecoder(w.Body).Decode(&attach); err != nil {
t.Fatalf("decode attach: %v", err)
}
if attach["attached"] != true {
t.Errorf("attached = %v", attach["attached"])
}

req = httptest.NewRequest(http.MethodDelete, "/workspaces/default/sandboxes/my-sandbox/providers/claude-prov", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("detach status = %d, want 200; body: %s", w.Code, w.Body.String())
}
}
Loading
Loading