diff --git a/client/client.go b/client/client.go index a051d97..74da971 100644 --- a/client/client.go +++ b/client/client.go @@ -13,6 +13,8 @@ import ( var BaseURL = "https://customer.cloudamqp.com/api" +var MetadataURL = "https://api.cloudamqp.com/api" + type Client struct { apiKey string baseURL string @@ -107,6 +109,32 @@ func (c *Client) makeRequest(method, endpoint string, body any) ([]byte, error) return respBody, nil } +func (c *Client) makeExternalRequest(method, requestURL string) ([]byte, error) { + req, err := http.NewRequest(method, requestURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", fmt.Sprintf("cloudamqp-cli/%s", c.version)) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) + } + + return respBody, nil +} + // Instance-specific operations using /instances/{id}/ endpoints // Node management diff --git a/client/instances.go b/client/instances.go index 3b6c7bf..308b903 100644 --- a/client/instances.go +++ b/client/instances.go @@ -31,6 +31,7 @@ type InstanceCreateRequest struct { Name string `json:"name"` Plan string `json:"plan"` Region string `json:"region"` + RMQVersion string `json:"rmq_version,omitempty"` Tags []string `json:"tags,omitempty"` VPCSubnet string `json:"vpc_subnet,omitempty"` VPCID *int `json:"vpc_id,omitempty"` @@ -97,6 +98,10 @@ func (c *Client) CreateInstance(req *InstanceCreateRequest) (*InstanceCreateResp } } + if req.RMQVersion != "" { + formData.Set("rmq_version", req.RMQVersion) + } + if req.VPCSubnet != "" { formData.Set("vpc_subnet", req.VPCSubnet) } diff --git a/client/instances_test.go b/client/instances_test.go index adea9cd..284448b 100644 --- a/client/instances_test.go +++ b/client/instances_test.go @@ -141,6 +141,31 @@ func TestCreateInstance_WithTags(t *testing.T) { assert.NoError(t, err) } +func TestCreateInstance_WithRMQVersion(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + assert.NoError(t, err) + + assert.Equal(t, "4.0.5", r.FormValue("rmq_version")) + + response := InstanceCreateResponse{ID: 1234} + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := NewWithBaseURL("test-api-key", server.URL, "test") + + req := &InstanceCreateRequest{ + Name: "test-instance", + Plan: "bunny-1", + Region: "amazon-web-services::us-east-1", + RMQVersion: "4.0.5", + } + + _, err := client.CreateInstance(req) + assert.NoError(t, err) +} + func TestCreateInstance_WithVPC(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() diff --git a/client/regions.go b/client/regions.go index d3f3eb4..87bc366 100644 --- a/client/regions.go +++ b/client/regions.go @@ -37,6 +37,20 @@ func (c *Client) ListRegions(provider string) ([]Region, error) { return regions, nil } +func (c *Client) ListVersions() ([]string, error) { + respBody, err := c.makeExternalRequest("GET", MetadataURL+"/metadata/rabbitmq-versions") + if err != nil { + return nil, err + } + + var versions []string + if err := json.Unmarshal(respBody, &versions); err != nil { + return nil, err + } + + return versions, nil +} + func (c *Client) ListPlans(backend string) ([]Plan, error) { endpoint := "/plans" if backend != "" { diff --git a/client/regions_test.go b/client/regions_test.go index dec8acf..1ce2dfe 100644 --- a/client/regions_test.go +++ b/client/regions_test.go @@ -133,3 +133,28 @@ func TestListPlans_WithBackend(t *testing.T) { assert.Len(t, plans, 1) assert.Equal(t, "rabbitmq", plans[0].Backend) } + +func TestListVersions(t *testing.T) { + expectedVersions := []string{"3.13.7", "4.0.5", "4.1.0"} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "/metadata/rabbitmq-versions", r.URL.Path) + assert.Empty(t, r.Header.Get("Authorization"), "metadata endpoint should be called without auth") + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(expectedVersions) + })) + defer server.Close() + + originalMetadataURL := MetadataURL + MetadataURL = server.URL + defer func() { MetadataURL = originalMetadataURL }() + + client := NewWithBaseURL("test-api-key", server.URL, "test") + + versions, err := client.ListVersions() + + assert.NoError(t, err) + assert.Equal(t, expectedVersions, versions) +} diff --git a/cmd/completion_cache.go b/cmd/completion_cache.go index b931f8d..e852ac3 100644 --- a/cmd/completion_cache.go +++ b/cmd/completion_cache.go @@ -106,6 +106,7 @@ func setCachedData(key string, ttl time.Duration, data interface{}) error { const ( plansCacheTTL = 1 * time.Hour // Plans rarely change regionsCacheTTL = 1 * time.Hour // Regions rarely change + versionsCacheTTL = 1 * time.Hour // Versions rarely change instancesCacheTTL = 1 * time.Minute // Instances change frequently vpcsCacheTTL = 1 * time.Minute // VPCs change frequently ) diff --git a/cmd/completion_helpers.go b/cmd/completion_helpers.go index 3c613f8..aaa5b8a 100644 --- a/cmd/completion_helpers.go +++ b/cmd/completion_helpers.go @@ -166,6 +166,63 @@ formatOutput: return suggestions, cobra.ShellCompDirectiveNoFileComp } +// completeVersions returns available RabbitMQ versions for completion. +// Only suggests versions when the selected plan is positively identified as a rabbitmq plan. +func completeVersions(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + apiKey, err := completionAPIKey() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + c := client.New(apiKey, Version) + + planName, _ := cmd.Flags().GetString("plan") + if planName == "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var plans []client.Plan + if cachedData, ok := getCachedData("plans", plansCacheTTL); ok { + _ = json.Unmarshal(cachedData, &plans) + } + if len(plans) == 0 { + plans, err = c.ListPlans("") + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + if len(plans) > 0 { + setCachedData("plans", plansCacheTTL, plans) + } + } + + isRabbitMQPlan := false + for _, p := range plans { + if p.Name == planName { + isRabbitMQPlan = p.Backend == "rabbitmq" + break + } + } + if !isRabbitMQPlan { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var versions []string + if cachedData, ok := getCachedData("versions", versionsCacheTTL); ok { + if err := json.Unmarshal(cachedData, &versions); err == nil { + return versions, cobra.ShellCompDirectiveNoFileComp + } + } + + versions, err = c.ListVersions() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + setCachedData("versions", versionsCacheTTL, versions) + + return versions, cobra.ShellCompDirectiveNoFileComp +} + // completeCopySettings returns the valid copy-settings options func completeCopySettings(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { settings := []string{ diff --git a/cmd/instance_create.go b/cmd/instance_create.go index b71a711..b954581 100644 --- a/cmd/instance_create.go +++ b/cmd/instance_create.go @@ -14,6 +14,7 @@ var ( instanceName string instancePlan string instanceRegion string + instanceRMQVersion string instanceTags []string instanceVPCSubnet string instanceVPCID string @@ -34,6 +35,7 @@ Required flags: --region: Region identifier (e.g., amazon-web-services::us-east-1) Optional flags: + --rmq-version: RabbitMQ version (e.g., 4.0.5) - only for rabbitmq plans --tags: Instance tags (can be specified multiple times) --vpc-subnet: VPC subnet for dedicated VPC --vpc-id: ID of existing VPC to add instance to @@ -55,10 +57,11 @@ Optional flags: c := client.New(apiKey, Version) req := &client.InstanceCreateRequest{ - Name: instanceName, - Plan: instancePlan, - Region: instanceRegion, - Tags: instanceTags, + Name: instanceName, + Plan: instancePlan, + Region: instanceRegion, + RMQVersion: instanceRMQVersion, + Tags: instanceTags, } if instanceVPCSubnet != "" { @@ -118,6 +121,7 @@ func init() { instanceCreateCmd.Flags().StringVar(&instanceName, "name", "", "Name of the instance (required)") instanceCreateCmd.Flags().StringVar(&instancePlan, "plan", "", "Subscription plan (required)") instanceCreateCmd.Flags().StringVar(&instanceRegion, "region", "", "Region identifier (required)") + instanceCreateCmd.Flags().StringVar(&instanceRMQVersion, "rmq-version", "", "RabbitMQ version (e.g., 4.0.5); only applies to rabbitmq plans, ignored otherwise") instanceCreateCmd.Flags().StringSliceVar(&instanceTags, "tags", []string{}, "Instance tags") instanceCreateCmd.Flags().StringVar(&instanceVPCSubnet, "vpc-subnet", "", "VPC subnet") instanceCreateCmd.Flags().StringVar(&instanceVPCID, "vpc-id", "", "VPC ID") @@ -130,6 +134,7 @@ func init() { instanceCreateCmd.MarkFlagRequired("plan") instanceCreateCmd.MarkFlagRequired("region") + instanceCreateCmd.RegisterFlagCompletionFunc("rmq-version", completeVersions) instanceCreateCmd.RegisterFlagCompletionFunc("plan", completePlans) instanceCreateCmd.RegisterFlagCompletionFunc("region", completeRegions) instanceCreateCmd.RegisterFlagCompletionFunc("vpc-id", completeVPCIDFlag)