diff --git a/cmd/cloud/autoscaling_cli_test.go b/cmd/cloud/autoscaling_cli_test.go index bbe6f1301..1f5839a38 100644 --- a/cmd/cloud/autoscaling_cli_test.go +++ b/cmd/cloud/autoscaling_cli_test.go @@ -165,6 +165,26 @@ func TestASGDeleteCmd(t *testing.T) { func TestASGPolicyAddCmd(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/autoscaling/groups" && r.Method == http.MethodGet { + payload := map[string]interface{}{ + "data": []map[string]interface{}{ + { + "id": asgTestID, + "name": asgTestName, + "vpc_id": "vpc-1", + "image": "nginx:latest", + "min_instances": 1, + "max_instances": 3, + "desired_count": 2, + "current_count": 1, + "status": "active", + "created_at": time.Now().UTC().Format(time.RFC3339), + }, + }, + } + _ = json.NewEncoder(w).Encode(payload) + return + } if r.URL.Path != "/autoscaling/groups/"+asgTestID+"/policies" || r.Method != http.MethodPost { w.WriteHeader(http.StatusNotFound) return diff --git a/cmd/cloud/dns.go b/cmd/cloud/dns.go index ff1ff6fa9..373cc6e56 100644 --- a/cmd/cloud/dns.go +++ b/cmd/cloud/dns.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "strconv" - "strings" "github.com/google/uuid" "github.com/olekukonko/tablewriter" @@ -61,38 +60,15 @@ var dnsCreateZoneCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { name := args[0] desc, _ := cmd.Flags().GetString("description") - vpcStr, _ := cmd.Flags().GetString("vpc-id") - - var vpcID *uuid.UUID - if vpcStr != "" { - // Try to resolve as full UUID first - uid, err := uuid.Parse(vpcStr) - if err != nil { - // Not a UUID, try to resolve by name or short ID - client := createClient(opts) - vpcs, err := client.ListVPCs() - if err == nil { - for _, vpc := range vpcs { - if vpc.Name == vpcStr || strings.HasPrefix(vpc.ID, vpcStr) { - uid, err := uuid.Parse(vpc.ID) - if err == nil { - vpcID = &uid - break - } - } - } - } - if vpcID == nil { - fmt.Printf("Error: invalid vpc-id format: %v\n", err) - return - } - } else { - vpcID = &uid - } + vpcIDOrName, _ := cmd.Flags().GetString("vpc-id") + + var vpcPtr *string + if vpcIDOrName != "" { + vpcPtr = &vpcIDOrName } client := createClient(opts) - zone, err := client.CreateDNSZone(name, desc, vpcID) + zone, err := client.CreateDNSZone(name, desc, vpcPtr) if err != nil { fmt.Printf(dnsErrorFormat, err) return diff --git a/cmd/cloud/snapshot.go b/cmd/cloud/snapshot.go index ff7238de4..9195c7c77 100644 --- a/cmd/cloud/snapshot.go +++ b/cmd/cloud/snapshot.go @@ -5,7 +5,6 @@ import ( "fmt" "os" - "github.com/google/uuid" "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" ) @@ -60,19 +59,19 @@ var snapshotListCmd = &cobra.Command{ } var snapshotCreateCmd = &cobra.Command{ - Use: "create [volume-id]", + Use: "create [volume-id|volume-name]", Short: "Create a snapshot from a volume", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - volID, err := uuid.Parse(args[0]) + volIDOrName := args[0] + desc, err := cmd.Flags().GetString("desc") if err != nil { fmt.Printf(snapshotErrorFormat, err) return } - desc, _ := cmd.Flags().GetString("desc") client := createClient(opts) - snapshot, err := client.CreateSnapshot(volID, desc) + snapshot, err := client.CreateSnapshot(cmd.Context(), volIDOrName, desc) if err != nil { fmt.Printf(snapshotErrorFormat, err) return diff --git a/internal/ccm/instances_test.go b/internal/ccm/instances_test.go index 43ed792bb..d57c7aa9d 100644 --- a/internal/ccm/instances_test.go +++ b/internal/ccm/instances_test.go @@ -21,7 +21,16 @@ func TestInstancesV2(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { - case "/instances/test-node": + case "/instances": + // List instances for resolveID lookup + instances := []sdk.Instance{ + {ID: "inst-123", Name: "test-node", PrivateIP: "10.0.0.5", Status: "RUNNING", InstanceType: "standard-2"}, + {ID: "inst-456", Name: "stopped-node", Status: StatusStopped}, + } + if err := json.NewEncoder(w).Encode(sdk.Response[[]sdk.Instance]{Data: instances}); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + case "/instances/inst-123": inst := sdk.Instance{ ID: "inst-123", Name: "test-node", @@ -32,7 +41,7 @@ func TestInstancesV2(t *testing.T) { if err := json.NewEncoder(w).Encode(sdk.Response[sdk.Instance]{Data: inst}); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } - case "/instances/stopped-node": + case "/instances/inst-456": inst := sdk.Instance{ ID: "inst-456", Status: StatusStopped, diff --git a/internal/csi/driver_test.go b/internal/csi/driver_test.go index 56d81883e..b6e4b62c7 100644 --- a/internal/csi/driver_test.go +++ b/internal/csi/driver_test.go @@ -160,7 +160,12 @@ func TestDriver_ControllerServer(t *testing.T) { w.Write([]byte(`{"data": "success"}`)) return } - if r.Method == "GET" && r.URL.Path == "/instances/node-1" { + if r.Method == "GET" && r.URL.Path == "/instances" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data": [{"id": "inst-123", "name": "node-1"}]}`)) + return + } + if r.Method == "GET" && r.URL.Path == "/instances/inst-123" { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"data": {"id": "inst-123", "name": "node-1"}}`)) return diff --git a/pkg/sdk/autoscaling.go b/pkg/sdk/autoscaling.go index 340a80333..5c247325b 100644 --- a/pkg/sdk/autoscaling.go +++ b/pkg/sdk/autoscaling.go @@ -117,10 +117,17 @@ type CreatePolicyRequest struct { CooldownSec int `json:"cooldown_sec"` } -func (c *Client) CreateScalingPolicy(groupID string, req CreatePolicyRequest) error { +func (c *Client) CreateScalingPolicy(groupIDOrName string, req CreatePolicyRequest) error { + id, err := c.resolveID("scaling-group", func() ([]interface{}, error) { + groups, err := c.ListScalingGroups() + return interfaceSlice(groups), err + }, func(v interface{}) string { return v.(ScalingGroup).ID }, func(v interface{}) string { return v.(ScalingGroup).Name }, groupIDOrName) + if err != nil { + return err + } resp, err := c.resty.R(). SetBody(req). - Post(fmt.Sprintf("%s/autoscaling/groups/%s/policies", c.apiURL, groupID)) + Post(fmt.Sprintf("%s/autoscaling/groups/%s/policies", c.apiURL, id)) if err != nil { return err diff --git a/pkg/sdk/client.go b/pkg/sdk/client.go index 25f1bdca8..4ca889cca 100644 --- a/pkg/sdk/client.go +++ b/pkg/sdk/client.go @@ -216,6 +216,43 @@ func (c *Client) resolveID(resourceType string, listFn func() ([]interface{}, er return matches[0], nil } +// resolveIDWithContext resolves a partial ID or name to a full UUID with context support. +func (c *Client) resolveIDWithContext(ctx context.Context, resourceType string, listFn func(context.Context) ([]interface{}, error), getID func(interface{}) string, getName func(interface{}) string, idOrName string) (string, error) { + // If it's a valid UUID, use it directly + if _, err := uuid.Parse(idOrName); err == nil { + return idOrName, nil + } + + // Try to resolve by name or prefix + items, err := listFn(ctx) + if err != nil { + return "", err + } + + // Check for exact name match + for _, item := range items { + if getName(item) == idOrName { + return getID(item), nil + } + } + + // Try prefix match - track matches for ambiguity check + var matches []string + for _, item := range items { + if strings.HasPrefix(getID(item), idOrName) { + matches = append(matches, getID(item)) + } + } + + if len(matches) == 0 { + return "", fmt.Errorf("%s not found: %s", resourceType, idOrName) + } + if len(matches) > 1 { + return "", fmt.Errorf("%s ambiguous: %s matches %d resources", resourceType, idOrName, len(matches)) + } + return matches[0], nil +} + // interfaceSlice converts a slice of any type to []interface{} func interfaceSlice[T any](slice []T) []interface{} { result := make([]interface{}, len(slice)) diff --git a/pkg/sdk/compute.go b/pkg/sdk/compute.go index 05d5abd67..a8acb5701 100644 --- a/pkg/sdk/compute.go +++ b/pkg/sdk/compute.go @@ -45,27 +45,43 @@ func (c *Client) ListInstancesWithContext(ctx context.Context) ([]Instance, erro return res.Data, nil } +// resolveInstanceIDWithContext resolves an instance ID or name to a full ID with context support. +func (c *Client) resolveInstanceIDWithContext(ctx context.Context, idOrName string) (string, error) { + return c.resolveID("instance", func() ([]interface{}, error) { + instances, err := c.ListInstancesWithContext(ctx) + return interfaceSlice(instances), err + }, func(v interface{}) string { return v.(Instance).ID }, func(v interface{}) string { return v.(Instance).Name }, idOrName) +} + // GetInstance retrieves a compute instance by ID or name. func (c *Client) GetInstance(idOrName string) (*Instance, error) { - var res Response[Instance] - if err := c.get(fmt.Sprintf("/instances/%s", idOrName), &res); err != nil { - return nil, err - } - return &res.Data, nil + return c.GetInstanceWithContext(context.Background(), idOrName) } // GetInstanceWithContext retrieves a compute instance with context support. func (c *Client) GetInstanceWithContext(ctx context.Context, idOrName string) (*Instance, error) { + id, err := c.resolveInstanceIDWithContext(ctx, idOrName) + if err != nil { + return nil, err + } var res Response[Instance] - if err := c.getWithContext(ctx, fmt.Sprintf("/instances/%s", idOrName), &res); err != nil { + if err := c.getWithContext(ctx, fmt.Sprintf("/instances/%s", id), &res); err != nil { return nil, err } return &res.Data, nil } func (c *Client) GetConsoleURL(idOrName string) (string, error) { + return c.GetConsoleURLWithContext(context.Background(), idOrName) +} + +func (c *Client) GetConsoleURLWithContext(ctx context.Context, idOrName string) (string, error) { + id, err := c.resolveInstanceIDWithContext(ctx, idOrName) + if err != nil { + return "", err + } var res Response[string] - if err := c.get(fmt.Sprintf("/instances/%s/console", idOrName), &res); err != nil { + if err := c.getWithContext(ctx, fmt.Sprintf("/instances/%s/console", id), &res); err != nil { return "", err } return res.Data, nil @@ -100,17 +116,33 @@ func (c *Client) LaunchInstance(name, image, ports, instanceType string, vpcID, } // UpdateInstanceMetadata updates the metadata and labels of an instance. -func (c *Client) UpdateInstanceMetadata(id string, metadata, labels map[string]string) error { +func (c *Client) UpdateInstanceMetadata(idOrName string, metadata, labels map[string]string) error { + return c.UpdateInstanceMetadataWithContext(context.Background(), idOrName, metadata, labels) +} + +func (c *Client) UpdateInstanceMetadataWithContext(ctx context.Context, idOrName string, metadata, labels map[string]string) error { + id, err := c.resolveInstanceIDWithContext(ctx, idOrName) + if err != nil { + return err + } body := map[string]interface{}{ "metadata": metadata, "labels": labels, } - return c.put(fmt.Sprintf("/instances/%s/metadata", id), body, nil) + return c.putWithContext(ctx, fmt.Sprintf("/instances/%s/metadata", id), body, nil) } // StopInstance stops a running instance by ID or name. func (c *Client) StopInstance(idOrName string) error { - return c.post(fmt.Sprintf("/instances/%s/stop", idOrName), nil, nil) + return c.StopInstanceWithContext(context.Background(), idOrName) +} + +func (c *Client) StopInstanceWithContext(ctx context.Context, idOrName string) error { + id, err := c.resolveInstanceIDWithContext(ctx, idOrName) + if err != nil { + return err + } + return c.postWithContext(ctx, fmt.Sprintf("/instances/%s/stop", id), nil, nil) } // TerminateInstance deletes an instance by ID or name. @@ -120,12 +152,24 @@ func (c *Client) TerminateInstance(idOrName string) error { // TerminateInstanceWithContext deletes an instance with context support. func (c *Client) TerminateInstanceWithContext(ctx context.Context, idOrName string) error { - return c.deleteWithContext(ctx, fmt.Sprintf("/instances/%s", idOrName), nil) + id, err := c.resolveInstanceIDWithContext(ctx, idOrName) + if err != nil { + return err + } + return c.deleteWithContext(ctx, fmt.Sprintf("/instances/%s", id), nil) } // GetInstanceLogs retrieves the raw log output for an instance. func (c *Client) GetInstanceLogs(idOrName string) (string, error) { - resp, err := c.resty.R().Get(c.apiURL + fmt.Sprintf("/instances/%s/logs", idOrName)) + return c.GetInstanceLogsWithContext(context.Background(), idOrName) +} + +func (c *Client) GetInstanceLogsWithContext(ctx context.Context, idOrName string) (string, error) { + id, err := c.resolveInstanceIDWithContext(ctx, idOrName) + if err != nil { + return "", err + } + resp, err := c.resty.R().SetContext(ctx).Get(c.apiURL + fmt.Sprintf("/instances/%s/logs", id)) if err != nil { return "", err } @@ -137,10 +181,18 @@ func (c *Client) GetInstanceLogs(idOrName string) (string, error) { // ResizeInstance changes the instance type of a running or stopped instance. func (c *Client) ResizeInstance(idOrName, newInstanceType string) error { + return c.ResizeInstanceWithContext(context.Background(), idOrName, newInstanceType) +} + +func (c *Client) ResizeInstanceWithContext(ctx context.Context, idOrName, newInstanceType string) error { + id, err := c.resolveInstanceIDWithContext(ctx, idOrName) + if err != nil { + return err + } body := map[string]string{ "instance_type": newInstanceType, } - return c.post(fmt.Sprintf("/instances/%s/resize", idOrName), body, nil) + return c.postWithContext(ctx, fmt.Sprintf("/instances/%s/resize", id), body, nil) } // InstanceStats captures resource usage for an instance. @@ -153,8 +205,16 @@ type InstanceStats struct { // GetInstanceStats returns resource usage metrics for an instance. func (c *Client) GetInstanceStats(idOrName string) (*InstanceStats, error) { + return c.GetInstanceStatsWithContext(context.Background(), idOrName) +} + +func (c *Client) GetInstanceStatsWithContext(ctx context.Context, idOrName string) (*InstanceStats, error) { + id, err := c.resolveInstanceIDWithContext(ctx, idOrName) + if err != nil { + return nil, err + } var res Response[InstanceStats] - if err := c.get(fmt.Sprintf("/instances/%s/stats", idOrName), &res); err != nil { + if err := c.getWithContext(ctx, fmt.Sprintf("/instances/%s/stats", id), &res); err != nil { return nil, err } return &res.Data, nil diff --git a/pkg/sdk/compute_test.go b/pkg/sdk/compute_test.go index 14f1388af..32df954c9 100644 --- a/pkg/sdk/compute_test.go +++ b/pkg/sdk/compute_test.go @@ -55,6 +55,12 @@ func TestClientGetInstance(t *testing.T) { } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances" && r.Method == "GET" { + w.Header().Set(computeContentType, computeApplicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Response[[]Instance]{Data: []Instance{mockInstance}}) + return + } assert.Equal(t, computeInstancesPath+computeInstanceID, r.URL.Path) assert.Equal(t, "GET", r.Method) @@ -102,10 +108,21 @@ func TestClientLaunchInstance(t *testing.T) { } func TestClientStopInstance(t *testing.T) { + mockInstance := Instance{ + ID: computeInstanceID, + Name: "test-instance", + Status: "running", + } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances" && r.Method == "GET" { + w.Header().Set(computeContentType, computeApplicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Response[[]Instance]{Data: []Instance{mockInstance}}) + return + } assert.Equal(t, computeInstancesPath+computeInstanceID+"/stop", r.URL.Path) assert.Equal(t, "POST", r.Method) - w.Header().Set(computeContentType, computeApplicationJSON) // Added for consistency, though no body is returned + w.Header().Set(computeContentType, computeApplicationJSON) w.WriteHeader(http.StatusOK) })) defer server.Close() @@ -117,7 +134,18 @@ func TestClientStopInstance(t *testing.T) { } func TestClientTerminateInstance(t *testing.T) { + mockInstance := Instance{ + ID: computeInstanceID, + Name: "test-instance", + Status: "running", + } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances" && r.Method == "GET" { + w.Header().Set(computeContentType, computeApplicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Response[[]Instance]{Data: []Instance{mockInstance}}) + return + } assert.Equal(t, computeInstancesPath+computeInstanceID, r.URL.Path) assert.Equal(t, "DELETE", r.Method) w.WriteHeader(http.StatusOK) @@ -132,8 +160,19 @@ func TestClientTerminateInstance(t *testing.T) { func TestClientGetInstanceLogs(t *testing.T) { mockLogs := "hello world\n" + mockInstance := Instance{ + ID: computeInstanceID, + Name: "test-instance", + Status: "running", + } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances" && r.Method == "GET" { + w.Header().Set(computeContentType, computeApplicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Response[[]Instance]{Data: []Instance{mockInstance}}) + return + } assert.Equal(t, computeInstancesPath+computeInstanceID+"/logs", r.URL.Path) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(mockLogs)) @@ -148,7 +187,18 @@ func TestClientGetInstanceLogs(t *testing.T) { } func TestClientGetInstanceLogsErrorStatus(t *testing.T) { + mockInstance := Instance{ + ID: computeInstanceID, + Name: "test-instance", + Status: "running", + } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances" && r.Method == "GET" { + w.Header().Set(computeContentType, computeApplicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Response[[]Instance]{Data: []Instance{mockInstance}}) + return + } assert.Equal(t, computeInstancesPath+computeInstanceID+"/logs", r.URL.Path) w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("boom")) @@ -174,8 +224,19 @@ func TestClientGetInstanceStats(t *testing.T) { CPUPercentage: 15.5, MemoryUsageBytes: 1024 * 1024 * 10, } + mockInstance := Instance{ + ID: computeInstanceID, + Name: "test-instance", + Status: "running", + } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances" && r.Method == "GET" { + w.Header().Set(computeContentType, computeApplicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Response[[]Instance]{Data: []Instance{mockInstance}}) + return + } assert.Equal(t, computeInstancesPath+computeInstanceID+"/stats", r.URL.Path) w.Header().Set(computeContentType, computeApplicationJSON) w.WriteHeader(http.StatusOK) @@ -191,7 +252,18 @@ func TestClientGetInstanceStats(t *testing.T) { } func TestClientComputeErrors(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mockInstance := Instance{ + ID: computeInstanceID, + Name: "test-instance", + Status: "running", + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances" && r.Method == "GET" { + w.Header().Set(computeContentType, computeApplicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Response[[]Instance]{Data: []Instance{mockInstance}}) + return + } w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("boom")) })) @@ -234,7 +306,18 @@ func TestClientAPIError(t *testing.T) { } func TestClientResizeInstance(t *testing.T) { + mockInstance := Instance{ + ID: computeInstanceID, + Name: "test-instance", + Status: "running", + } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances" && r.Method == "GET" { + w.Header().Set(computeContentType, computeApplicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Response[[]Instance]{Data: []Instance{mockInstance}}) + return + } assert.Equal(t, computeInstancesPath+computeInstanceID+"/resize", r.URL.Path) assert.Equal(t, http.MethodPost, r.Method) @@ -255,7 +338,18 @@ func TestClientResizeInstance(t *testing.T) { } func TestClientResizeInstanceError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mockInstance := Instance{ + ID: computeInstanceID, + Name: "test-instance", + Status: "running", + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances" && r.Method == "GET" { + w.Header().Set(computeContentType, computeApplicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Response[[]Instance]{Data: []Instance{mockInstance}}) + return + } w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("boom")) })) diff --git a/pkg/sdk/dns.go b/pkg/sdk/dns.go index 0b6b8c82a..a9d9e6322 100644 --- a/pkg/sdk/dns.go +++ b/pkg/sdk/dns.go @@ -17,13 +17,20 @@ func (c *Client) ListDNSZones() ([]domain.DNSZone, error) { } // CreateDNSZone creates a new DNS zone. -func (c *Client) CreateDNSZone(name, description string, vpcID *uuid.UUID) (*domain.DNSZone, error) { +func (c *Client) CreateDNSZone(name, description string, vpcIDOrName *string) (*domain.DNSZone, error) { payload := map[string]interface{}{ "name": name, "description": description, } - if vpcID != nil { - payload["vpc_id"] = vpcID.String() + if vpcIDOrName != nil { + id, err := c.resolveID("vpc", func() ([]interface{}, error) { + vpcs, err := c.ListVPCs() + return interfaceSlice(vpcs), err + }, func(v interface{}) string { return v.(VPC).ID }, func(v interface{}) string { return v.(VPC).Name }, *vpcIDOrName) + if err != nil { + return nil, err + } + payload["vpc_id"] = id } var resp struct { diff --git a/pkg/sdk/kubernetes.go b/pkg/sdk/kubernetes.go index a429ee3dc..5a72e6254 100644 --- a/pkg/sdk/kubernetes.go +++ b/pkg/sdk/kubernetes.go @@ -90,6 +90,23 @@ func (c *Client) ListClusters() ([]*Cluster, error) { return resp.Data, nil } +// ListClustersWithContext returns all clusters for the current user with context support. +func (c *Client) ListClustersWithContext(ctx context.Context) ([]*Cluster, error) { + var resp Response[[]*Cluster] + if err := c.getWithContext(ctx, clustersPath, &resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +// resolveClusterIDWithContext resolves a cluster ID or name to a full UUID with context support. +func (c *Client) resolveClusterIDWithContext(ctx context.Context, idOrName string) (string, error) { + return c.resolveID("cluster", func() ([]interface{}, error) { + clusters, err := c.ListClustersWithContext(ctx) + return interfaceSlicePtr(clusters), err + }, func(v interface{}) string { return v.(*Cluster).ID.String() }, func(v interface{}) string { return v.(*Cluster).Name }, idOrName) +} + // CreateCluster initiates cluster provisioning. func (c *Client) CreateCluster(input *CreateClusterInput) (*Cluster, error) { var resp Response[*Cluster] @@ -99,13 +116,17 @@ func (c *Client) CreateCluster(input *CreateClusterInput) (*Cluster, error) { return resp.Data, nil } -// GetCluster retrieves cluster details by ID. -func (c *Client) GetCluster(id string) (*Cluster, error) { - return c.GetClusterWithContext(context.Background(), id) +// GetCluster retrieves cluster details by ID or name. +func (c *Client) GetCluster(idOrName string) (*Cluster, error) { + return c.GetClusterWithContext(context.Background(), idOrName) } -// GetClusterWithContext retrieves cluster details by ID with context support. -func (c *Client) GetClusterWithContext(ctx context.Context, id string) (*Cluster, error) { +// GetClusterWithContext retrieves cluster details by ID or name with context support. +func (c *Client) GetClusterWithContext(ctx context.Context, idOrName string) (*Cluster, error) { + id, err := c.resolveClusterIDWithContext(ctx, idOrName) + if err != nil { + return nil, err + } var resp Response[*Cluster] if err := c.getWithContext(ctx, clustersPath+"/"+id, &resp); err != nil { return nil, err @@ -114,41 +135,86 @@ func (c *Client) GetClusterWithContext(ctx context.Context, id string) (*Cluster } // DeleteCluster removes a cluster. -func (c *Client) DeleteCluster(id string) error { +func (c *Client) DeleteCluster(idOrName string) error { + return c.DeleteClusterWithContext(context.Background(), idOrName) +} + +// DeleteClusterWithContext removes a cluster with context support. +func (c *Client) DeleteClusterWithContext(ctx context.Context, idOrName string) error { + id, err := c.resolveClusterIDWithContext(ctx, idOrName) + if err != nil { + return err + } var resp Response[any] - return c.delete(clustersPath+"/"+id, &resp) + return c.deleteWithContext(ctx, clustersPath+"/"+id, &resp) } // GetKubeconfig retrieves the cluster kubeconfig, optionally for a specific role. -func (c *Client) GetKubeconfig(id string, role string) (string, error) { +func (c *Client) GetKubeconfig(idOrName string, role string) (string, error) { + return c.GetKubeconfigWithContext(context.Background(), idOrName, role) +} + +// GetKubeconfigWithContext retrieves the cluster kubeconfig with context support. +func (c *Client) GetKubeconfigWithContext(ctx context.Context, idOrName string, role string) (string, error) { + id, err := c.resolveClusterIDWithContext(ctx, idOrName) + if err != nil { + return "", err + } path := clustersPath + "/" + id + "/kubeconfig" if role != "" { - path += "?role=" + role + path += "?role=" + url.QueryEscape(role) } var resp Response[string] - if err := c.get(path, &resp); err != nil { + if err := c.getWithContext(ctx, path, &resp); err != nil { return "", err } return resp.Data, nil } // RepairCluster triggers a re-run of critical provisioning steps. -func (c *Client) RepairCluster(id string) error { +func (c *Client) RepairCluster(idOrName string) error { + return c.RepairClusterWithContext(context.Background(), idOrName) +} + +// RepairClusterWithContext triggers a re-run of critical provisioning steps with context support. +func (c *Client) RepairClusterWithContext(ctx context.Context, idOrName string) error { + id, err := c.resolveClusterIDWithContext(ctx, idOrName) + if err != nil { + return err + } var resp Response[any] - return c.post(clustersPath+"/"+id+"/repair", nil, &resp) + return c.postWithContext(ctx, clustersPath+"/"+id+"/repair", nil, &resp) } // ScaleCluster adjusts the number of worker nodes. -func (c *Client) ScaleCluster(id string, workers int) error { +func (c *Client) ScaleCluster(idOrName string, workers int) error { + return c.ScaleClusterWithContext(context.Background(), idOrName, workers) +} + +// ScaleClusterWithContext adjusts the number of worker nodes with context support. +func (c *Client) ScaleClusterWithContext(ctx context.Context, idOrName string, workers int) error { + id, err := c.resolveClusterIDWithContext(ctx, idOrName) + if err != nil { + return err + } var resp Response[any] input := &ScaleClusterInput{Workers: workers} - return c.post(clustersPath+"/"+id+"/scale", input, &resp) + return c.postWithContext(ctx, clustersPath+"/"+id+"/scale", input, &resp) } // GetClusterHealth retrieved the operational health of the cluster. -func (c *Client) GetClusterHealth(id string) (*ClusterHealth, error) { +func (c *Client) GetClusterHealth(idOrName string) (*ClusterHealth, error) { + return c.GetClusterHealthWithContext(context.Background(), idOrName) +} + +// GetClusterHealthWithContext retrieves the operational health of the cluster with context support. +func (c *Client) GetClusterHealthWithContext(ctx context.Context, idOrName string) (*ClusterHealth, error) { + id, err := c.resolveClusterIDWithContext(ctx, idOrName) + if err != nil { + return nil, err + } var resp Response[*ClusterHealth] - if err := c.get(clustersPath+"/"+id+"/health", &resp); err != nil { + if err := c.getWithContext(ctx, clustersPath+"/"+id+"/health", &resp); err != nil { return nil, err } return resp.Data, nil @@ -160,22 +226,49 @@ type UpgradeClusterInput struct { } // UpgradeCluster initiates an asynchronous version upgrade. -func (c *Client) UpgradeCluster(id string, version string) error { +func (c *Client) UpgradeCluster(idOrName string, version string) error { + return c.UpgradeClusterWithContext(context.Background(), idOrName, version) +} + +// UpgradeClusterWithContext initiates an asynchronous version upgrade with context support. +func (c *Client) UpgradeClusterWithContext(ctx context.Context, idOrName string, version string) error { + id, err := c.resolveClusterIDWithContext(ctx, idOrName) + if err != nil { + return err + } var resp Response[any] input := &UpgradeClusterInput{Version: version} - return c.post(clustersPath+"/"+id+"/upgrade", input, &resp) + return c.postWithContext(ctx, clustersPath+"/"+id+"/upgrade", input, &resp) } // RotateSecrets triggers a renewal of cluster certificates. -func (c *Client) RotateSecrets(id string) error { +func (c *Client) RotateSecrets(idOrName string) error { + return c.RotateSecretsWithContext(context.Background(), idOrName) +} + +// RotateSecretsWithContext triggers a renewal of cluster certificates with context support. +func (c *Client) RotateSecretsWithContext(ctx context.Context, idOrName string) error { + id, err := c.resolveClusterIDWithContext(ctx, idOrName) + if err != nil { + return err + } var resp Response[any] - return c.post(clustersPath+"/"+id+"/rotate-secrets", nil, &resp) + return c.postWithContext(ctx, clustersPath+"/"+id+"/rotate-secrets", nil, &resp) } // CreateBackup initiates a cluster state snapshot. -func (c *Client) CreateBackup(id string) error { +func (c *Client) CreateBackup(idOrName string) error { + return c.CreateBackupWithContext(context.Background(), idOrName) +} + +// CreateBackupWithContext initiates a cluster state snapshot with context support. +func (c *Client) CreateBackupWithContext(ctx context.Context, idOrName string) error { + id, err := c.resolveClusterIDWithContext(ctx, idOrName) + if err != nil { + return err + } var resp Response[any] - return c.post(clustersPath+"/"+id+"/backups", nil, &resp) + return c.postWithContext(ctx, clustersPath+"/"+id+"/backups", nil, &resp) } // RestoreBackupInput defines the input for restoring a cluster from backup. @@ -184,32 +277,63 @@ type RestoreBackupInput struct { } // RestoreBackup initiates a cluster restoration from a specific path. -func (c *Client) RestoreBackup(id string, backupPath string) error { +func (c *Client) RestoreBackup(idOrName string, backupPath string) error { + return c.RestoreBackupWithContext(context.Background(), idOrName, backupPath) +} + +// RestoreBackupWithContext initiates a cluster restoration from a specific path with context support. +func (c *Client) RestoreBackupWithContext(ctx context.Context, idOrName string, backupPath string) error { + id, err := c.resolveClusterIDWithContext(ctx, idOrName) + if err != nil { + return err + } var resp Response[any] input := &RestoreBackupInput{BackupPath: backupPath} - return c.post(clustersPath+"/"+id+"/restore", input, &resp) + return c.postWithContext(ctx, clustersPath+"/"+id+"/restore", input, &resp) } // AddNodeGroup adds a new node pool to the cluster. -func (c *Client) AddNodeGroup(clusterID string, input NodeGroupInput) (*NodeGroup, error) { +func (c *Client) AddNodeGroup(clusterIDOrName string, input NodeGroupInput) (*NodeGroup, error) { + return c.AddNodeGroupWithContext(context.Background(), clusterIDOrName, input) +} + +// AddNodeGroupWithContext adds a new node pool to the cluster with context support. +func (c *Client) AddNodeGroupWithContext(ctx context.Context, clusterIDOrName string, input NodeGroupInput) (*NodeGroup, error) { + id, err := c.resolveClusterIDWithContext(ctx, clusterIDOrName) + if err != nil { + return nil, err + } var resp Response[*NodeGroup] - if err := c.post(clustersPath+"/"+clusterID+"/nodegroups", input, &resp); err != nil { + if err := c.postWithContext(ctx, clustersPath+"/"+id+"/nodegroups", input, &resp); err != nil { return nil, err } return resp.Data, nil } // UpdateNodeGroupWithContext updates a node group's parameters with context support. -func (c *Client) UpdateNodeGroupWithContext(ctx context.Context, clusterID string, name string, input UpdateNodeGroupInput) (*NodeGroup, error) { +func (c *Client) UpdateNodeGroupWithContext(ctx context.Context, clusterIDOrName string, name string, input UpdateNodeGroupInput) (*NodeGroup, error) { + id, err := c.resolveClusterIDWithContext(ctx, clusterIDOrName) + if err != nil { + return nil, err + } var resp Response[*NodeGroup] - if err := c.putWithContext(ctx, clustersPath+"/"+clusterID+"/nodegroups/"+url.PathEscape(name), input, &resp); err != nil { + if err := c.putWithContext(ctx, clustersPath+"/"+id+"/nodegroups/"+url.PathEscape(name), input, &resp); err != nil { return nil, err } return resp.Data, nil } // DeleteNodeGroup removes a node group. -func (c *Client) DeleteNodeGroup(clusterID string, name string) error { +func (c *Client) DeleteNodeGroup(clusterIDOrName string, name string) error { + return c.DeleteNodeGroupWithContext(context.Background(), clusterIDOrName, name) +} + +// DeleteNodeGroupWithContext removes a node group with context support. +func (c *Client) DeleteNodeGroupWithContext(ctx context.Context, clusterIDOrName string, name string) error { + id, err := c.resolveClusterIDWithContext(ctx, clusterIDOrName) + if err != nil { + return err + } var resp Response[any] - return c.delete(clustersPath+"/"+clusterID+"/nodegroups/"+url.PathEscape(name), &resp) + return c.deleteWithContext(ctx, clustersPath+"/"+id+"/nodegroups/"+url.PathEscape(name), &resp) } diff --git a/pkg/sdk/snapshot.go b/pkg/sdk/snapshot.go index 9ccb7b09b..759c6fccd 100644 --- a/pkg/sdk/snapshot.go +++ b/pkg/sdk/snapshot.go @@ -2,20 +2,27 @@ package sdk import ( + "context" "fmt" - "github.com/google/uuid" "github.com/poyrazk/thecloud/internal/core/domain" ) -func (c *Client) CreateSnapshot(volumeID uuid.UUID, description string) (*domain.Snapshot, error) { +func (c *Client) CreateSnapshot(ctx context.Context, volumeIDOrName string, description string) (*domain.Snapshot, error) { + id, err := c.resolveIDWithContext(ctx, "volume", func(ctx context.Context) ([]interface{}, error) { + vols, err := c.ListVolumesWithContext(ctx) + return interfaceSlice(vols), err + }, func(v interface{}) string { return v.(Volume).ID.String() }, func(v interface{}) string { return v.(Volume).Name }, volumeIDOrName) + if err != nil { + return nil, err + } req := map[string]interface{}{ - "volume_id": volumeID, + "volume_id": id, "description": description, } var snapshot domain.Snapshot - err := c.post("/snapshots", req, &snapshot) + err = c.postWithContext(ctx, "/snapshots", req, &snapshot) if err != nil { return nil, err } diff --git a/pkg/sdk/snapshot_test.go b/pkg/sdk/snapshot_test.go index 71361063a..2543261bc 100644 --- a/pkg/sdk/snapshot_test.go +++ b/pkg/sdk/snapshot_test.go @@ -1,6 +1,7 @@ package sdk import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -25,6 +26,7 @@ const ( func TestClientCreateSnapshot(t *testing.T) { volumeID := uuid.New() + volumeIDStr := volumeID.String() expectedSnapshot := domain.Snapshot{ ID: uuid.New(), VolumeID: volumeID, @@ -34,13 +36,24 @@ func TestClientCreateSnapshot(t *testing.T) { } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/volumes" && r.Method == http.MethodGet { + w.Header().Set(snapshotContentType, snapshotApplicationJSON) + err := json.NewEncoder(w).Encode(Response[[]Volume]{ + Data: []Volume{{ID: volumeID, Name: "test-volume"}}, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return + } assert.Equal(t, snapshotPath, r.URL.Path) assert.Equal(t, http.MethodPost, r.Method) var req map[string]interface{} err := json.NewDecoder(r.Body).Decode(&req) assert.NoError(t, err) - assert.Equal(t, volumeID.String(), req["volume_id"]) + assert.Equal(t, volumeIDStr, req["volume_id"]) assert.Equal(t, expectedSnapshot.Description, req["description"]) w.Header().Set(snapshotContentType, snapshotApplicationJSON) @@ -49,7 +62,7 @@ func TestClientCreateSnapshot(t *testing.T) { defer server.Close() client := NewClient(server.URL, snapshotAPIKey) - snapshot, err := client.CreateSnapshot(volumeID, snapshotDescription) + snapshot, err := client.CreateSnapshot(context.Background(), volumeIDStr, snapshotDescription) require.NoError(t, err) assert.NotNil(t, snapshot) @@ -157,7 +170,7 @@ func TestClientSnapshotErrors(t *testing.T) { defer server.Close() client := NewClient(server.URL, snapshotAPIKey) - _, err := client.CreateSnapshot(uuid.New(), "snap") + _, err := client.CreateSnapshot(context.Background(), uuid.New().String(), "snap") require.Error(t, err) _, err = client.ListSnapshots() diff --git a/pkg/sdk/volume.go b/pkg/sdk/volume.go index 07c68e8bb..e4ed28539 100644 --- a/pkg/sdk/volume.go +++ b/pkg/sdk/volume.go @@ -2,6 +2,7 @@ package sdk import ( + "context" "fmt" "time" @@ -28,6 +29,14 @@ func (c *Client) ListVolumes() ([]Volume, error) { return res.Data, nil } +func (c *Client) ListVolumesWithContext(ctx context.Context) ([]Volume, error) { + var res Response[[]Volume] + if err := c.getWithContext(ctx, "/volumes", &res); err != nil { + return nil, err + } + return res.Data, nil +} + func (c *Client) CreateVolume(name string, sizeGB int) (*Volume, error) { body := map[string]interface{}{ "name": name,