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
39 changes: 10 additions & 29 deletions .claude/rules/bff-go.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,9 @@ backend/
│ │ ├── *_handler.go # Per-resource handlers
│ │ └── middleware.go # Auth, CORS, logging
│ ├── auth/ # OIDC middleware
│ ├── gateway/ # Thin gRPC wrapper
│ │ ├── client.go # Connection, per-RPC OIDC auth
│ │ ├── sandboxes.go # Sandbox CRUD + logs
│ │ ├── workspaces.go # Workspace + member CRUD
│ │ ├── providers.go # Provider + profile CRUD
│ │ ├── policies.go # Policy + draft policy
│ │ └── inference.go # Inference route CRUD
│ └── models/ # Response DTOs
├── proto/ # Copied from NVIDIA/OpenShell/proto/
├── gen/ # protoc-generated Go stubs (committed)
│ ├── sdkclient/ # SDK auth provider (per-request JWT forwarding)
│ │ └── auth.go # ContextAuthProvider
│ └── models/ # Response DTOs and SDK type converters
├── go.mod
└── go.sum
```
Expand All @@ -41,30 +34,22 @@ func (app *App) ListSandboxes(w http.ResponseWriter, r *http.Request)

URL params via `chi.URLParam(r, "workspace")`.

## Gateway client
## SDK client

The `internal/gateway/` package wraps protoc-generated gRPC stubs. Each method is 5-10 lines:
The BFF uses `openshell-sdk-go` via a single shared `openshell.ClientInterface`. Handlers access sub-clients directly:

```go
func (c *Client) ListSandboxes(ctx context.Context, workspace string) ([]*pb.Sandbox, error) {
resp, err := c.openshell.ListSandboxes(ctx, &pb.ListSandboxesRequest{
Workspace: workspace,
})
if err != nil {
return nil, err
}
return resp.Sandboxes, nil
}
sandboxes, err := app.client.Sandboxes().List(r.Context(), workspace)
```

Only wrap user-facing RPCs (~30). Skip supervisor/internal RPCs.
Per-request JWT forwarding is handled by `ContextAuthProvider` in `internal/sdkclient/auth.go`, which reads the token from the request context on every gRPC call.

## Auth

OIDC via `go-oidc` v3. Per-request flow:
1. Extract JWT from `Authorization: Bearer` header or HTTP-only cookie
2. Validate against gateway's OIDC issuer JWKS
3. Forward same JWT to gateway on every gRPC call via `grpc.PerRPCCredentials`
3. Forward same JWT to gateway on every SDK call via `ContextAuthProvider`
4. Gateway enforces RBAC (admin/user roles) and workspace membership

## Configuration
Expand Down Expand Up @@ -96,10 +81,6 @@ type ErrorResponse struct {
- `httptest.NewRecorder()` + `http.NewRequest()` for handler tests
- `slog` for structured logging

## Proto regeneration

```bash
make proto # runs protoc on backend/proto/*.proto → backend/gen/
```
## SDK dependency

Proto files are copied from `NVIDIA/OpenShell/proto/`. Keep them in sync manually or via CI check.
The BFF depends on `github.com/rhuss/openshell-sdk-go`. Update with `go get -u` in `backend/`.
6 changes: 6 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"enabledPlugins": {
"design-audit@patternfly-ai-helpers": true,
"patternfly-mcp@patternfly-ai-helpers": true
}
}
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ backend/bin/
.idea/
.vscode/

# Dev environment state (generated certs, runtime artifacts)
# Dev environment state (generated certs, runtime artifacts, env config)
scripts/.pki/
scripts/.state/
scripts/.env.dev

# Env files are never committed
.env
Expand Down
12 changes: 5 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,14 @@ backend/ Go BFF
cmd/ Entry point
internal/api/ REST handlers
internal/auth/ OIDC middleware
internal/gateway/ Thin gRPC wrapper (~30 RPCs)
proto/ Copied from NVIDIA/OpenShell/proto/
gen/ protoc-generated Go stubs
internal/sdkclient/ SDK auth provider (per-request JWT forwarding)
internal/models/ Response DTOs and SDK type converters
```

## Build and run

