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
40 changes: 40 additions & 0 deletions cmd/agents_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"strings"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -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`.
//
Expand All @@ -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(),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions cmd/agents_import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
202 changes: 202 additions & 0 deletions cmd/contexts.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading