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
2 changes: 2 additions & 0 deletions apps/stackpanel-go/cmd/cli/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ func runAgent(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err)
}

cfg.Version = Version

// Only override when the user explicitly passed --port; both the flag
// default and config.Load default to 9876, so we can't distinguish
// "user typed --port 9876" from "user omitted --port". This means
Expand Down
4 changes: 4 additions & 0 deletions apps/stackpanel-go/internal/agent/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ type Config struct {
// Authentication token (from STACKPANEL_AUTH_TOKEN)
AuthToken string

// Version is the agent build version reported by
// stackpanel.agent.v1.AgentService/GetAgentInfo.
Version string

// TestPairingToken enables deterministic pairing for integration tests.
// Normally the agent generates a random signing key on startup, making
// the JWT pairing token unpredictable. This field pins both the signing
Expand Down
158 changes: 158 additions & 0 deletions apps/stackpanel-go/internal/agent/project/connect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package project

import (
"context"
"errors"
"fmt"
"path/filepath"
"sort"

"connectrpc.com/connect"
"google.golang.org/protobuf/types/known/timestamppb"

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/pkg/userconfig"
)

var _ agentv1connect.ProjectServiceHandler = (*ConnectHandler)(nil)

// ConnectHandler serves stackpanel.agent.v1.ProjectService over the
// user-level project registry.
//
// Projects are selected per request (ADR 0005), so this service only
// maintains the registry; it never changes which project other requests
// run against.
type ConnectHandler struct {
registry *userconfig.Manager

// checkListed reports whether a listed project is still usable; it runs
// for every project on every list, so it must stay cheap.
checkListed func(path string) error

// checkAdded fully validates a project before it is registered.
checkAdded func(path string) error
}

// NewConnectHandler returns a ProjectService backed by the manager's registry.
func NewConnectHandler(m *Manager) *ConnectHandler {
return &ConnectHandler{
registry: m.UserConfigManager(),
checkListed: QuickValidate,
checkAdded: ValidateProject,
}
}

func (h *ConnectHandler) ListProjects(
_ context.Context,
_ *connect.Request[agentv1.ListProjectsRequest],
) (*connect.Response[agentv1.ListProjectsResponse], error) {
projects := h.registry.ListProjects()
sort.SliceStable(projects, func(i, j int) bool {
return projects[i].LastOpened.After(projects[j].LastOpened)
})

res := &agentv1.ListProjectsResponse{
Projects: make([]*agentv1.Project, 0, len(projects)),
}
for _, p := range projects {
res.Projects = append(res.Projects, h.toProto(p))
}
if def := h.registry.GetDefaultProject(); def != nil {
res.DefaultProjectId = projectID(*def)
}
return connect.NewResponse(res), nil
}

func (h *ConnectHandler) AddProject(
_ context.Context,
req *connect.Request[agentv1.AddProjectRequest],
) (*connect.Response[agentv1.AddProjectResponse], error) {
path := req.Msg.GetPath()
if !filepath.IsAbs(path) {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path must be absolute: %q", path))
}
path = filepath.Clean(path)
if err := h.checkAdded(path); err != nil {
return nil, connect.NewError(connect.CodeInvalidArgument, err)
}

p, err := h.registry.AddProject(path, "")
if err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
return connect.NewResponse(&agentv1.AddProjectResponse{Project: h.toProto(*p)}), nil
}

func (h *ConnectHandler) RemoveProject(
_ context.Context,
req *connect.Request[agentv1.RemoveProjectRequest],
) (*connect.Response[agentv1.RemoveProjectResponse], error) {
p, err := h.lookup(req.Msg.GetProjectId())
if err != nil {
return nil, err
}
if err := h.registry.RemoveProject(p.Path); err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
return connect.NewResponse(&agentv1.RemoveProjectResponse{}), nil
}

func (h *ConnectHandler) SetDefaultProject(
_ context.Context,
req *connect.Request[agentv1.SetDefaultProjectRequest],
) (*connect.Response[agentv1.SetDefaultProjectResponse], error) {
if req.Msg.GetProjectId() == "" {
if err := h.registry.ClearDefaultProject(); err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
return connect.NewResponse(&agentv1.SetDefaultProjectResponse{}), nil
}

p, err := h.lookup(req.Msg.GetProjectId())
if err != nil {
return nil, err
}
if err := h.registry.SetDefaultProject(p.Path); err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
return connect.NewResponse(&agentv1.SetDefaultProjectResponse{}), nil
}

func (h *ConnectHandler) lookup(id string) (*userconfig.Project, error) {
if id == "" {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project_id is required"))
}
p := h.registry.GetProjectByID(id)
if p == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unknown project %q", id))
}
return p, nil
}

func (h *ConnectHandler) toProto(p userconfig.Project) *agentv1.Project {
out := &agentv1.Project{
Id: projectID(p),
Name: p.Name,
Path: p.Path,
Valid: true,
}
if !p.LastOpened.IsZero() {
out.LastOpenedAt = timestamppb.New(p.LastOpened)
}
if err := h.checkListed(p.Path); err != nil {
out.Valid = false
out.InvalidReason = err.Error()
}
return out
}

// projectID is the identifier clients send in the Stackpanel-Project header.
// Older registry entries have no stored ID, so it falls back to the one
// derived from the path, matching userconfig.Manager.GetProjectByID.
func projectID(p userconfig.Project) string {
if p.ID != "" {
return p.ID
}
return userconfig.GenerateProjectID(p.Path)
}
150 changes: 150 additions & 0 deletions apps/stackpanel-go/internal/agent/project/connect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package project

