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
15 changes: 15 additions & 0 deletions cmd/agents_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,21 @@ func printAgentDetail(out io.Writer, a *apiclient.AgentDetail, hasRoute *bool) {
rows.add("UID", strDeref(a.Metadata.UID, "N/A"))
rows.flush(out)

if len(a.Contexts) > 0 {
section(out, "Contexts")
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, " NAME\tTYPE\tMOUNT PATH\tACCESS\tCLAIM")
for _, c := range a.Contexts {
access := "Read-write"
if c.ReadOnly {
access = "Read-only"
}
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n",
c.Name, orDefault(c.Type, "-"), c.MountPath, access, orDefault(c.ClaimName, "-"))
}
_ = w.Flush()
}

// Endpoint (Service info, when present).
//
// The external route is reported here, beside the Service, because both
Expand Down
12 changes: 12 additions & 0 deletions cmd/agents_get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const agentDetailBody = `{
},
"workloadType": "deployment",
"readyStatus": "Ready",
"contexts": [{
"name": "research", "type": "workspace", "mountPath": "/workspace",
"readOnly": false, "claimName": "context-research"
}],
"service": {
"name": "orders",
"type": "ClusterIP",
Expand Down Expand Up @@ -100,6 +104,7 @@ func TestAgentsGetText(t *testing.T) {
"Replicas:", "2/2 ready (2 available)",
"Created:", "2026-01-02T03:04:05Z",
"UID:", "abc-123",
"Contexts", "research", "workspace", "/workspace", "Read-write", "context-research",
"Endpoint",
"Service:", "orders (ClusterIP)",
"Cluster IP:", "10.0.0.5",
Expand Down Expand Up @@ -137,13 +142,20 @@ func TestAgentsGetJSON(t *testing.T) {
Name string `json:"name"`
} `json:"metadata"`
ReadyStatus string `json:"readyStatus"`
Contexts []struct {
Name string `json:"name"`
MountPath string `json:"mountPath"`
} `json:"contexts"`
}
if err := json.Unmarshal([]byte(out), &decoded); err != nil {
t.Fatalf("--json output is not valid JSON: %v\n%s", err, out)
}
if decoded.Metadata.Name != "orders" || decoded.ReadyStatus != "Ready" {
t.Errorf("unexpected decoded JSON: %+v", decoded)
}
if len(decoded.Contexts) != 1 || decoded.Contexts[0].Name != "research" || decoded.Contexts[0].MountPath != "/workspace" {
t.Errorf("contexts missing from JSON: %+v", decoded.Contexts)
}
}

func TestAgentsGetNamespaceOverride(t *testing.T) {
Expand Down
28 changes: 23 additions & 5 deletions cmd/contexts.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ func newContextsListCmd() *cobra.Command {
if err != nil {
return err
}
cmd.Println(string(encoded))
fmt.Fprintln(cmd.OutOrStdout(), string(encoded))
return nil
}
if len(result.Items) == 0 {
Expand Down Expand Up @@ -195,12 +195,30 @@ func printContextResource(cmd *cobra.Command, value *apiclient.ContextResource,
if err != nil {
return err
}
cmd.Println(string(encoded))
fmt.Fprintln(cmd.OutOrStdout(), string(encoded))
return nil
}
cmd.Printf("%s/%s: %s %s, %s %s, claim %s\n", value.Namespace, value.Name,
value.Status, value.Type, value.Storage.Size, value.Storage.AccessMode, value.Attachment.ClaimName)
return nil
storageClass := value.Storage.StorageClass
if storageClass == "" {
storageClass = "<default>"
}
writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(writer, "Context Information")
fmt.Fprintf(writer, " Name:\t%s\n", value.Name)
fmt.Fprintf(writer, " Namespace:\t%s\n", value.Namespace)
fmt.Fprintf(writer, " Type:\t%s\n", value.Type)
fmt.Fprintf(writer, " Status:\t%s\n", value.Status)
fmt.Fprintln(writer)
fmt.Fprintln(writer, "Storage")
fmt.Fprintf(writer, " Backend:\t%s\n", value.Storage.Backend)
fmt.Fprintf(writer, " Size:\t%s\n", value.Storage.Size)
fmt.Fprintf(writer, " Access Mode:\t%s\n", value.Storage.AccessMode)
fmt.Fprintf(writer, " Storage Class:\t%s\n", storageClass)
fmt.Fprintln(writer)
fmt.Fprintln(writer, "Attachment")
fmt.Fprintf(writer, " Kind:\t%s\n", value.Attachment.Kind)
fmt.Fprintf(writer, " Claim:\t%s\n", value.Attachment.ClaimName)
return writer.Flush()
}

func init() {
Expand Down
73 changes: 73 additions & 0 deletions cmd/contexts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,79 @@ func TestContextsList(t *testing.T) {
}
}

func TestContextGetShowsLabeledDetails(t *testing.T) {
isolateHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/namespaces":
_, _ = w.Write([]byte(`{"namespaces":["team1"]}`))
case "/api/v1/contexts/team1/research":
_, _ = w.Write([]byte(`{"name":"research","namespace":"team1","type":"workspace","status":"ready","storage":{"backend":"pvc","size":"10Gi","accessMode":"ReadWriteMany","storageClass":"ibm-scale-csi"},"attachment":{"kind":"pvc","claimName":"context-research"}}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
setupImportContext(t, srv, "team1")

