diff --git a/README.md b/README.md index b545353..c7a0ae5 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,14 @@ The CLI currently focuses on authentication, cluster inspection, and applying re Calls the Kedify API and transparently reads all pages before printing the final cluster list. - `kedify get cluster [name-or-id]` Prints one cluster by name or id, and shows an interactive picker when no name is provided. +- `kedify delete cluster [name-or-id]` + Deletes one cluster by name or id, and shows an interactive picker when no name is provided. - `kedify list recommendations ` Prints the recommendations payload for a cluster id. - `kedify apply recommendations ` Applies recommendations from a saved JSON or YAML file to a Helm values file and can emit `json`, `diff`, or `override` output. - Output formatting - `kedify list clusters`, `kedify get cluster`, and `kedify list recommendations` support `-o` and `--output` with `text`, `json`, or `yaml`. `text` is the default. + `kedify list clusters`, `kedify get cluster`, and `kedify list recommendations` support `-o` and `--output` with `text`, `json`, or `yaml`. `text` is the default. `kedify delete cluster` prints its confirmation message to `stderr` and keeps `stdout` empty for shell-friendly usage. ## Build @@ -119,6 +121,18 @@ Get a cluster as JSON: ./bin/kedify get cluster my-cluster -o json ``` +Delete a cluster by name: + +```bash +./bin/kedify delete cluster my-cluster +``` + +Delete a cluster by UUID: + +```bash +./bin/kedify delete cluster fc6af0dc-685b-4055-805d-0d3e0ead1596 +``` + List recommendations for a cluster as JSON: ```bash @@ -166,6 +180,12 @@ Pick a cluster interactively: ./bin/kedify get cluster ``` +Pick a cluster interactively and delete it: + +```bash +./bin/kedify delete cluster +``` + Override the API URL: ```bash diff --git a/internal/api/client.go b/internal/api/client.go index 8de7afd..6d81995 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -89,6 +89,14 @@ func (c *Client) GetRecommendations(apiURL, token, clusterID string) (any, error return nil, fmt.Errorf("request recommendations for cluster %s: %w", clusterID, err) } +func (c *Client) DeleteCluster(apiURL, token, clusterID string) error { + if _, err := c.doRequest(apiURL, token, http.MethodDelete, "/clusters/"+url.PathEscape(clusterID), nil); err != nil { + return fmt.Errorf("delete cluster %s: %w", clusterID, err) + } + + return nil +} + func (c *Client) listPaginatedItems(apiURL, token, path string) ([]any, error) { var allItems []any page := 1 @@ -188,11 +196,24 @@ func readResponseBody(resp *http.Response) ([]byte, error) { } func (c *Client) getJSON(apiURL, token, path string, target any) error { + body, err := c.doRequest(apiURL, token, http.MethodGet, path, nil) + if err != nil { + return err + } + + if err := json.Unmarshal(body, target); err != nil { + return fmt.Errorf("parse response as json: %w", err) + } + + return nil +} + +func (c *Client) doRequest(apiURL, token, method, path string, body io.Reader) ([]byte, error) { requestURL := strings.TrimRight(apiURL, "/") + path - req, err := http.NewRequest(http.MethodGet, requestURL, nil) + req, err := http.NewRequest(method, requestURL, body) if err != nil { - return fmt.Errorf("build request: %w", err) + return nil, fmt.Errorf("build request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) @@ -200,21 +221,17 @@ func (c *Client) getJSON(apiURL, token, path string, target any) error { resp, err := c.httpClient.Do(req) if err != nil { - return err + return nil, err } - body, err := readResponseBody(resp) + responseBody, err := readResponseBody(resp) if err != nil { - return err + return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("request failed with status %s: %s", resp.Status, strings.TrimSpace(string(body))) - } - - if err := json.Unmarshal(body, target); err != nil { - return fmt.Errorf("parse response as json: %w", err) + return nil, fmt.Errorf("request failed with status %s: %s", resp.Status, strings.TrimSpace(string(responseBody))) } - return nil + return responseBody, nil } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 72a4394..6679002 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -189,3 +189,25 @@ func TestGetRecommendationsFallsBackToNonPaginatedPayload(t *testing.T) { t.Fatalf("requests = %d, want 1", requests) } } + +func TestDeleteClusterCallsDedicatedEndpoint(t *testing.T) { + client := &Client{httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodDelete { + t.Fatalf("method = %q", r.Method) + } + if r.URL.Path != "/v1/clusters/fc6af0dc-685b-4055-805d-0d3e0ead1596" { + t.Fatalf("path = %q", r.URL.Path) + } + + return &http.Response{ + StatusCode: http.StatusNoContent, + Status: "204 No Content", + Body: io.NopCloser(strings.NewReader("")), + Header: make(http.Header), + }, nil + })}} + + if err := client.DeleteCluster("https://api.dev.kedify.io/v1", "token", "fc6af0dc-685b-4055-805d-0d3e0ead1596"); err != nil { + t.Fatalf("DeleteCluster() error = %v", err) + } +} diff --git a/internal/cli/delete/cluster.go b/internal/cli/delete/cluster.go new file mode 100644 index 0000000..3a8f097 --- /dev/null +++ b/internal/cli/delete/cluster.go @@ -0,0 +1,80 @@ +package delete + +import ( + "fmt" + + clictx "github.com/kedify/cli/internal/cli/context" + getcmd "github.com/kedify/cli/internal/cli/get" +) + +type DeleteClusterCmd struct { + Name string `arg:"" optional:"" name:"name" help:"Cluster name or id."` +} + +func (c *DeleteClusterCmd) Run(ctx *clictx.Context) error { + token, err := clictx.ResolveToken(ctx) + if err != nil { + return err + } + + clusterID, clusterName, err := c.resolveCluster(ctx, token) + if err != nil { + return err + } + + if err := ctx.Client.DeleteCluster(ctx.APIURL, token, clusterID); err != nil { + return err + } + + if clusterName != "" { + _, err = fmt.Fprintf(ctx.Stderr, "Deleted cluster %q (%s).\n", clusterName, clusterID) + } else { + _, err = fmt.Fprintf(ctx.Stderr, "Deleted cluster %s.\n", clusterID) + } + + return err +} + +func (c *DeleteClusterCmd) resolveCluster(ctx *clictx.Context, token string) (string, string, error) { + if c.Name != "" && getcmd.IsUUID(c.Name) { + cluster, err := ctx.Client.GetCluster(ctx.APIURL, token, c.Name) + if err == nil { + return c.Name, clusterString(cluster, "name"), nil + } + return c.Name, "", nil + } + + clusters, err := ctx.Client.ListClusters(ctx.APIURL, token) + if err != nil { + return "", "", err + } + + if c.Name != "" { + cluster, err := getcmd.FindCluster(clusters, c.Name) + if err != nil { + return "", "", err + } + return clusterString(cluster, "id"), clusterString(cluster, "name"), nil + } + + cluster, err := ctx.SelectCluster(ctx.Stdin, ctx.Stdout, ctx.Stderr, clusters) + if err != nil { + return "", "", err + } + + return clusterString(cluster, "id"), clusterString(cluster, "name"), nil +} + +func clusterString(cluster map[string]any, key string) string { + value, ok := cluster[key] + if !ok { + return "" + } + + text, ok := value.(string) + if !ok { + return "" + } + + return text +} diff --git a/internal/cli/delete/cluster_test.go b/internal/cli/delete/cluster_test.go new file mode 100644 index 0000000..effb5f8 --- /dev/null +++ b/internal/cli/delete/cluster_test.go @@ -0,0 +1,178 @@ +package delete + +import ( + "bytes" + "io" + "strings" + "testing" + + clictx "github.com/kedify/cli/internal/cli/context" + "github.com/kedify/cli/internal/service" +) + +type fakeCredentialsStore struct { + creds service.Credentials +} + +func (f *fakeCredentialsStore) ReadCredentials() (service.Credentials, error) { + return f.creds, nil +} + +func (f *fakeCredentialsStore) WriteCredentials(service.Credentials) error { + return nil +} + +type fakeClusterService struct { + clusters []map[string]any + cluster map[string]any + recommendations any + err error + lastURL string + lastToken string + lastID string + deletedIDs []string +} + +func (f *fakeClusterService) ListClusters(apiURL, token string) ([]map[string]any, error) { + f.lastURL = apiURL + f.lastToken = token + if f.err != nil { + return nil, f.err + } + return f.clusters, nil +} + +func (f *fakeClusterService) GetCluster(apiURL, token, clusterID string) (map[string]any, error) { + f.lastURL = apiURL + f.lastToken = token + f.lastID = clusterID + if f.err != nil { + return nil, f.err + } + return f.cluster, nil +} + +func (f *fakeClusterService) GetRecommendations(apiURL, token, clusterID string) (any, error) { + f.lastURL = apiURL + f.lastToken = token + f.lastID = clusterID + if f.err != nil { + return nil, f.err + } + return f.recommendations, nil +} + +func (f *fakeClusterService) DeleteCluster(apiURL, token, clusterID string) error { + f.lastURL = apiURL + f.lastToken = token + f.lastID = clusterID + f.deletedIDs = append(f.deletedIDs, clusterID) + if f.err != nil { + return f.err + } + return nil +} + +func TestDeleteClusterCmdRunDeletesNamedCluster(t *testing.T) { + store := &fakeCredentialsStore{creds: service.Credentials{Token: "stored-token"}} + clusterService := &fakeClusterService{ + clusters: []map[string]any{ + {"id": "1", "name": "alpha"}, + {"id": "2", "name": "beta"}, + }, + } + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: stdout, + Stderr: stderr, + APIURL: "https://api.dev.kedify.io/v1", + Token: "override-token", + Client: clusterService, + Credentials: store, + SelectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, _ []map[string]any) (map[string]any, error) { + t.Fatal("SelectCluster should not be called when name is provided") + return nil, nil + }, + } + + if err := (&DeleteClusterCmd{Name: "beta"}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(clusterService.deletedIDs) != 1 || clusterService.deletedIDs[0] != "2" { + t.Fatalf("deleted IDs = %#v", clusterService.deletedIDs) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), `Deleted cluster "beta" (2).`) { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestDeleteClusterCmdRunDeletesUUIDDirectly(t *testing.T) { + store := &fakeCredentialsStore{creds: service.Credentials{Token: "stored-token"}} + clusterService := &fakeClusterService{ + cluster: map[string]any{ + "id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", + "name": "alpha", + }, + } + + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + APIURL: "https://api.dev.kedify.io/v1", + Token: "override-token", + Client: clusterService, + Credentials: store, + SelectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, _ []map[string]any) (map[string]any, error) { + t.Fatal("SelectCluster should not be called when UUID is provided") + return nil, nil + }, + } + + id := "fc6af0dc-685b-4055-805d-0d3e0ead1596" + if err := (&DeleteClusterCmd{Name: id}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(clusterService.deletedIDs) != 1 || clusterService.deletedIDs[0] != id { + t.Fatalf("deleted IDs = %#v", clusterService.deletedIDs) + } +} + +func TestDeleteClusterCmdRunUsesSelectorWhenNameMissing(t *testing.T) { + store := &fakeCredentialsStore{creds: service.Credentials{Token: "stored-token"}} + clusterService := &fakeClusterService{ + clusters: []map[string]any{{"id": "2", "name": "beta"}}, + } + + ctx := &clictx.Context{ + Stdin: bytes.NewBuffer(nil), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + APIURL: "https://api.dev.kedify.io/v1", + Token: "override-token", + Client: clusterService, + Credentials: store, + SelectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, clusters []map[string]any) (map[string]any, error) { + if len(clusters) != 1 { + t.Fatalf("selector clusters len = %d, want 1", len(clusters)) + } + return clusters[0], nil + }, + } + + if err := (&DeleteClusterCmd{}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(clusterService.deletedIDs) != 1 || clusterService.deletedIDs[0] != "2" { + t.Fatalf("deleted IDs = %#v", clusterService.deletedIDs) + } +} diff --git a/internal/cli/get/cluster.go b/internal/cli/get/cluster.go index 5ae5011..1e4bb92 100644 --- a/internal/cli/get/cluster.go +++ b/internal/cli/get/cluster.go @@ -35,7 +35,7 @@ func (c *GetClusterCmd) Run(ctx *clictx.Context) error { return err } } else { - cluster, err = findCluster(clusters, c.Name) + cluster, err = FindCluster(clusters, c.Name) if err != nil { return err } @@ -57,7 +57,7 @@ func (c *GetClusterCmd) Run(ctx *clictx.Context) error { return ctx.WriteOutput(ctx.Stdout, cluster, c.Output) } -func findCluster(clusters []map[string]any, query string) (map[string]any, error) { +func FindCluster(clusters []map[string]any, query string) (map[string]any, error) { for _, cluster := range clusters { if clusterString(cluster, "name") == query || clusterString(cluster, "id") == query { return cluster, nil @@ -72,6 +72,10 @@ func isUUID(value string) bool { return err == nil } +func IsUUID(value string) bool { + return isUUID(value) +} + func clusterString(cluster map[string]any, key string) string { value, ok := cluster[key] if !ok { diff --git a/internal/cli/get/cluster_test.go b/internal/cli/get/cluster_test.go index 5f2948a..8f32035 100644 --- a/internal/cli/get/cluster_test.go +++ b/internal/cli/get/cluster_test.go @@ -61,6 +61,13 @@ func (f *fakeClusterService) GetRecommendations(apiURL, token, clusterID string) return f.recommendations, nil } +func (f *fakeClusterService) DeleteCluster(apiURL, token, clusterID string) error { + f.lastURL = apiURL + f.lastToken = token + f.lastID = clusterID + return f.err +} + func TestGetClusterCmdRunFindsNamedCluster(t *testing.T) { store := &fakeCredentialsStore{creds: service.Credentials{Token: "stored-token"}} clusterService := &fakeClusterService{ @@ -235,7 +242,7 @@ func TestGetClusterCmdRunUsesSelectorWhenNameMissing(t *testing.T) { } func TestFindClusterReturnsErrorWhenMissing(t *testing.T) { - _, err := findCluster([]map[string]any{{"name": "alpha"}}, "beta") + _, err := FindCluster([]map[string]any{{"name": "alpha"}}, "beta") if err == nil { t.Fatal("expected error, got nil") } diff --git a/internal/cli/list/clusters_test.go b/internal/cli/list/clusters_test.go index fce749e..09f42c8 100644 --- a/internal/cli/list/clusters_test.go +++ b/internal/cli/list/clusters_test.go @@ -61,6 +61,13 @@ func (f *fakeClusterService) GetRecommendations(apiURL, token, clusterID string) return f.recommendations, nil } +func (f *fakeClusterService) DeleteCluster(apiURL, token, clusterID string) error { + f.lastURL = apiURL + f.lastToken = token + f.lastID = clusterID + return f.err +} + func TestListClustersCmdRunWritesClusters(t *testing.T) { store := &fakeCredentialsStore{creds: service.Credentials{Token: "stored-token"}} clusterService := &fakeClusterService{ diff --git a/internal/cli/list/recommendations_test.go b/internal/cli/list/recommendations_test.go index cbe3c8c..bad95bb 100644 --- a/internal/cli/list/recommendations_test.go +++ b/internal/cli/list/recommendations_test.go @@ -48,6 +48,13 @@ func (f *fakeRecommendationClusterService) GetRecommendations(apiURL, token, clu return f.recommendations, nil } +func (f *fakeRecommendationClusterService) DeleteCluster(apiURL, token, clusterID string) error { + f.lastURL = apiURL + f.lastToken = token + f.lastID = clusterID + return f.err +} + func TestListRecommendationsCmdRunWritesRecommendations(t *testing.T) { store := &fakeRecommendationCredentialsStore{creds: service.Credentials{Token: "stored-token"}} clusterService := &fakeRecommendationClusterService{ diff --git a/internal/cli/run.go b/internal/cli/run.go index bc165fe..8cf372d 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -11,6 +11,7 @@ import ( "github.com/kedify/cli/internal/cli/apply" "github.com/kedify/cli/internal/cli/auth" clictx "github.com/kedify/cli/internal/cli/context" + "github.com/kedify/cli/internal/cli/delete" "github.com/kedify/cli/internal/cli/get" "github.com/kedify/cli/internal/cli/list" clierrors "github.com/kedify/cli/internal/errors" @@ -20,12 +21,13 @@ import ( ) type CLI struct { - APIURL string `name:"apiurl" help:"Base URL for the Kedify API." default:"https://api.dev.kedify.io/v1" env:"KEDIFY_API_URL"` - Token string `name:"token" help:"Kedify API token." env:"KEDIFY_TOKEN"` - Auth AuthCmd `cmd:"" help:"Authentication helpers."` - Apply ApplyCmd `cmd:"" help:"Apply Kedify recommendations."` - Get GetCmd `cmd:"" help:"Get Kedify resources."` - List ListCmd `cmd:"" help:"List Kedify resources."` + APIURL string `name:"apiurl" help:"Base URL for the Kedify API." default:"https://api.dev.kedify.io/v1" env:"KEDIFY_API_URL"` + Token string `name:"token" help:"Kedify API token." env:"KEDIFY_TOKEN"` + Auth AuthCmd `cmd:"" help:"Authentication helpers."` + Apply ApplyCmd `cmd:"" help:"Apply Kedify recommendations."` + Delete DeleteCmd `cmd:"" help:"Delete Kedify resources."` + Get GetCmd `cmd:"" help:"Get Kedify resources."` + List ListCmd `cmd:"" help:"List Kedify resources."` } type AuthCmd struct { @@ -41,6 +43,10 @@ type ApplyCmd struct { Recommendations apply.ApplyRecommendationsCmd `cmd:"" help:"Apply recommendations to a Helm values file."` } +type DeleteCmd struct { + Cluster delete.DeleteClusterCmd `cmd:"" help:"Delete a cluster by name or id. If no name is provided, an interactive picker is shown."` +} + type ListCmd struct { Clusters list.ListClustersCmd `cmd:"" help:"List clusters."` Recommendations list.ListRecommendationsCmd `cmd:"" help:"List recommendations for a cluster id."` diff --git a/internal/service/cluster.go b/internal/service/cluster.go index 06d7491..8ee5d4d 100644 --- a/internal/service/cluster.go +++ b/internal/service/cluster.go @@ -4,4 +4,5 @@ type ClusterService interface { ListClusters(apiURL, token string) ([]map[string]any, error) GetCluster(apiURL, token, clusterID string) (map[string]any, error) GetRecommendations(apiURL, token, clusterID string) (any, error) + DeleteCluster(apiURL, token, clusterID string) error }