import (
"context"
"errors"
"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/pkg/userconfig"
)

var errGone = errors.New("project not found")

// newTestClient serves a ProjectService over a registry in a temp config dir.
// Validity is decided by the `invalid` set so tests don't depend on the real
// validators (which reject temp dirs as suspicious and evaluate Nix).
func newTestClient(t *testing.T, invalid map[string]bool) (agentv1connect.ProjectServiceClient, *userconfig.Manager) {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
reg, err := userconfig.NewManager()
if err != nil {
t.Fatalf("userconfig.NewManager: %v", err)
}

check := func(path string) error {
if invalid[path] {
return errGone
}
return nil
}
mux := http.NewServeMux()
mux.Handle(agentv1connect.NewProjectServiceHandler(&ConnectHandler{
registry: reg,
checkListed: check,
checkAdded: check,
}))
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return agentv1connect.NewProjectServiceClient(srv.Client(), srv.URL), reg
}

func TestListProjectsReportsValidityAndDefault(t *testing.T) {
good := filepath.Join(t.TempDir(), "good")
gone := filepath.Join(t.TempDir(), "gone")
client, reg := newTestClient(t, map[string]bool{gone: true})

goodProject, err := reg.AddProject(good, "")
if err != nil {
t.Fatal(err)
}
if _, err := reg.AddProject(gone, ""); err != nil {
t.Fatal(err)
}
if err := reg.SetDefaultProject(goodProject.Path); err != nil {
t.Fatal(err)
}

res, err := client.ListProjects(context.Background(), connect.NewRequest(&agentv1.ListProjectsRequest{}))
if err != nil {
t.Fatalf("ListProjects: %v", err)
}

byPath := map[string]*agentv1.Project{}
for _, p := range res.Msg.GetProjects() {
byPath[p.GetPath()] = p
}
if len(byPath) != 2 {
t.Fatalf("got %d projects, want 2", len(byPath))
}
if p := byPath[goodProject.Path]; !p.GetValid() || p.GetInvalidReason() != "" {
t.Errorf("good project: valid=%v reason=%q, want valid", p.GetValid(), p.GetInvalidReason())
}
if p := byPath[filepath.Clean(gone)]; p.GetValid() || p.GetInvalidReason() != errGone.Error() {
t.Errorf("gone project: valid=%v reason=%q, want invalid with reason", p.GetValid(), p.GetInvalidReason())
}
if got, want := res.Msg.GetDefaultProjectId(), byPath[goodProject.Path].GetId(); got != want {
t.Errorf("default project id = %q, want %q", got, want)
}
}

func TestAddProjectValidatesBeforeRegistering(t *testing.T) {
bad := filepath.Join(t.TempDir(), "bad")
client, reg := newTestClient(t, map[string]bool{bad: true})
ctx := context.Background()

for _, path := range []string{"relative/dir", bad} {
_, err := client.AddProject(ctx, connect.NewRequest(&agentv1.AddProjectRequest{Path: path}))
if connect.CodeOf(err) != connect.CodeInvalidArgument {
t.Errorf("AddProject(%q) code = %v, want InvalidArgument", path, connect.CodeOf(err))
}
}
if n := len(reg.ListProjects()); n != 0 {
t.Fatalf("rejected paths were registered: %d projects", n)
}

dir := filepath.Join(t.TempDir(), "app")
first, err := client.AddProject(ctx, connect.NewRequest(&agentv1.AddProjectRequest{Path: dir}))
if err != nil {
t.Fatalf("AddProject: %v", err)
}
again, err := client.AddProject(ctx, connect.NewRequest(&agentv1.AddProjectRequest{Path: dir}))
if err != nil {
t.Fatalf("AddProject again: %v", err)
}
if first.Msg.GetProject().GetId() != again.Msg.GetProject().GetId() || len(reg.ListProjects()) != 1 {
t.Errorf("re-adding a registered path should return the existing project")
}
}

func TestDefaultAndRemoveResolveProjectsByID(t *testing.T) {
client, reg := newTestClient(t, nil)
ctx := context.Background()

p, err := reg.AddProject(filepath.Join(t.TempDir(), "app"), "")
if err != nil {
t.Fatal(err)
}
id := projectID(*p)

if _, err := client.SetDefaultProject(ctx, connect.NewRequest(&agentv1.SetDefaultProjectRequest{ProjectId: id})); err != nil {
t.Fatalf("SetDefaultProject: %v", err)
}
if got := reg.GetDefaultProjectPath(); got != p.Path {
t.Errorf("default path = %q, want %q", got, p.Path)
}
if _, err := client.SetDefaultProject(ctx, connect.NewRequest(&agentv1.SetDefaultProjectRequest{})); err != nil {
t.Fatalf("clear default: %v", err)
}
if reg.GetDefaultProject() != nil {
t.Error("default project was not cleared")
}

_, err = client.RemoveProject(ctx, connect.NewRequest(&agentv1.RemoveProjectRequest{ProjectId: "no-such-id"}))
if connect.CodeOf(err) != connect.CodeNotFound {
t.Errorf("RemoveProject(unknown) code = %v, want NotFound", connect.CodeOf(err))
}
if _, err := client.RemoveProject(ctx, connect.NewRequest(&agentv1.RemoveProjectRequest{ProjectId: id})); err != nil {
t.Fatalf("RemoveProject: %v", err)
}
if n := len(reg.ListProjects()); n != 0 {
t.Errorf("project still registered after RemoveProject: %d", n)
}
}
Loading
Loading