diff --git a/README.md b/README.md index 9e7e81a..a397c75 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,9 @@ is maintained in the [context-service repository](https://github.com/rossoctl/context-service). ```sh +# Discover storage choices without direct Kubernetes access. +rossoctl context storage-classes + # Create and inspect a shared workspace. rossoctl context create research --shared --size 10Gi \ --storage-class ibm-scale-csi diff --git a/cmd/contexts.go b/cmd/contexts.go index ff2a13d..d26fcab 100644 --- a/cmd/contexts.go +++ b/cmd/contexts.go @@ -189,6 +189,51 @@ func newContextsDeleteCmd() *cobra.Command { } } +func newContextStorageClassesCmd() *cobra.Command { + var jsonOutput bool + cmd := &cobra.Command{ + Use: "storage-classes", + Aliases: []string{"sc"}, + Short: "List storage classes available for context resources", + Long: `List the storage classes that can be selected when creating context resources. + +This command obtains a constrained view through the Rosso API and does not +require direct Kubernetes access. Use a returned name with --storage-class.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, err := newClient(cmd) + if err != nil { + return err + } + result, err := client.ListContextStorageClasses(cmd.Context()) + if err != nil { + return err + } + if jsonOutput { + encoded, err := json.MarshalIndent(result.Items, "", " ") + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), string(encoded)) + return nil + } + if len(result.Items) == 0 { + cmd.Println("No storage classes found.") + return nil + } + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 3, ' ', 0) + fmt.Fprintln(writer, "NAME\tDEFAULT\tPROVISIONER\tBINDING MODE\tEXPANSION") + for _, item := range result.Items { + fmt.Fprintf(writer, "%s\t%t\t%s\t%s\t%t\n", item.Name, item.Default, + item.Provisioner, item.VolumeBindingMode, item.AllowVolumeExpansion) + } + return writer.Flush() + }, + } + cmd.Flags().BoolVar(&jsonOutput, "json", false, "print JSON") + return cmd +} + func printContextResource(cmd *cobra.Command, value *apiclient.ContextResource, jsonOutput bool) error { if jsonOutput { encoded, err := json.MarshalIndent(value, "", " ") @@ -234,6 +279,6 @@ PVC-backed storage mounted into StatefulSet or Sandbox agents. Learn more: https://github.com/rossoctl/rossoctl/blob/main/docs/concepts/context-service.md` contextsCmd.PersistentFlags().StringVar(&contextsNamespace, "namespace", "", "namespace (overrides current context)") - contextsCmd.AddCommand(newContextsCreateCmd(), newContextsListCmd(), newContextsGetCmd(), newContextsDeleteCmd()) + contextsCmd.AddCommand(newContextsCreateCmd(), newContextsListCmd(), newContextsGetCmd(), newContextsDeleteCmd(), newContextStorageClassesCmd()) rootCmd.AddCommand(contextsCmd) } diff --git a/cmd/contexts_test.go b/cmd/contexts_test.go index 03a9a0d..e3f4f7c 100644 --- a/cmd/contexts_test.go +++ b/cmd/contexts_test.go @@ -87,6 +87,62 @@ func TestContextsList(t *testing.T) { } } +func TestContextStorageClasses(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/context-storage-classes": + _, _ = w.Write([]byte(`{"items":[{"name":"fast","default":true,"provisioner":"example.csi.io","volumeBindingMode":"WaitForFirstConsumer","reclaimPolicy":"Delete","allowVolumeExpansion":true}]}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + setupImportContext(t, srv, "team1") + + out, err := execute(t, "context", "storage-classes") + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"NAME", "DEFAULT", "PROVISIONER", "BINDING MODE", "EXPANSION", "fast", "true", "example.csi.io", "WaitForFirstConsumer"} { + if !strings.Contains(out, expected) { + t.Errorf("output missing %q:\n%s", expected, out) + } + } +} + +func TestContextStorageClassesJSON(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/context-storage-classes": + _, _ = w.Write([]byte(`{"items":[{"name":"fast","default":true,"provisioner":"example.csi.io","volumeBindingMode":"Immediate","reclaimPolicy":"Delete","allowVolumeExpansion":false}]}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + setupImportContext(t, srv, "team1") + + out, err := execute(t, "context", "storage-classes", "--json") + if err != nil { + t.Fatal(err) + } + var result []map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if len(result) != 1 || result[0]["name"] != "fast" || result[0]["default"] != true { + t.Fatalf("unexpected result: %#v", result) + } +} + func TestContextGetShowsLabeledDetails(t *testing.T) { isolateHome(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/apiclient/apiclient.go b/internal/apiclient/apiclient.go index b941615..5726af3 100644 --- a/internal/apiclient/apiclient.go +++ b/internal/apiclient/apiclient.go @@ -702,6 +702,29 @@ type ContextListResponse struct { Items []ContextResource `json:"items"` } +// ContextStorageClass is the constrained storage choice view returned by Rosso. +// It intentionally does not expose arbitrary Kubernetes StorageClass fields. +type ContextStorageClass struct { + Name string `json:"name"` + Default bool `json:"default"` + Provisioner string `json:"provisioner"` + VolumeBindingMode string `json:"volumeBindingMode"` + ReclaimPolicy string `json:"reclaimPolicy"` + AllowVolumeExpansion bool `json:"allowVolumeExpansion"` +} + +type ContextStorageClassListResponse struct { + Items []ContextStorageClass `json:"items"` +} + +func (c *Client) ListContextStorageClasses(ctx context.Context) (*ContextStorageClassListResponse, error) { + var resp ContextStorageClassListResponse + if err := c.getJSON(ctx, "context-storage-classes", &resp); err != nil { + return nil, err + } + return &resp, nil +} + func (c *Client) CreateContext(ctx context.Context, req *CreateContextRequest) (*ContextResource, error) { var resp ContextResource if err := c.postJSON(ctx, "contexts", req, &resp); err != nil { diff --git a/internal/apiclient/apiclient_test.go b/internal/apiclient/apiclient_test.go index dfd559e..1e50608 100644 --- a/internal/apiclient/apiclient_test.go +++ b/internal/apiclient/apiclient_test.go @@ -48,6 +48,27 @@ func TestGetAuthConfig(t *testing.T) { } } +func TestListContextStorageClasses(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(`{"items":[{"name":"fast","default":true,"provisioner":"example.csi.io","volumeBindingMode":"Immediate","reclaimPolicy":"Delete","allowVolumeExpansion":true}]}`)) + })) + defer srv.Close() + + c := &Client{BaseURL: srv.URL + "/api/v1/"} + result, err := c.ListContextStorageClasses(context.Background()) + if err != nil { + t.Fatal(err) + } + if gotPath != "/api/v1/context-storage-classes" { + t.Fatalf("path = %q", gotPath) + } + if len(result.Items) != 1 || result.Items[0].Name != "fast" || !result.Items[0].Default || !result.Items[0].AllowVolumeExpansion { + t.Fatalf("unexpected result: %#v", result) + } +} + func TestGetAuthConfigBaseWithoutTrailingSlash(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/auth/config" { diff --git a/internal/rossoctlclient/rossoctlclient.go b/internal/rossoctlclient/rossoctlclient.go index 7ffdccd..fbcbaaf 100644 --- a/internal/rossoctlclient/rossoctlclient.go +++ b/internal/rossoctlclient/rossoctlclient.go @@ -71,6 +71,9 @@ type Rossoctl interface { // ListContexts lists context resources in a namespace. ListContexts(ctx context.Context, namespace string) (*apiclient.ContextListResponse, error) + // ListContextStorageClasses lists the storage choices available for contexts. + ListContextStorageClasses(ctx context.Context) (*apiclient.ContextStorageClassListResponse, error) + // GetContext fetches a named context resource in a namespace. GetContext(ctx context.Context, namespace, name string) (*apiclient.ContextResource, error) diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 4780434..7c81c5b 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -166,6 +166,7 @@ var apiRoutes = []Route{ // unrelated reason, and reporting the two identically sends the user looking at // their server's version instead of at their current context. {http.MethodPost, "/contexts", unimplemented}, + {http.MethodGet, "/context-storage-classes", unimplemented}, {http.MethodGet, "/contexts/{namespace}", unimplemented}, {http.MethodDelete, "/contexts/{namespace}/{name}", unimplemented}, {http.MethodGet, "/contexts/{namespace}/{name}", unimplemented}, diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index aa8ed19..4d35ffd 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -325,8 +325,8 @@ func TestListenPortInUse(t *testing.T) { } // TestRouteTableMatchesOpenAPI guards the operation count against accidental -// edits to the route table. The count is 48: 44 operations from the backend's -// OpenAPI document under /api/v1, plus the 4 context operations described below, +// edits to the route table. The count is 49: 44 operations from the backend's +// OpenAPI document under /api/v1, plus the 5 context operations described below, // with /health and /ready at the root counted separately. // // It was 43 until PUT /agents/{namespace}/{name}/identity-config was added: the @@ -335,8 +335,9 @@ func TestListenPortInUse(t *testing.T) { // correct alongside evidence that the document grew — otherwise a route invented // here would be waved through. // -// The 4 context routes (POST /contexts, GET /contexts/{namespace}, and GET and -// DELETE /contexts/{namespace}/{name}) are the evidence-backed exception to +// The 5 context routes (GET /context-storage-classes, POST /contexts, +// GET /contexts/{namespace}, and GET and DELETE /contexts/{namespace}/{name}) +// are the evidence-backed exception to // "transcribed from the document": they are the context resource API from // rossoctl/rossoctl#2392, which postdates the document this table was built from, // and they are listed from the paths internal/apiclient actually requests. @@ -344,7 +345,7 @@ func TestListenPortInUse(t *testing.T) { // that — it drives the real client, so a path here that the client does not ask // for, or vice versa, fails. func TestRouteTableMatchesOpenAPI(t *testing.T) { - if got, want := len(APIRoutes()), 48; got != want { + if got, want := len(APIRoutes()), 49; got != want { t.Errorf("API route count = %d, want %d", got, want) } if got, want := len(HealthRoutes()), 2; got != want { diff --git a/internal/serve/wire_test.go b/internal/serve/wire_test.go index d87b017..89b3a2a 100644 --- a/internal/serve/wire_test.go +++ b/internal/serve/wire_test.go @@ -379,6 +379,11 @@ func TestContextRoutesAreReachedByTheClient(t *testing.T) { requireUnimplemented(t, "ListContexts", err) }) + t.Run("ListContextStorageClasses", func(t *testing.T) { + _, err := client.ListContextStorageClasses(ctx) + requireUnimplemented(t, "ListContextStorageClasses", err) + }) + t.Run("GetContext", func(t *testing.T) { _, err := client.GetContext(ctx, "nsA", "research") requireUnimplemented(t, "GetContext", err)