Skip to content
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 46 additions & 1 deletion cmd/contexts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "", " ")
Expand Down Expand Up @@ -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)
}
56 changes: 56 additions & 0 deletions cmd/contexts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions internal/apiclient/apiclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions internal/apiclient/apiclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
3 changes: 3 additions & 0 deletions internal/rossoctlclient/rossoctlclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions internal/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
11 changes: 6 additions & 5 deletions internal/serve/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -335,16 +335,17 @@ 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.
// TestContextRoutesAreReachedByTheClient in wire_test.go is what holds them to
// 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 {
Expand Down
5 changes: 5 additions & 0 deletions internal/serve/wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down