Skip to content

Commit f95167b

Browse files
maazghaniEItanya
andauthored
Implement namespace scoping for /api/agents (#1878)
Addresses #1812 and @EItanya's feedback to implement server side. If this PR lands, I have a [draft](maazghani#3) of a potential UI implementation. Adds `GET /api/agents/{namespace}` to work like `kubectl get agent -n kagent`, and leaving `GET /api/agents` as `kubectl get agent -A`. Re-use existing namespace validation pattern to return invalid namespaces as 400 bad request. * implemented `HandleListAgentsForNamespace` handler with tests * DRY'd out Agent + AgentHarness aggregation logic into a shared helper * registered route for `/{namespace}` in `server.go` * updated `ListAgents` to hit path-based `/api/agents/${namespace}` Ran e2e tests using kind-backed env successfully, and validated namespace-filtering works. --------- Signed-off-by: Maaz Ghani <maazghani@gmail.com> Co-authored-by: Eitan Yarmush <eitan.yarmush@solo.io>
1 parent 5d9419b commit f95167b

5 files changed

Lines changed: 168 additions & 26 deletions

File tree

.devcontainer/devcontainer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"build": {
44
"dockerfile": "Dockerfile",
55
"args": {
6-
"TOOLS_GO_VERSION": "1.26.1",
6+
"TOOLS_GO_VERSION": "1.26.2",
77
"TOOLS_NODE_VERSION": "24.13.0",
88
"TOOLS_UV_VERSION": "0.10.4",
99
"TOOLS_K9S_VERSION": "0.50.4",

go/api/client/agent.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,26 @@ package client
33
import (
44
"context"
55
"fmt"
6+
"net/url"
67

78
api "github.com/kagent-dev/kagent/go/api/httpapi"
89
"github.com/kagent-dev/kagent/go/api/v1alpha2"
910
)
1011

1112
// Agent defines the agent operations
1213
type Agent interface {
13-
ListAgents(ctx context.Context) (*api.StandardResponse[[]api.AgentResponse], error)
14+
ListAgents(ctx context.Context, opts ...ListAgentsOptions) (*api.StandardResponse[[]api.AgentResponse], error)
1415
CreateAgent(ctx context.Context, request *v1alpha2.Agent) (*api.StandardResponse[*v1alpha2.Agent], error)
1516
GetAgent(ctx context.Context, agentRef string) (*api.StandardResponse[*api.AgentResponse], error)
1617
UpdateAgent(ctx context.Context, request *v1alpha2.Agent) (*api.StandardResponse[*v1alpha2.Agent], error)
1718
DeleteAgent(ctx context.Context, agentRef string) error
1819
}
1920

21+
// ListAgentsOptions configures ListAgents requests.
22+
type ListAgentsOptions struct {
23+
Namespace string
24+
}
25+
2026
// agentClient handles agent-related requests
2127
type agentClient struct {
2228
client *BaseClient
@@ -27,14 +33,23 @@ func NewAgentClient(client *BaseClient) Agent {
2733
return &agentClient{client: client}
2834
}
2935

30-
// ListAgents lists all agents for a user
31-
func (c *agentClient) ListAgents(ctx context.Context) (*api.StandardResponse[[]api.AgentResponse], error) {
36+
// ListAgents lists all agents for a user. When Namespace is set, only agents in that namespace are returned.
37+
func (c *agentClient) ListAgents(ctx context.Context, opts ...ListAgentsOptions) (*api.StandardResponse[[]api.AgentResponse], error) {
38+
if len(opts) > 1 {
39+
return nil, fmt.Errorf("ListAgents accepts at most one options argument")
40+
}
41+
3242
userID := c.client.GetUserIDOrDefault("")
3343
if userID == "" {
3444
return nil, fmt.Errorf("userID is required")
3545
}
3646

37-
resp, err := c.client.Get(ctx, "/api/agents", userID)
47+
path := "/api/agents"
48+
if len(opts) > 0 && opts[0].Namespace != "" {
49+
path += "?namespace=" + url.QueryEscape(opts[0].Namespace)
50+
}
51+
52+
resp, err := c.client.Get(ctx, path, userID)
3853
if err != nil {
3954
return nil, err
4055
}

go/core/internal/httpserver/handlers/agents.go

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
apierrors "k8s.io/apimachinery/pkg/api/errors"
1919
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2020
"k8s.io/apimachinery/pkg/types"
21+
utilvalidation "k8s.io/apimachinery/pkg/util/validation"
2122
"sigs.k8s.io/controller-runtime/pkg/client"
2223
ctrllog "sigs.k8s.io/controller-runtime/pkg/log"
2324
)
@@ -32,35 +33,46 @@ func NewAgentsHandler(base *Base) *AgentsHandler {
3233
return &AgentsHandler{Base: base}
3334
}
3435

35-
// HandleListAgents handles GET /api/agents requests using database
36+
// HandleListAgents handles GET /api/agents requests using database.
37+
// Optional query param: namespace=<ns>.
3638
func (h *AgentsHandler) HandleListAgents(w ErrorResponseWriter, r *http.Request) {
3739
log := ctrllog.FromContext(r.Context()).WithName("agents-handler").WithValues("operation", "list-db")
3840

39-
if err := Check(h.Authorizer, r, auth.Resource{Type: "Agent"}); err != nil {
40-
w.RespondWithError(err)
41+
namespace := r.URL.Query().Get("namespace")
42+
if namespace == "" {
43+
h.handleListAgents(w, r, log)
4144
return
4245
}
4346

44-
agentList := &v1alpha2.AgentList{}
45-
if err := h.KubeClient.List(r.Context(), agentList); err != nil {
46-
w.RespondWithError(errors.NewInternalServerError("Failed to list Agents from Kubernetes", err))
47+
if strings.TrimSpace(namespace) != namespace {
48+
w.RespondWithError(errors.NewBadRequestError(
49+
fmt.Sprintf("invalid namespace %q: must not contain leading or trailing whitespace", namespace),
50+
nil,
51+
))
4752
return
4853
}
4954

50-
agentsWithID := make([]api.AgentResponse, 0)
51-
h.appendAgentResponses(r.Context(), log, agentObjects(agentList.Items), &agentsWithID)
55+
if errs := utilvalidation.IsDNS1123Label(namespace); len(errs) > 0 {
56+
w.RespondWithError(errors.NewBadRequestError(
57+
fmt.Sprintf("invalid namespace %q: %s", namespace, strings.Join(errs, "; ")),
58+
nil,
59+
))
60+
return
61+
}
5262

53-
harnessList := &v1alpha2.AgentHarnessList{}
54-
if err := h.KubeClient.List(r.Context(), harnessList); err != nil {
55-
w.RespondWithError(errors.NewInternalServerError("Failed to list AgentHarness resources from Kubernetes", err))
63+
h.handleListAgents(w, r, log.WithValues("namespace", namespace), client.InNamespace(namespace))
64+
}
65+
66+
func (h *AgentsHandler) handleListAgents(w ErrorResponseWriter, r *http.Request, log logr.Logger, opts ...client.ListOption) {
67+
if err := Check(h.Authorizer, r, auth.Resource{Type: "Agent"}); err != nil {
68+
w.RespondWithError(err)
5669
return
5770
}
58-
for i := range harnessList.Items {
59-
sb := &harnessList.Items[i]
60-
if sb.Spec.Backend != v1alpha2.AgentHarnessBackendOpenClaw && sb.Spec.Backend != v1alpha2.AgentHarnessBackendNemoClaw {
61-
continue
62-
}
63-
agentsWithID = append(agentsWithID, h.openshellAgentHarnessAgentResponse(r.Context(), log, sb))
71+
72+
agentsWithID, err := h.listAgentResponses(r.Context(), log, opts...)
73+
if err != nil {
74+
w.RespondWithError(err)
75+
return
6476
}
6577

6678
log.Info("Successfully listed agents", "count", len(agentsWithID))
@@ -91,6 +103,33 @@ func (h *AgentsHandler) HandleListSandboxAgents(w ErrorResponseWriter, r *http.R
91103
RespondWithJSON(w, http.StatusOK, data)
92104
}
93105

106+
// listAgentResponses fetches Agent and AgentHarness resources, applies the
107+
// provided list options (e.g. client.InNamespace), and returns the merged
108+
// slice of AgentResponse values.
109+
func (h *AgentsHandler) listAgentResponses(ctx context.Context, log logr.Logger, opts ...client.ListOption) ([]api.AgentResponse, error) {
110+
agentList := &v1alpha2.AgentList{}
111+
if err := h.KubeClient.List(ctx, agentList, opts...); err != nil {
112+
return nil, errors.NewInternalServerError("Failed to list Agents from Kubernetes", err)
113+
}
114+
115+
harnessList := &v1alpha2.AgentHarnessList{}
116+
if err := h.KubeClient.List(ctx, harnessList, opts...); err != nil {
117+
return nil, errors.NewInternalServerError("Failed to list AgentHarness resources from Kubernetes", err)
118+
}
119+
120+
result := make([]api.AgentResponse, 0, len(agentList.Items)+len(harnessList.Items))
121+
h.appendAgentResponses(ctx, log, agentObjects(agentList.Items), &result)
122+
for i := range harnessList.Items {
123+
sb := &harnessList.Items[i]
124+
if sb.Spec.Backend != v1alpha2.AgentHarnessBackendOpenClaw && sb.Spec.Backend != v1alpha2.AgentHarnessBackendNemoClaw {
125+
continue
126+
}
127+
result = append(result, h.openshellAgentHarnessAgentResponse(ctx, log, sb))
128+
}
129+
130+
return result, nil
131+
}
132+
94133
func (h *AgentsHandler) appendAgentResponses(
95134
ctx context.Context,
96135
log logr.Logger,

go/core/internal/httpserver/handlers/agents_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,92 @@ func TestHandleListAgents(t *testing.T) {
500500
}
501501
require.True(t, found)
502502
})
503+
504+
t.Run("filters Agent and AgentHarness rows by namespace query parameter", func(t *testing.T) {
505+
modelConfig := createTestModelConfig()
506+
agentDefault := createTestAgent("agent-in-default", modelConfig)
507+
agentOther := &v1alpha2.Agent{
508+
ObjectMeta: metav1.ObjectMeta{Name: "agent-in-other", Namespace: "other"},
509+
Spec: v1alpha2.AgentSpec{
510+
Type: v1alpha2.AgentType_Declarative,
511+
Declarative: &v1alpha2.DeclarativeAgentSpec{
512+
ModelConfig: modelConfig.Name,
513+
},
514+
},
515+
}
516+
harnessDefault := &v1alpha2.AgentHarness{
517+
ObjectMeta: metav1.ObjectMeta{Name: "harness-default", Namespace: "default"},
518+
Spec: v1alpha2.AgentHarnessSpec{
519+
Backend: v1alpha2.AgentHarnessBackendOpenClaw,
520+
ModelConfigRef: "test-model-config",
521+
},
522+
}
523+
harnessOther := &v1alpha2.AgentHarness{
524+
ObjectMeta: metav1.ObjectMeta{Name: "harness-other", Namespace: "other"},
525+
Spec: v1alpha2.AgentHarnessSpec{
526+
Backend: v1alpha2.AgentHarnessBackendOpenClaw,
527+
ModelConfigRef: "test-model-config",
528+
},
529+
}
530+
unsupportedHarnessDefault := &v1alpha2.AgentHarness{
531+
ObjectMeta: metav1.ObjectMeta{Name: "unsupported-harness", Namespace: "default"},
532+
Spec: v1alpha2.AgentHarnessSpec{
533+
Backend: v1alpha2.AgentHarnessBackendType("unsupported"),
534+
ModelConfigRef: "test-model-config",
535+
},
536+
}
537+
handler, _ := setupTestHandler(t, agentDefault, agentOther, harnessDefault, harnessOther, unsupportedHarnessDefault, modelConfig)
538+
539+
req := httptest.NewRequest("GET", "/api/agents?namespace=default", nil)
540+
req = setUser(req, "test-user")
541+
w := httptest.NewRecorder()
542+
543+
handler.HandleListAgents(&testErrorResponseWriter{w}, req)
544+
545+
require.Equal(t, http.StatusOK, w.Code)
546+
var response api.StandardResponse[[]api.AgentResponse]
547+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
548+
require.Len(t, response.Data, 2)
549+
550+
byName := make(map[string]api.AgentResponse, len(response.Data))
551+
for _, row := range response.Data {
552+
byName[row.Agent.Metadata.Name] = row
553+
require.Equal(t, "default", row.Agent.Metadata.Namespace)
554+
}
555+
require.Contains(t, byName, "agent-in-default")
556+
require.Contains(t, byName, "harness-default")
557+
require.NotContains(t, byName, "agent-in-other")
558+
require.NotContains(t, byName, "harness-other")
559+
require.NotContains(t, byName, "unsupported-harness")
560+
})
561+
562+
// Kubernetes namespace names must be DNS-1123 labels. Rejecting invalid input
563+
// before calling the Kubernetes client keeps the list path consistent with
564+
// other resource handlers and avoids surprising cross-namespace behavior.
565+
t.Run("returns 400 for invalid namespace query value", func(t *testing.T) {
566+
handler, _ := setupTestHandler(t)
567+
568+
req := httptest.NewRequest("GET", "/api/agents?namespace=INVALID_NS!", nil)
569+
req = setUser(req, "test-user")
570+
w := httptest.NewRecorder()
571+
572+
handler.HandleListAgents(&testErrorResponseWriter{w}, req)
573+
574+
require.Equal(t, http.StatusBadRequest, w.Code)
575+
})
576+
577+
t.Run("returns 400 for namespace query value with leading or trailing whitespace", func(t *testing.T) {
578+
handler, _ := setupTestHandler(t)
579+
580+
req := httptest.NewRequest("GET", "/api/agents?namespace=%20default", nil)
581+
req = setUser(req, "test-user")
582+
w := httptest.NewRecorder()
583+
584+
handler.HandleListAgents(&testErrorResponseWriter{w}, req)
585+
586+
require.Equal(t, http.StatusBadRequest, w.Code)
587+
require.Contains(t, w.Body.String(), "must not contain leading or trailing whitespace")
588+
})
503589
}
504590

505591
func TestHandleListSandboxAgents(t *testing.T) {

ui/src/app/actions/agents.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -562,12 +562,14 @@ export async function createAgent(agentConfig: AgentFormData, update: boolean =
562562
}
563563

564564
/**
565-
* Gets all agents
566-
* @returns A promise with all agents
565+
* Gets all agents, optionally filtered by namespace.
566+
* @param opts.namespace When set, calls `/agents?namespace=<ns>`; otherwise calls `/agents`.
567+
* @returns A promise with the matching agents
567568
*/
568-
export async function getAgents(): Promise<BaseResponse<AgentResponse[]>> {
569+
export async function getAgents(opts: { namespace?: string } = {}): Promise<BaseResponse<AgentResponse[]>> {
569570
try {
570-
const { data } = await fetchApi<BaseResponse<AgentResponse[]>>(`/agents`);
571+
const path = opts.namespace ? `/agents?namespace=${encodeURIComponent(opts.namespace)}` : `/agents`;
572+
const { data } = await fetchApi<BaseResponse<AgentResponse[]>>(path);
571573

572574
const sortedData = data?.sort((a, b) => {
573575
const aRef = k8sRefUtils.toRef(a.agent.metadata.namespace || "", a.agent.metadata.name);

0 commit comments

Comments
 (0)