Skip to content
Merged
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
37 changes: 27 additions & 10 deletions apps/stackpanel-go/internal/agent/server/connect_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ import (
// allowlist stay at the HTTP layer; authentication is part of the Connect
// interceptor chain so no procedure can be mounted without it.
func (s *Server) mountV1(mux *http.ServeMux) {
routes := newV1Routes(s.config.Version, s.isValidToken, project.NewConnectHandler(s.projectMgr))
routes := newV1Routes(v1Deps{
version: s.config.Version,
validToken: s.isValidToken,
projects: project.NewConnectHandler(s.projectMgr),
shell: newShellService(),
scope: projectInterceptor{resolve: s.resolveProjectRoot, runtimes: s.runtimes},
})
for _, r := range routes {
mux.Handle(r.path, s.withCORS(s.withAllowedOrigin(r.handler.ServeHTTP)))
}
Expand All @@ -34,26 +40,37 @@ type v1Route struct {
// Connect reads and decompresses the body before interceptors run, so the limit must apply before auth.
const v1ReadMaxBytes = 2 << 20 // same 2 MiB cap as REST request bodies

// v1Deps are the service implementations and policies newV1Routes mounts.
type v1Deps struct {
version string
validToken func(token string) bool
projects agentv1connect.ProjectServiceHandler
shell agentv1connect.ShellServiceHandler
// scope resolves the request's project for project-scoped services.
scope connect.Interceptor
}

// newV1Routes builds every v1 service behind one interceptor chain and
// reports exactly the procedures they serve through GetAgentInfo.
func newV1Routes(
version string,
validToken func(token string) bool,
projects agentv1connect.ProjectServiceHandler,
) []v1Route {
func newV1Routes(d v1Deps) []v1Route {
opts := connect.WithHandlerOptions(
connect.WithReadMaxBytes(v1ReadMaxBytes),
connect.WithInterceptors(authInterceptor{valid: validToken}),
connect.WithInterceptors(authInterceptor{valid: d.validToken}),
connect.WithRecover(recoverRPC),
)
info := &agentInfoService{version: version}
// Project-scoped services also resolve the request's project. Auth stays
// the outermost interceptor, so unauthenticated calls never start a
// project runtime.
scoped := connect.WithHandlerOptions(opts, connect.WithInterceptors(d.scope))
info := &agentInfoService{version: d.version}

routes := make([]v1Route, 0, 2)
routes := make([]v1Route, 0, 3)
add := func(path string, handler http.Handler) {
routes = append(routes, v1Route{path: path, handler: handler})
}
add(agentv1connect.NewAgentServiceHandler(info, opts))
add(agentv1connect.NewProjectServiceHandler(projects, opts))
add(agentv1connect.NewProjectServiceHandler(d.projects, opts))
add(agentv1connect.NewShellServiceHandler(d.shell, scoped))

info.procedures = procedurePaths(routes)
return routes
Expand Down
101 changes: 101 additions & 0 deletions apps/stackpanel-go/internal/agent/server/connect_v1_project.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package server

import (
"context"
"errors"
"fmt"
"net/http"
"strings"

"connectrpc.com/connect"

"github.com/darkmatter/stackpanel/stackpanel-go/internal/agent/project"
)

// projectHeader names the project a request runs against (ADR 0005). It is
// the header the Studio already sends and the CORS policy already allows.
const projectHeader = "X-Stackpanel-Project"

type runtimeKey struct{}

// runtimeFor returns the project runtime attached by projectInterceptor.
// Only project-scoped services are mounted behind that interceptor.
func runtimeFor(ctx context.Context) *projectRuntime {
rt, _ := ctx.Value(runtimeKey{}).(*projectRuntime)
return rt
}

// projectInterceptor resolves the project for project-scoped v1 services and
// attaches its runtime to the request context. Runtimes come from the same
// registry the legacy handlers use, so both act on shared instances.
type projectInterceptor struct {
// resolve maps the request's project header (possibly empty) to a
// project root, returning a Connect error when it cannot.
resolve func(projectID string) (string, error)
runtimes *runtimeRegistry
}

func (p projectInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
ctx, err := p.attach(ctx, req.Header())
if err != nil {
return nil, err
}
return next(ctx, req)
}
}

func (p projectInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {
return next
}

func (p projectInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
ctx, err := p.attach(ctx, conn.RequestHeader())
if err != nil {
return err
}
return next(ctx, conn)
}
}

func (p projectInterceptor) attach(ctx context.Context, header http.Header) (context.Context, error) {
root, err := p.resolve(strings.TrimSpace(header.Get(projectHeader)))
if err != nil {
return nil, err
}
rt, err := p.runtimes.get(root)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("start project runtime: %w", err))
}
return context.WithValue(ctx, runtimeKey{}, rt), nil
}

// resolveProjectRoot maps a request's project ID to a project root. Without
// an ID it uses the agent's current project, then the registry's default:
// current comes first so header-less v1 calls act on the same project as the
// legacy REST routes during the migration (ADR 0005).
func (s *Server) resolveProjectRoot(projectID string) (string, error) {
registry := s.projectMgr.UserConfigManager()

if projectID != "" {
p := registry.GetProjectByID(projectID)
if p == nil {
return "", connect.NewError(connect.CodeNotFound, fmt.Errorf("unknown project %q", projectID))
}
if err := project.QuickValidate(p.Path); err != nil {
return "", connect.NewError(connect.CodeFailedPrecondition,
fmt.Errorf("project %q is not usable: %w", projectID, err))
}
return p.Path, nil
}

if root := s.config.ProjectRoot; root != "" {
return root, nil
}
if def := registry.GetDefaultProject(); def != nil {
return def.Path, nil
}
return "", connect.NewError(connect.CodeFailedPrecondition,
errors.New("no project selected: send "+projectHeader+" or open a project"))
}
110 changes: 110 additions & 0 deletions apps/stackpanel-go/internal/agent/server/connect_v1_project_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package server

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"

"connectrpc.com/connect"

agentv1 "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1"
"github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect"
"github.com/darkmatter/stackpanel/stackpanel-go/internal/agent/config"
"github.com/darkmatter/stackpanel/stackpanel-go/internal/agent/project"
)

// TestProjectInterceptorRunsRequestsAgainstTheResolvedProject mounts
// ShellService behind the project interceptor. Each fake runtime reports its
// own root as a changed file, so the response shows which project ran.
func TestProjectInterceptorRunsRequestsAgainstTheResolvedProject(t *testing.T) {
registry, _ := newTestRegistry()
scope := projectInterceptor{
runtimes: registry,
resolve: func(id string) (string, error) {
switch id {
case "":
return "/p/current", nil
case "other":
return "/p/other", nil
default:
return "", connect.NewError(connect.CodeNotFound, fmt.Errorf("unknown project %q", id))
}
},
}
shells := map[string]devshell{}
svc := shellService{shellFor: func(ctx context.Context) devshell {
root := runtimeFor(ctx).root
if shells[root] == nil {
shells[root] = &fakeShell{status: ShellStatus{ChangedFiles: []string{root}}}
}
return shells[root]
}}

mux := http.NewServeMux()
mux.Handle(agentv1connect.NewShellServiceHandler(svc, connect.WithInterceptors(scope)))
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
client := agentv1connect.NewShellServiceClient(srv.Client(), srv.URL)

status := func(projectID string) (string, error) {
req := connect.NewRequest(&agentv1.GetShellStatusRequest{})
if projectID != "" {
req.Header().Set(projectHeader, projectID)
}
res, err := client.GetShellStatus(context.Background(), req)
if err != nil {
return "", err
}
return res.Msg.GetChangedFiles()[0], nil
}

if got, err := status(""); err != nil || got != "/p/current" {
t.Errorf("no header: got %q, %v; want the current project", got, err)
}
if got, err := status("other"); err != nil || got != "/p/other" {
t.Errorf("header other: got %q, %v; want /p/other", got, err)
}
if _, err := status("missing"); connect.CodeOf(err) != connect.CodeNotFound {
t.Errorf("unknown project: code %v, want NotFound", connect.CodeOf(err))
}
if n := len(registry.runtimes); n != 2 {
t.Errorf("registry holds %d runtimes, want one per resolved project", n)
}
}

func TestResolveProjectRootFallsBackToCurrentThenDefault(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
mgr, err := project.NewManager(t.TempDir())
if err != nil {
t.Fatal(err)
}
registry := mgr.UserConfigManager()
def, err := registry.AddProject(filepath.Join(t.TempDir(), "default"), "")
if err != nil {
t.Fatal(err)
}
s := &Server{config: &config.Config{}, projectMgr: mgr}

if _, err := s.resolveProjectRoot(""); connect.CodeOf(err) != connect.CodeFailedPrecondition {
t.Errorf("nothing selected: code %v, want FailedPrecondition", connect.CodeOf(err))
}
if _, err := s.resolveProjectRoot("no-such-id"); connect.CodeOf(err) != connect.CodeNotFound {
t.Errorf("unknown id: code %v, want NotFound", connect.CodeOf(err))
}

if err := registry.SetDefaultProject(def.Path); err != nil {
t.Fatal(err)
}
if got, err := s.resolveProjectRoot(""); err != nil || got != def.Path {
t.Errorf("no header, default set: got %q, %v; want %q", got, err, def.Path)
}

s.config.ProjectRoot = "/p/current"
if got, err := s.resolveProjectRoot(""); err != nil || got != "/p/current" {
t.Errorf("no header, current set: got %q, %v; want the current project first", got, err)
}
}
Loading
Loading