diff --git a/cmd/agents_import.go b/cmd/agents_import.go index c6ab971..2595641 100644 --- a/cmd/agents_import.go +++ b/cmd/agents_import.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strings" "github.com/spf13/cobra" @@ -36,6 +37,10 @@ var importCreateHTTPRoute bool // split or reject outright, and a non-nil slice default leaks between tests. var importAdditionalParameterJSON []string +// importContextFlags contains named Context Service resources to mount into +// the agent. Each value has the form NAME:MOUNT_PATH. +var importContextFlags []string + // newAgentsImportCmd builds the `agents import` command and its two // subcommands, `from-image` and `from-source`. // @@ -51,6 +56,8 @@ func newAgentsImportCmd() *cobra.Command { "create an HTTPRoute exposing the agent") importCmd.PersistentFlags().StringArrayVar(&importAdditionalParameterJSON, additionalParameterFlagName, nil, "JSON dict, or a file containing one, merged into the request body (repeatable; later values and these keys win)") + importCmd.PersistentFlags().StringArrayVar(&importContextFlags, "context", nil, + "named context and absolute mount path as NAME:MOUNT_PATH (repeatable)") importCmd.AddCommand( newAgentsImportFromImageCmd(), @@ -97,6 +104,13 @@ the flags above already set replaces it.`, if storageSize != "" && importDeploymentType != "statefulset" && importDeploymentType != "sandbox" { return fmt.Errorf("--storage-size requires --deployment-type statefulset or sandbox") } + contexts, err := parseContextFlags(importContextFlags) + if err != nil { + return err + } + if len(contexts) > 0 && importDeploymentType != "statefulset" && importDeploymentType != "sandbox" { + return fmt.Errorf("--context requires --deployment-type statefulset or sandbox") + } namespace, err := agentsNamespace() if err != nil { @@ -135,6 +149,7 @@ the flags above already set replaces it.`, ImagePullSecret: imagePullSecret, EnvVars: envVars, CreateHTTPRoute: importCreateHTTPRoute, + Contexts: contexts, // Set last, but applied last as well: the overlay happens when the // request is marshaled, so it wins over every field above — including @@ -182,6 +197,31 @@ the flags above already set replaces it.`, return cmd } +func parseContextFlags(values []string) ([]apiclient.ContextAttachment, error) { + attachments := make([]apiclient.ContextAttachment, 0, len(values)) + seenPaths := make(map[string]struct{}, len(values)) + for _, value := range values { + name, mountPath, ok := strings.Cut(value, ":") + if !ok || strings.TrimSpace(name) == "" || strings.TrimSpace(mountPath) == "" { + return nil, fmt.Errorf("invalid --context %q: expected NAME:MOUNT_PATH", value) + } + name = strings.TrimSpace(name) + mountPath = strings.TrimSpace(mountPath) + if !strings.HasPrefix(mountPath, "/") { + return nil, fmt.Errorf("invalid --context %q: mount path must be absolute", value) + } + if _, exists := seenPaths[mountPath]; exists { + return nil, fmt.Errorf("invalid --context %q: mount path %q is already used", value, mountPath) + } + seenPaths[mountPath] = struct{}{} + attachments = append(attachments, apiclient.ContextAttachment{ + Name: name, + MountPath: mountPath, + }) + } + return attachments, nil +} + func newAgentsImportFromSourceCmd() *cobra.Command { var ( name string diff --git a/cmd/agents_import_test.go b/cmd/agents_import_test.go index 1f62725..15223ca 100644 --- a/cmd/agents_import_test.go +++ b/cmd/agents_import_test.go @@ -141,6 +141,45 @@ func TestAgentsImportFromImagePersistentStorage(t *testing.T) { } } +func TestAgentsImportFromImageContexts(t *testing.T) { + isolateHome(t) + var body map[string]any + srv := newImportServer(t, &body) + setupImportContext(t, srv, "team1") + + if _, err := execute(t, "agents", "import", "--deployment-type", "sandbox", + "--context", "research:/workspace", "--context", "memory:/memory", + "from-image", "--name", "orders", "--containerImage", "img"); err != nil { + t.Fatalf("import: %v", err) + } + contexts, ok := body["contexts"].([]any) + if !ok || len(contexts) != 2 { + t.Fatalf("contexts = %#v, want two attachments", body["contexts"]) + } + first := contexts[0].(map[string]any) + if first["name"] != "research" || first["mountPath"] != "/workspace" || first["readOnly"] != false { + t.Errorf("first context = %#v", first) + } +} + +func TestAgentsImportFromImageRejectsInvalidContext(t *testing.T) { + for _, value := range []string{"research", "research:relative"} { + _, err := execute(t, "agents", "import", "--deployment-type", "sandbox", "--context", value, "from-image", + "--name", "orders", "--containerImage", "img") + if err == nil || !strings.Contains(err.Error(), "invalid --context") { + t.Errorf("--context %q error = %v, want validation error", value, err) + } + } +} + +func TestAgentsImportFromImageRejectsContextForDeployment(t *testing.T) { + _, err := execute(t, "agents", "import", "--context", "research:/workspace", "from-image", + "--name", "orders", "--containerImage", "img") + if err == nil || !strings.Contains(err.Error(), "statefulset or sandbox") { + t.Fatalf("error = %v, want workload compatibility error", err) + } +} + func TestAgentsImportFromImageRejectsStorageForDeployment(t *testing.T) { isolateHome(t) var body map[string]any diff --git a/cmd/contexts.go b/cmd/contexts.go new file mode 100644 index 0000000..42e2e72 --- /dev/null +++ b/cmd/contexts.go @@ -0,0 +1,202 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/rossoctl/rossoctl-cli/internal/apiclient" +) + +var contextsNamespace string + +func contextNamespace() (string, error) { + if contextsNamespace != "" { + return contextsNamespace, nil + } + return currentNamespace() +} + +func newContextsCreateCmd() *cobra.Command { + var contextType, backend, size, storageClass string + var shared, jsonOutput bool + cmd := &cobra.Command{ + Use: "create NAME", + Short: "Create a named context resource", + Long: `Create a named context resource. + +Types classify how the stored data is intended to be used: + workspace Mutable files used while an agent works + memory Durable observations and experiences + knowledge Synthesized, reusable understanding + artifacts Produced reports, media, and other outputs + +All types currently use the same PVC-backed storage and lifecycle behavior.`, + Example: ` # Create a 1Gi ReadWriteOnce workspace + rossoctl context create research + + # Create PVC-backed memory for an agent + rossoctl context create research-memory --type memory --size 5Gi + + # Create a 10Gi shared ReadWriteMany workspace on a storage class + rossoctl context create research-shared --shared --size 10Gi --storage-class ibm-scale-csi + + # Mount the context when importing a Sandbox agent + rossoctl agents import --deployment-type sandbox --context research:/workspace from-image --name agent-1 --containerImage IMAGE`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + namespace, err := contextNamespace() + if err != nil { + return err + } + mode := "ReadWriteOnce" + if shared { + mode = "ReadWriteMany" + } + client, err := newClient(cmd) + if err != nil { + return err + } + result, err := client.CreateContext(cmd.Context(), &apiclient.CreateContextRequest{ + Name: args[0], Namespace: namespace, Type: contextType, + Storage: apiclient.ContextStorage{ + Backend: backend, Size: size, AccessMode: mode, StorageClass: storageClass, + }, + }) + if err != nil { + return err + } + return printContextResource(cmd, result, jsonOutput) + }, + } + cmd.Flags().StringVar(&contextType, "type", "workspace", "context type (workspace, memory, knowledge, or artifacts)") + cmd.Flags().StringVar(&backend, "backend", "pvc", "storage backend (currently pvc)") + cmd.Flags().StringVarP(&size, "size", "s", "1Gi", "storage size") + cmd.Flags().StringVar(&storageClass, "storage-class", "", "Kubernetes storage class") + cmd.Flags().BoolVar(&shared, "shared", false, "use shared ReadWriteMany storage") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "print JSON") + return cmd +} + +func newContextsGetCmd() *cobra.Command { + var jsonOutput bool + cmd := &cobra.Command{ + Use: "get NAME", Short: "Show a context resource", Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + namespace, err := contextNamespace() + if err != nil { + return err + } + client, err := newClient(cmd) + if err != nil { + return err + } + result, err := client.GetContext(cmd.Context(), namespace, args[0]) + if err != nil { + return err + } + return printContextResource(cmd, result, jsonOutput) + }, + } + cmd.Flags().BoolVar(&jsonOutput, "json", false, "print JSON") + return cmd +} + +func newContextsListCmd() *cobra.Command { + var jsonOutput bool + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List context resources", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + namespace, err := contextNamespace() + if err != nil { + return err + } + client, err := newClient(cmd) + if err != nil { + return err + } + result, err := client.ListContexts(cmd.Context(), namespace) + if err != nil { + return err + } + if jsonOutput { + encoded, err := json.MarshalIndent(result.Items, "", " ") + if err != nil { + return err + } + cmd.Println(string(encoded)) + return nil + } + if len(result.Items) == 0 { + cmd.Println("No contexts found.") + return nil + } + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 3, ' ', 0) + fmt.Fprintln(writer, "NAME\tTYPE\tSTATUS\tSIZE\tACCESS MODE\tCLAIM") + for _, item := range result.Items { + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\n", item.Name, item.Type, item.Status, + item.Storage.Size, item.Storage.AccessMode, item.Attachment.ClaimName) + } + return writer.Flush() + }, + } + cmd.Flags().BoolVar(&jsonOutput, "json", false, "print JSON") + return cmd +} + +func newContextsDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete NAME", Aliases: []string{"rm"}, Short: "Delete a context resource", Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + namespace, err := contextNamespace() + if err != nil { + return err + } + client, err := newClient(cmd) + if err != nil { + return err + } + if err := client.DeleteContext(cmd.Context(), namespace, args[0]); err != nil { + return err + } + cmd.Printf("Context %q deleted from namespace %q.\n", args[0], namespace) + return nil + }, + } +} + +func printContextResource(cmd *cobra.Command, value *apiclient.ContextResource, jsonOutput bool) error { + if jsonOutput { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + cmd.Println(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 +} + +func init() { + contextsCmd := newGroup("contexts", "Manage named agent context resources") + contextsCmd.Aliases = []string{"context"} + contextsCmd.PersistentFlags().StringVar(&contextsNamespace, "namespace", "", "namespace (overrides current context)") + contextsCmd.AddCommand(newContextsCreateCmd(), newContextsListCmd(), newContextsGetCmd(), newContextsDeleteCmd()) + rootCmd.AddCommand(contextsCmd) +} diff --git a/cmd/contexts_test.go b/cmd/contexts_test.go new file mode 100644 index 0000000..a6e693b --- /dev/null +++ b/cmd/contexts_test.go @@ -0,0 +1,135 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestContextCreateSupportsAllTypes(t *testing.T) { + for _, contextType := range []string{"workspace", "memory", "knowledge", "artifacts"} { + t.Run(contextType, func(t *testing.T) { + isolateHome(t) + var requestType string + 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": + var body struct { + Type string `json:"type"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + requestType = body.Type + _, _ = w.Write([]byte(`{"name":"research","namespace":"team1","type":"` + body.Type + `","status":"provisioning","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") + + if _, err := execute(t, "context", "create", "research", "--type", contextType); err != nil { + t.Fatal(err) + } + if requestType != contextType { + t.Fatalf("type = %q, want %q", requestType, contextType) + } + }) + } +} + +func TestContextsList(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": + _, _ = w.Write([]byte(`{"items":[{"name":"research","namespace":"team1","type":"workspace","status":"ready","storage":{"backend":"pvc","size":"10Gi","accessMode":"ReadWriteMany"},"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, "contexts", "list") + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"research", "ReadWriteMany", "context-research"} { + if !strings.Contains(out, expected) { + t.Errorf("output missing %q:\n%s", expected, out) + } + } + + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 2 { + t.Fatalf("expected header and one row, got:\n%s", out) + } + for _, columns := range [][2]string{ + {"TYPE", "workspace"}, + {"STATUS", "ready"}, + {"SIZE", "10Gi"}, + {"ACCESS MODE", "ReadWriteMany"}, + {"CLAIM", "context-research"}, + } { + if strings.Index(lines[0], columns[0]) != strings.Index(lines[1], columns[1]) { + t.Errorf("column %q is not aligned with %q:\n%s", columns[0], columns[1], out) + } + } +} + +func TestContextAlias(t *testing.T) { + command, _, err := rootCmd.Find([]string{"context", "list"}) + if err != nil { + t.Fatal(err) + } + if command.Name() != "list" { + t.Fatalf("resolved command = %q, want list", command.Name()) + } +} + +func TestContextCommandsShowHelpWithoutName(t *testing.T) { + for _, subcommand := range []string{"create", "get", "delete"} { + out, err := execute(t, "context", subcommand) + if err != nil { + t.Fatalf("context %s: %v", subcommand, err) + } + if !strings.Contains(out, "Usage:") || !strings.Contains(out, subcommand+" NAME") { + t.Errorf("context %s did not show command help:\n%s", subcommand, out) + } + } +} + +func TestContextCreateHelpIncludesExamples(t *testing.T) { + out, err := execute(t, "context", "create", "--help") + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{ + "Types classify how the stored data is intended to be used:", + "workspace Mutable files used while an agent works", + "memory Durable observations and experiences", + "knowledge Synthesized, reusable understanding", + "artifacts Produced reports, media, and other outputs", + "same PVC-backed storage and lifecycle behavior", + "Examples:", + "context create research", + "--type memory", + "--shared", + "--context research:/workspace", + "workspace, memory, knowledge, or artifacts", + } { + if !strings.Contains(out, expected) { + t.Errorf("create help missing %q:\n%s", expected, out) + } + } +} diff --git a/internal/apiclient/apiclient.go b/internal/apiclient/apiclient.go index e68f9bb..b941615 100644 --- a/internal/apiclient/apiclient.go +++ b/internal/apiclient/apiclient.go @@ -226,6 +226,9 @@ func (c *Client) request(ctx context.Context, method, path string, body []byte, } return &StatusError{Endpoint: endpoint, StatusCode: resp.StatusCode, Body: msg} } + if out == nil { + return nil + } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("decoding response from %s: %w", endpoint, err) @@ -605,6 +608,12 @@ func marshalWithAdditional(req any, additional map[string]any) ([]byte, error) { return json.Marshal(merged) } +type ContextAttachment struct { + Name string `json:"name"` + MountPath string `json:"mountPath"` + ReadOnly bool `json:"readOnly"` +} + // CreateAgentRequest is the subset of the backend's CreateAgentRequest that // the CLI populates. Fields the server defaults are omitted; only what we set // is sent. deploymentMethod selects image vs source; workloadType selects @@ -616,6 +625,7 @@ type CreateAgentRequest struct { WorkloadType string `json:"workloadType"` EnvVars []EnvVar `json:"envVars,omitempty"` PersistentStorage *PersistentStorageConfig `json:"persistentStorage,omitempty"` + Contexts []ContextAttachment `json:"contexts,omitempty"` // Image deployment fields. ContainerImage string `json:"containerImage,omitempty"` @@ -662,6 +672,67 @@ func (r CreateAgentRequest) MarshalJSON() ([]byte, error) { return marshalWithAdditional(plain(r), r.AdditionalParameters) } +type ContextStorage struct { + Backend string `json:"backend"` + Size string `json:"size"` + AccessMode string `json:"accessMode"` + StorageClass string `json:"storageClass,omitempty"` +} + +type CreateContextRequest struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Type string `json:"type"` + Storage ContextStorage `json:"storage"` +} + +type ContextResource struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Type string `json:"type"` + Status string `json:"status"` + Storage ContextStorage `json:"storage"` + Attachment struct { + Kind string `json:"kind"` + ClaimName string `json:"claimName"` + } `json:"attachment"` +} + +type ContextListResponse struct { + Items []ContextResource `json:"items"` +} + +func (c *Client) CreateContext(ctx context.Context, req *CreateContextRequest) (*ContextResource, error) { + var resp ContextResource + if err := c.postJSON(ctx, "contexts", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *Client) ListContexts(ctx context.Context, namespace string) (*ContextListResponse, error) { + var resp ContextListResponse + path := "contexts/" + url.PathEscape(namespace) + if err := c.getJSON(ctx, path, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *Client) GetContext(ctx context.Context, namespace, name string) (*ContextResource, error) { + var resp ContextResource + path := "contexts/" + url.PathEscape(namespace) + "/" + url.PathEscape(name) + if err := c.getJSON(ctx, path, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (c *Client) DeleteContext(ctx context.Context, namespace, name string) error { + path := "contexts/" + url.PathEscape(namespace) + "/" + url.PathEscape(name) + return c.deleteJSON(ctx, path, nil) +} + // CreateAgentResponse mirrors the backend's CreateAgentResponse model. type CreateAgentResponse struct { Success bool `json:"success"` diff --git a/internal/apiclient/apiclient_test.go b/internal/apiclient/apiclient_test.go index d9d8a25..dfd559e 100644 --- a/internal/apiclient/apiclient_test.go +++ b/internal/apiclient/apiclient_test.go @@ -826,3 +826,18 @@ func TestCreateAgentSendsAdditionalParameters(t *testing.T) { t.Errorf("containerImage = %v, want the overlay to win on the wire", gotBody["containerImage"]) } } + +func TestDeleteContextAcceptsEmptyResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.URL.Path != "/api/v1/contexts/team1/research" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := &Client{BaseURL: srv.URL + "/api/v1/"} + if err := c.DeleteContext(context.Background(), "team1", "research"); err != nil { + t.Fatalf("DeleteContext: %v", err) + } +} diff --git a/internal/rossoctlclient/rossoctlclient.go b/internal/rossoctlclient/rossoctlclient.go index 351266c..7ffdccd 100644 --- a/internal/rossoctlclient/rossoctlclient.go +++ b/internal/rossoctlclient/rossoctlclient.go @@ -65,6 +65,18 @@ type Rossoctl interface { // CreateAgent creates an agent from the given request. CreateAgent(ctx context.Context, req *apiclient.CreateAgentRequest) (*apiclient.CreateAgentResponse, error) + // CreateContext creates a named agent context resource. + CreateContext(ctx context.Context, req *apiclient.CreateContextRequest) (*apiclient.ContextResource, error) + + // ListContexts lists context resources in a namespace. + ListContexts(ctx context.Context, namespace string) (*apiclient.ContextListResponse, error) + + // GetContext fetches a named context resource in a namespace. + GetContext(ctx context.Context, namespace, name string) (*apiclient.ContextResource, error) + + // DeleteContext deletes a named context resource in a namespace. + DeleteContext(ctx context.Context, namespace, name string) error + // ListTools lists tools in the given namespace (empty => server default). ListTools(ctx context.Context, namespace string) (*apiclient.ToolListResponse, error) diff --git a/scripts/watch-contexts.sh b/scripts/watch-contexts.sh new file mode 100755 index 0000000..4336b5b --- /dev/null +++ b/scripts/watch-contexts.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -u + +namespace="team1" +show_k8s=false + +usage() { + cat <<'EOF' +Usage: watch-contexts.sh [options] + +Show one snapshot of Rosso agents and Context Service resources. + +Options: + -n, --namespace NAME Namespace to watch (default: team1) + --k8s Also show Sandboxes, StatefulSets, Pods, and PVCs + -h, --help Show this help + +Examples: + ./scripts/watch-contexts.sh + ./scripts/watch-contexts.sh --k8s + watch -n 2 ./scripts/watch-contexts.sh --k8s +EOF +} + +while (($#)); do + case "$1" in + -n|--namespace) + namespace=${2:?"namespace is required"} + shift 2 + ;; + --k8s) + show_k8s=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +printf 'Rosso context snapshot — namespace: %s — %s\n\n' "$namespace" "$(date '+%Y-%m-%d %H:%M:%S')" + +echo 'AGENTS' +rossoctl agents --namespace "$namespace" list || true + +echo +echo 'CONTEXTS' +rossoctl context --namespace "$namespace" list || true + +if $show_k8s; then + echo + echo 'KUBERNETES RESOURCES' + kubectl -n "$namespace" get sandboxes,statefulsets,pods,pvc 2>&1 || true +fi