```bash
make setup # install frontend + go deps
make proto # regenerate Go stubs from proto files
make dev # start frontend dev server + BFF with hot reload
make build # produce container image
make test # frontend unit tests + go tests
Expand All @@ -36,17 +34,17 @@ Requires a running OpenShell gateway: `openshell gateway start` (Podman) or poin

## Architecture rules

- **Proto is source of truth.** `backend/proto/` defines what exists. Before implementing anything API-adjacent, read the actual proto definitions. Never invent RPCs, fields, or lifecycle states (see `.claude/rules/openshell-api.md` for the list of things that famously don't exist: sandbox stop/start, workspace policy library, OCSF events API, member role update).
- **SDK is source of truth.** `openshell-sdk-go` defines what exists. Before implementing anything API-adjacent, check the SDK interfaces. Never invent RPCs, fields, or lifecycle states (see `.claude/rules/openshell-api.md` for the list of things that famously don't exist: sandbox stop/start, workspace policy library, OCSF events API, member role update).
- **Zero `@odh-dashboard/*` imports.** This repo has no knowledge of odh-dashboard. Downstream consumption happens via a separate package that imports our components.
- **OIDC only for auth.** No mTLS, no OpenShift OAuth, no edge tokens.
- **gRPC via protoc-generated stubs**, not any SDK. The `internal/gateway/` package wraps ~30 user-facing RPCs. Skip internal/supervisor RPCs.
- **gRPC via openshell-sdk-go.** The SDK client wraps ~30 user-facing RPCs with sub-clients (Sandboxes, Workspaces, Providers, Policy, Config, Inference, Services, Exec, Files). Skip internal/supervisor RPCs.
- **No WebSockets.** Downstream federation proxy can't handle them. Use polling for status, polling for logs.
- **PatternFly 6 only.** No MUI, no custom design system.
- **Page components must be self-contained and exportable.** Each page takes props and uses internal API hooks. No dashboard-specific wrappers baked in.

## OpenShell API reference

The gateway exposes 60+ gRPC RPCs across 4 services. We surface ~30 user-facing ones. Proto files are in `backend/proto/`. See the full API surface map in the `brain/openshell-dashboard/api-surface.md` planning doc.
The gateway exposes 60+ gRPC RPCs across 4 services. We surface ~30 user-facing ones via the `openshell-sdk-go` Go SDK. See the full API surface map in the `brain/openshell-dashboard/api-surface.md` planning doc.

## Personas

Expand Down
34 changes: 6 additions & 28 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,46 +1,24 @@
GO_MODULE := github.com/Gkrumbach07/openshell-dashboard/backend
PROTO_DIR := backend/proto
GEN_DIR := backend/gen
PROTO_FILES := options.proto datamodel.proto sandbox.proto inference.proto openshell.proto

# Map each proto file to its generated Go package import path.
PROTO_GO_OPTS := \
--go_opt=Moptions.proto=$(GO_MODULE)/gen/optionsv1 \
--go_opt=Mdatamodel.proto=$(GO_MODULE)/gen/datamodelv1 \
--go_opt=Msandbox.proto=$(GO_MODULE)/gen/sandboxv1 \
--go_opt=Minference.proto=$(GO_MODULE)/gen/inferencev1 \
--go_opt=Mopenshell.proto=$(GO_MODULE)/gen/openshellv1
PROTO_GRPC_OPTS := \
--go-grpc_opt=Moptions.proto=$(GO_MODULE)/gen/optionsv1 \
--go-grpc_opt=Mdatamodel.proto=$(GO_MODULE)/gen/datamodelv1 \
--go-grpc_opt=Msandbox.proto=$(GO_MODULE)/gen/sandboxv1 \
--go-grpc_opt=Minference.proto=$(GO_MODULE)/gen/inferencev1 \
--go-grpc_opt=Mopenshell.proto=$(GO_MODULE)/gen/openshellv1
# Auto-source dev environment config if available (written by scripts/dev-env.sh)
-include scripts/.env.dev
export

.PHONY: setup proto dev dev-full dev-backend dev-frontend build build-frontend build-backend test lint typecheck clean
.PHONY: setup dev dev-full dev-backend dev-frontend build build-frontend build-backend test lint typecheck clean

setup: ## Install frontend deps and Go deps
cd frontend && npm install
cd backend && go mod download

proto: ## Regenerate Go stubs from backend/proto/*.proto into backend/gen/
rm -rf $(GEN_DIR)
mkdir -p $(GEN_DIR)
protoc -I $(PROTO_DIR) \
--go_out=$(GEN_DIR) --go_opt=module=$(GO_MODULE)/gen $(PROTO_GO_OPTS) \
--go-grpc_out=$(GEN_DIR) --go-grpc_opt=module=$(GO_MODULE)/gen $(PROTO_GRPC_OPTS) \
$(addprefix $(PROTO_DIR)/,$(PROTO_FILES))
cd backend && go mod tidy

dev-full: ## Start dev infrastructure (Keycloak + gateway) then frontend + BFF
dev-full: ## Start Keycloak + gateway, then frontend + BFF (one command)
./scripts/dev-env.sh start
@$(MAKE) dev

dev: ## Start frontend dev server (:3000) and Go BFF (:8080)
@$(MAKE) -j2 dev-backend dev-frontend

dev-backend:
cd backend && AUTH_DISABLED=$${AUTH_DISABLED:-true} go run ./cmd/server
cd backend && go run ./cmd/server

dev-frontend:
cd frontend && npm start
Expand Down
35 changes: 21 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,13 @@ To test with real OIDC authentication against a local Keycloak and OpenShell gat

```bash
make setup
export OPENSHELL_DIR=~/path/to/openshell # your OpenShell checkout
make dev-full # starts infra + dashboard
```

# Point at your OpenShell source checkout
export OPENSHELL_DIR=~/path/to/openshell
That's it. `dev-full` starts Keycloak and the gateway (if not already running), writes a `scripts/.env.dev` config file, and launches the dashboard. On subsequent runs, `make dev` picks up the config automatically (no env vars needed).

# Start the infrastructure (Keycloak + gateway)
./scripts/dev-env.sh start

# Run the dashboard with the printed env vars
export OPENSHELL_GATEWAY_URL=grpcs://localhost:17670
export OIDC_ISSUER=http://localhost:8180/realms/openshell
export OIDC_CLIENT_ID=openshell-dashboard
export GATEWAY_CA_CERT=$(pwd)/scripts/.pki/ca.crt
export AUTH_DISABLED=false
make dev
```
If `OPENSHELL_DIR` is not set, the script prompts interactively and offers to clone the repo for you. The chosen path is saved to `scripts/.env.dev` so you only configure it once.

Open http://localhost:3000 and log in via Keycloak with one of the test users:

Expand All @@ -60,7 +52,22 @@ Open http://localhost:3000 and log in via Keycloak with one of the test users:
| `user@test` | `user` | Workspace member |
| `user-b@test` | `user-b` | Workspace member |

The script is idempotent. Run `./scripts/dev-env.sh status` to check components, `stop` to tear down, or `rebuild-gateway` after pulling upstream changes.
### What `dev-full` starts

| Component | How | Lifecycle |
|-----------|-----|-----------|
| Keycloak | Podman container (`openshell-keycloak`) on port 8180 | Runs until `dev-env.sh stop` |
| OpenShell gateway | Background process built from source, port 17670 (gRPCs) + 17671 (health) | Runs until `dev-env.sh stop` |
| Dashboard BFF | `go run` on port 8080 | Runs with `make dev`, Ctrl+C to stop |
| Dashboard frontend | Webpack dev server on port 3000 | Runs with `make dev`, Ctrl+C to stop |

Keycloak and the gateway survive across `make dev` restarts. Stop them explicitly:

```bash
./scripts/dev-env.sh stop # stops gateway + keycloak, cleans up orphans
./scripts/dev-env.sh status # check what's running
./scripts/dev-env.sh rebuild-gateway # rebuild after upstream changes
```

## Configuration

Expand Down
22 changes: 17 additions & 5 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import (
"strings"
"time"

openshell "github.com/rhuss/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"
)

// envOr returns the environment variable value or a default.
Expand Down Expand Up @@ -68,17 +70,27 @@ func main() {
CredentialRefresh: envOr("FEATURE_CREDENTIAL_REFRESH", "true") == "true",
Services: envOr("FEATURE_SERVICES", "true") == "true",
DraftPolicy: envOr("FEATURE_DRAFT_POLICY", "true") == "true",
DeploymentContext: envOr("DEPLOYMENT_CONTEXT", "standalone"),
WorkspaceBinding: envOr("FEATURE_WORKSPACE_BINDING", "false") == "true",
ResourceLinks: envOr("FEATURE_RESOURCE_LINKS", "false") == "true",
},
})

gatewayClient, err := gateway.New(*gatewayURL, *gatewayCACert)
cfg := openshell.Config{
Address: *gatewayURL,
Auth: sdkclient.ContextAuthProvider{},
}
if *gatewayCACert != "" {
cfg.TLS = &openshell.TLSConfig{CAFile: *gatewayCACert}
}
sdkClient, err := openshell.NewClient(cfg)
if err != nil {
slog.Error("gateway client setup failed", "error", err)
slog.Error("SDK client setup failed", "error", err)
os.Exit(1)
}
defer gatewayClient.Close()
defer sdkClient.Close()

app := api.NewApp(gatewayClient, authMiddleware, *staticDir, strings.Split(*origins, ","))
app := api.NewApp(sdkClient, authMiddleware, *staticDir, strings.Split(*origins, ","))

addr := ":" + *port
slog.Info("openshell-dashboard BFF listening",
Expand Down
Loading