out, err := execute(t, "context", "get", "research")
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{
"Context Information",
"Name: research",
"Namespace: team1",
"Type: workspace",
"Status: ready",
"Storage",
"Backend: pvc",
"Size: 10Gi",
"Access Mode: ReadWriteMany",
"Storage Class: ibm-scale-csi",
"Attachment",
"Kind: pvc",
"Claim: context-research",
} {
if !strings.Contains(out, expected) {
t.Errorf("get output missing %q:\n%s", expected, out)
}
}
}

func TestContextGetJSONWritesOnlyToStdout(t *testing.T) {
isolateHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/namespaces":
_, _ = w.Write([]byte(`{"namespaces":["team1"]}`))
case "/api/v1/contexts/team1/research":
_, _ = w.Write([]byte(`{"name":"research","namespace":"team1","type":"workspace","status":"ready","storage":{"backend":"pvc","size":"1Gi","accessMode":"ReadWriteOnce"},"attachment":{"kind":"pvc","claimName":"context-research"}}`))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
setupImportContext(t, srv, "team1")

stdout, stderr, err := executeSplit(t, "context", "get", "research", "--json")
if err != nil {
t.Fatal(err)
}
if stderr != "" {
t.Fatalf("stderr = %q, want empty", stderr)
}
var result map[string]any
if err := json.Unmarshal([]byte(stdout), &result); err != nil {
t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout)
}
if result["name"] != "research" {
t.Fatalf("name = %v, want research", result["name"])
}
}

func TestContextsListExplainsUnsupportedServer(t *testing.T) {
isolateHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
20 changes: 15 additions & 5 deletions internal/agentapi/agentapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,28 @@ package agentapi
// backend fills them from a Kubernetes custom resource, which has no fixed shape
// here. Callers read them opportunistically rather than relying on any key.
type AgentDetail struct {
Metadata AgentMetadata `json:"metadata"`
Spec map[string]any `json:"spec"`
Status map[string]any `json:"status"`
WorkloadType string `json:"workloadType"`
ReadyStatus string `json:"readyStatus"`
Metadata AgentMetadata `json:"metadata"`
Spec map[string]any `json:"spec"`
Status map[string]any `json:"status"`
WorkloadType string `json:"workloadType"`
ReadyStatus string `json:"readyStatus"`
Contexts []ContextAttachment `json:"contexts,omitempty"`

// Service is absent for anything not fronted by a Kubernetes Service — a
// local `authbridge exec` instance has no ClusterIP, and naming one nothing
// would answer on is worse than reporting none.
Service *ServiceInfo `json:"service"`
}

// ContextAttachment describes a named Context Service resource mounted in an agent.
type ContextAttachment struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
MountPath string `json:"mountPath"`
ReadOnly bool `json:"readOnly"`
ClaimName string `json:"claimName,omitempty"`
}

// ToolDetail mirrors the backend's GET /tools/{namespace}/{name} response, which
// has the same shape as an agent's. An alias rather than a copy, so the two
// cannot drift and one set of renderer helpers serves both.
Expand Down