diff --git a/_test/nexlet_inmem/inmemagent.go b/_test/nexlet_inmem/inmemagent.go index 97841994..fe81a978 100644 --- a/_test/nexlet_inmem/inmemagent.go +++ b/_test/nexlet_inmem/inmemagent.go @@ -263,34 +263,34 @@ func (a *InMemAgent) QueryWorkloads(namespace string, filter []string) (*models. a.Workloads.RLock() defer a.Workloads.RUnlock() - workloads, ok := a.Workloads.State[namespace] - if !ok { - a.Logger.Debug("namespace not found", slog.String("namespace", namespace)) - return &models.AgentListWorkloadsResponse{}, nil - } - resp := models.AgentListWorkloadsResponse{} - for _, workload := range workloads { - resp = append(resp, models.WorkloadSummary{ - Id: workload.id, - Name: workload.name, - Runtime: time.Since(workload.startTime).String(), - StartTime: workload.startTime.Format(time.RFC3339), - WorkloadType: a.WorkloadType, - WorkloadState: models.WorkloadStateRunning, - WorkloadLifecycle: "service", - Metadata: map[string]string{"extra": "metadata"}, - }) - } - if len(filter) > 0 { - filteredResp := models.AgentListWorkloadsResponse{} - for _, workload := range resp { - if slices.Contains(filter, workload.Id) { - filteredResp = append(filteredResp, workload) + // The system namespace is administrative: a query for it returns + // workloads across every namespace. Any other namespace only returns + // workloads that actually live under that map key. When filter is + // non-empty, only workloads whose id or name matches one of the + // filter entries are returned. + for mapNS, workloads := range a.Workloads.State { + if namespace != models.SystemNamespace && namespace != mapNS { + continue + } + workloadNS := mapNS + for _, workload := range workloads { + if len(filter) > 0 && !slices.Contains(filter, workload.id) && !slices.Contains(filter, workload.name) { + continue } + resp = append(resp, models.WorkloadSummary{ + Id: workload.id, + Namespace: &workloadNS, + Name: workload.name, + Runtime: time.Since(workload.startTime).String(), + StartTime: workload.startTime.Format(time.RFC3339), + WorkloadType: a.WorkloadType, + WorkloadState: models.WorkloadStateRunning, + WorkloadLifecycle: "service", + Metadata: map[string]string{"extra": "metadata"}, + }) } - resp = filteredResp } a.Logger.Debug("QueryWorkloads successful", slog.String("namespace", namespace), slog.String("filter", strings.Join(filter, ","))) diff --git a/agents/native/native.go b/agents/native/native.go index d3ddaf58..2e97ba4d 100644 --- a/agents/native/native.go +++ b/agents/native/native.go @@ -178,7 +178,7 @@ func (a *NativeAgent) GetWorkload(workloadId, targetXkey string) (*models.StartW } func (a *NativeAgent) QueryWorkloads(namespace string, filter []string) (*models.AgentListWorkloadsResponse, error) { - return a.state.GetNamespaceWorkloadList(namespace) + return a.state.GetNamespaceWorkloadList(namespace, filter) } func (a *NativeAgent) SetLameduck(before time.Duration) error { diff --git a/agents/native/state.go b/agents/native/state.go index 505ef6c5..56f4fdcc 100644 --- a/agents/native/state.go +++ b/agents/native/state.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "os/exec" + "slices" "strconv" "strings" "sync" @@ -84,29 +85,41 @@ func (n *nexletState) WorkloadCount() int { return total } -func (n *nexletState) GetNamespaceWorkloadList(ns string) (*models.AgentListWorkloadsResponse, error) { +func (n *nexletState) GetNamespaceWorkloadList(ns string, filter []string) (*models.AgentListWorkloadsResponse, error) { n.Lock() defer n.Unlock() ret := new(models.AgentListWorkloadsResponse) - namespace, ok := n.workloads[ns] - if !ok || len(namespace) == 0 { - return ret, nil - } - for id, w := range namespace { - ws := models.WorkloadSummary{ - Id: id, - Metadata: map[string]string{}, - Name: w.StartRequest.Name, - Runtime: "--", - StartTime: w.StartedAt.Format(time.RFC3339), - WorkloadLifecycle: string(w.StartRequest.WorkloadLifecycle), - WorkloadState: w.GetState(), - WorkloadType: NEXLET_REGISTER_TYPE, - Tags: w.StartRequest.Tags, + // The system namespace is administrative: a query for it returns + // workloads across every namespace. Any other namespace only returns + // workloads that actually live under that map key. + // + // When filter is non-empty, only workloads whose id or name matches + // one of the filter entries are returned. + for mapNS, processes := range n.workloads { + if ns != models.SystemNamespace && ns != mapNS { + continue + } + workloadNS := mapNS + for id, w := range processes { + if len(filter) > 0 && !slices.Contains(filter, id) && !slices.Contains(filter, w.StartRequest.Name) { + continue + } + ws := models.WorkloadSummary{ + Id: id, + Namespace: &workloadNS, + Metadata: map[string]string{}, + Name: w.StartRequest.Name, + Runtime: "--", + StartTime: w.StartedAt.Format(time.RFC3339), + WorkloadLifecycle: string(w.StartRequest.WorkloadLifecycle), + WorkloadState: w.GetState(), + WorkloadType: NEXLET_REGISTER_TYPE, + Tags: w.StartRequest.Tags, + } + *ret = append(*ret, ws) } - *ret = append(*ret, ws) } return ret, nil diff --git a/agents/native/state_test.go b/agents/native/state_test.go index 9b0d7780..3a09e601 100644 --- a/agents/native/state_test.go +++ b/agents/native/state_test.go @@ -116,7 +116,7 @@ func TestNexletState(t *testing.T) { _, ok = ns.Exists("noexist") be.False(t, ok) - list, err := ns.GetNamespaceWorkloadList("derp") + list, err := ns.GetNamespaceWorkloadList("derp", nil) be.NilErr(t, err) be.Equal(t, 1, len(*list)) @@ -193,3 +193,111 @@ func TestAddWorkloadWithInvalidUri(t *testing.T) { _, ok := ns.Exists(workloadID) be.False(t, ok) } + +// TestGetNamespaceWorkloadListSystemAndFilter exercises GetNamespaceWorkloadList +// without spawning real processes. It verifies three things: +// 1. The system namespace is administrative and returns workloads across +// every stored namespace, while a user namespace returns only its own. +// 2. The Namespace field on each WorkloadSummary is populated with the +// workload's actual owning namespace (sourced from the state map key). +// 3. The filter argument matches against both workload id and workload +// name; an empty filter returns everything. +func TestGetNamespaceWorkloadListSystemAndFilter(t *testing.T) { + ns := nexletState{ + Mutex: sync.Mutex{}, + ctx: context.Background(), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + status: models.AgentStateRunning, + workloads: map[string]NativeProcesses{}, + } + + // Seed three workloads across two user namespaces directly via the + // state map. AddWorkload spawns a real process, which is unnecessary + // for a pure listing test. + seed := func(namespace, id, name string) { + if _, ok := ns.workloads[namespace]; !ok { + ns.workloads[namespace] = make(NativeProcesses) + } + ns.workloads[namespace][id] = &NativeProcess{ + Name: name, + StartRequest: models.StartWorkloadRequest{ + Name: name, + Namespace: namespace, + WorkloadLifecycle: "service", + }, + StartedAt: time.Now(), + State: models.WorkloadStateRunning, + } + } + seed("alpha", "id-a1", "alpha-one") + seed("alpha", "id-a2", "alpha-two") + seed("beta", "id-b1", "beta-one") + + namespaceOf := func(ws models.WorkloadSummary) string { + t.Helper() + if ws.Namespace == nil { + t.Fatalf("workload %q is missing Namespace field", ws.Id) + } + return *ws.Namespace + } + + t.Run("user namespace returns only its own workloads", func(t *testing.T) { + list, err := ns.GetNamespaceWorkloadList("alpha", nil) + be.NilErr(t, err) + be.Equal(t, 2, len(*list)) + for _, ws := range *list { + be.Equal(t, "alpha", namespaceOf(ws)) + } + }) + + t.Run("unknown namespace returns empty", func(t *testing.T) { + list, err := ns.GetNamespaceWorkloadList("gamma", nil) + be.NilErr(t, err) + be.Equal(t, 0, len(*list)) + }) + + t.Run("system namespace returns workloads from every namespace", func(t *testing.T) { + list, err := ns.GetNamespaceWorkloadList(models.SystemNamespace, nil) + be.NilErr(t, err) + be.Equal(t, 3, len(*list)) + + seen := map[string]string{} + for _, ws := range *list { + seen[ws.Id] = namespaceOf(ws) + } + be.Equal(t, "alpha", seen["id-a1"]) + be.Equal(t, "alpha", seen["id-a2"]) + be.Equal(t, "beta", seen["id-b1"]) + }) + + t.Run("filter by id matches across system list", func(t *testing.T) { + list, err := ns.GetNamespaceWorkloadList(models.SystemNamespace, []string{"id-a2"}) + be.NilErr(t, err) + be.Equal(t, 1, len(*list)) + be.Equal(t, "id-a2", (*list)[0].Id) + be.Equal(t, "alpha", namespaceOf((*list)[0])) + }) + + t.Run("filter by name matches across system list", func(t *testing.T) { + list, err := ns.GetNamespaceWorkloadList(models.SystemNamespace, []string{"beta-one"}) + be.NilErr(t, err) + be.Equal(t, 1, len(*list)) + be.Equal(t, "id-b1", (*list)[0].Id) + be.Equal(t, "beta", namespaceOf((*list)[0])) + }) + + t.Run("filter mixes id and name and is scoped by namespace", func(t *testing.T) { + list, err := ns.GetNamespaceWorkloadList("alpha", []string{"id-a1", "alpha-two", "beta-one"}) + be.NilErr(t, err) + // beta-one is filtered out because the query is scoped to "alpha", + // not system; id-a1 matches by id, alpha-two matches by name. + be.Equal(t, 2, len(*list)) + ids := map[string]bool{} + for _, ws := range *list { + ids[ws.Id] = true + be.Equal(t, "alpha", namespaceOf(ws)) + } + be.True(t, ids["id-a1"]) + be.True(t, ids["id-a2"]) + }) +} diff --git a/cmd/nex/workloads.go b/cmd/nex/workloads.go index 7c3dcebd..0f17ae54 100644 --- a/cmd/nex/workloads.go +++ b/cmd/nex/workloads.go @@ -266,9 +266,15 @@ func (r *ListWorkload) Run(ctx context.Context, globals *Globals) error { tW.Style().Title.Align = text.AlignCenter tW.Style().Format.Header = text.FormatDefault tW.SetTitle("Running Workloads - " + globals.Namespace) - if r.ShowMetadata { + showNamespace := globals.Namespace == models.SystemNamespace + switch { + case showNamespace && r.ShowMetadata: + tW.AppendHeader(table.Row{"Id", "Name", "Namespace", "Start Time", "Execution Time", "Type", "Lifecycle", "State", "Metadata", "Tags"}) + case showNamespace: + tW.AppendHeader(table.Row{"Id", "Name", "Namespace", "Start Time", "Execution Time", "Type", "Lifecycle", "State"}) + case r.ShowMetadata: tW.AppendHeader(table.Row{"Id", "Name", "Start Time", "Execution Time", "Type", "Lifecycle", "State", "Metadata", "Tags"}) - } else { + default: tW.AppendHeader(table.Row{"Id", "Name", "Start Time", "Execution Time", "Type", "Lifecycle", "State"}) } for _, agentResponse := range resp { @@ -278,16 +284,30 @@ func (r *ListWorkload) Run(ctx context.Context, globals *Globals) error { rt = "--" } + var meta string if r.ShowMetadata { - meta := "--" + meta = "--" if workload.Metadata != nil { metaB, err := json.Marshal(workload.Metadata) if err == nil { meta = string(metaB) } } + } + + wlNS := "--" + if workload.Namespace != nil { + wlNS = *workload.Namespace + } + + switch { + case showNamespace && r.ShowMetadata: + tW.AppendRow(table.Row{workload.Id, workload.Name, wlNS, workload.StartTime, rt, workload.WorkloadType, workload.WorkloadLifecycle, workload.WorkloadState, meta, workload.Tags}) + case showNamespace: + tW.AppendRow(table.Row{workload.Id, workload.Name, wlNS, workload.StartTime, rt, workload.WorkloadType, workload.WorkloadLifecycle, workload.WorkloadState}) + case r.ShowMetadata: tW.AppendRow(table.Row{workload.Id, workload.Name, workload.StartTime, rt, workload.WorkloadType, workload.WorkloadLifecycle, workload.WorkloadState, meta, workload.Tags}) - } else { + default: tW.AppendRow(table.Row{workload.Id, workload.Name, workload.StartTime, rt, workload.WorkloadType, workload.WorkloadLifecycle, workload.WorkloadState}) } workloads++ diff --git a/models/api_shared.go b/models/api_shared.go index ff8c0018..7e750595 100644 --- a/models/api_shared.go +++ b/models/api_shared.go @@ -644,6 +644,9 @@ type WorkloadSummary struct { // The name of the workload Name string `json:"name"` + // The namespace the workload belongs to + Namespace *string `json:"namespace,omitempty"` + // The runtime of the workload Runtime string `json:"runtime"` diff --git a/models/schema/shared-workload-summary.json b/models/schema/shared-workload-summary.json index 992f026a..811f676c 100644 --- a/models/schema/shared-workload-summary.json +++ b/models/schema/shared-workload-summary.json @@ -8,6 +8,10 @@ "type": "string", "description": "The unique identifier of the workload" }, + "namespace": { + "type": "string", + "description": "The namespace the workload belongs to" + }, "name": { "type": "string", "description": "The name of the workload"