From aa7befa815c85475d1d8aa199320330fbf23e3bf Mon Sep 17 00:00:00 2001 From: markus812498 Date: Thu, 16 Apr 2026 19:16:35 +1200 Subject: [PATCH 1/5] allow to select rmq plan when creating instance --- client/client.go | 27 ++++++++++++++++++++++ client/instances.go | 5 +++++ client/regions.go | 14 ++++++++++++ cmd/completion_cache.go | 1 + cmd/completion_helpers.go | 47 +++++++++++++++++++++++++++++++++++++++ cmd/instance_create.go | 20 +++++++++++++++++ 6 files changed, 114 insertions(+) diff --git a/client/client.go b/client/client.go index a051d97..d06b7d6 100644 --- a/client/client.go +++ b/client/client.go @@ -107,6 +107,33 @@ func (c *Client) makeRequest(method, endpoint string, body any) ([]byte, error) return respBody, nil } +func (c *Client) makeExternalRequest(method, url string) ([]byte, error) { + req, err := http.NewRequest(method, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.SetBasicAuth("", c.apiKey) + 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..e019ac3 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"` + Version 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.Version != "" { + formData.Set("rmq_version", req.Version) + } + if req.VPCSubnet != "" { formData.Set("vpc_subnet", req.VPCSubnet) } diff --git a/client/regions.go b/client/regions.go index d3f3eb4..af886a2 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", "https://api.cloudamqp.com/api/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/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..aaecee0 100644 --- a/cmd/completion_helpers.go +++ b/cmd/completion_helpers.go @@ -166,6 +166,53 @@ formatOutput: return suggestions, cobra.ShellCompDirectiveNoFileComp } +// completeVersions returns available RabbitMQ versions for completion. +// Only suggests versions when the selected plan has a rabbitmq backend. +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) + + // Check if the selected plan is a rabbitmq plan + planName, _ := cmd.Flags().GetString("plan") + if planName != "" { + var plans []client.Plan + if cachedData, ok := getCachedData("plans", plansCacheTTL); ok { + json.Unmarshal(cachedData, &plans) + } + if len(plans) == 0 { + plans, _ = c.ListPlans("") + if len(plans) > 0 { + setCachedData("plans", plansCacheTTL, plans) + } + } + for _, p := range plans { + if p.Name == planName && p.Backend != "rabbitmq" { + 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..f3664da 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 @@ -61,6 +63,22 @@ Optional flags: Tags: instanceTags, } + if instanceRMQVersion != "" { + plans, err := c.ListPlans("") + if err != nil { + return fmt.Errorf("failed to verify plan backend: %w", err) + } + for _, p := range plans { + if p.Name == instancePlan { + if p.Backend != "rabbitmq" { + return fmt.Errorf("--rmq-version can only be used with rabbitmq plans, but %q has backend %q", instancePlan, p.Backend) + } + break + } + } + req.Version = instanceRMQVersion + } + if instanceVPCSubnet != "" { req.VPCSubnet = instanceVPCSubnet } @@ -118,6 +136,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)") 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 +149,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) From 29c4b7e3a9346ff1e7a105709675440a9b8328ef Mon Sep 17 00:00:00 2001 From: markus812498 Date: Fri, 17 Apr 2026 08:45:51 +1200 Subject: [PATCH 2/5] no need to pass apikey to call --- client/client.go | 1 - 1 file changed, 1 deletion(-) diff --git a/client/client.go b/client/client.go index d06b7d6..f2cae51 100644 --- a/client/client.go +++ b/client/client.go @@ -113,7 +113,6 @@ func (c *Client) makeExternalRequest(method, url string) ([]byte, error) { return nil, fmt.Errorf("failed to create request: %w", err) } - req.SetBasicAuth("", c.apiKey) req.Header.Set("User-Agent", fmt.Sprintf("cloudamqp-cli/%s", c.version)) resp, err := c.httpClient.Do(req) From 0e3c6d8249091057557acd5e0cb75119b0ed67d3 Mon Sep 17 00:00:00 2001 From: markus812498 Date: Fri, 17 Apr 2026 08:59:46 +1200 Subject: [PATCH 3/5] remove validation if version is passed --- cmd/instance_create.go | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/cmd/instance_create.go b/cmd/instance_create.go index f3664da..c389dbb 100644 --- a/cmd/instance_create.go +++ b/cmd/instance_create.go @@ -57,26 +57,11 @@ Optional flags: c := client.New(apiKey, Version) req := &client.InstanceCreateRequest{ - Name: instanceName, - Plan: instancePlan, - Region: instanceRegion, - Tags: instanceTags, - } - - if instanceRMQVersion != "" { - plans, err := c.ListPlans("") - if err != nil { - return fmt.Errorf("failed to verify plan backend: %w", err) - } - for _, p := range plans { - if p.Name == instancePlan { - if p.Backend != "rabbitmq" { - return fmt.Errorf("--rmq-version can only be used with rabbitmq plans, but %q has backend %q", instancePlan, p.Backend) - } - break - } - } - req.Version = instanceRMQVersion + Name: instanceName, + Plan: instancePlan, + Region: instanceRegion, + Version: instanceRMQVersion, + Tags: instanceTags, } if instanceVPCSubnet != "" { From 9aab3d81a2a47460ec57cd5ba27d6867e4058df4 Mon Sep 17 00:00:00 2001 From: markus812498 Date: Fri, 17 Apr 2026 11:45:43 +1200 Subject: [PATCH 4/5] addressing copilot feedback --- client/client.go | 4 ++-- client/instances.go | 6 +++--- cmd/completion_helpers.go | 40 ++++++++++++++++++++++++--------------- cmd/instance_create.go | 12 ++++++------ 4 files changed, 36 insertions(+), 26 deletions(-) diff --git a/client/client.go b/client/client.go index f2cae51..d00ce33 100644 --- a/client/client.go +++ b/client/client.go @@ -107,8 +107,8 @@ func (c *Client) makeRequest(method, endpoint string, body any) ([]byte, error) return respBody, nil } -func (c *Client) makeExternalRequest(method, url string) ([]byte, error) { - req, err := http.NewRequest(method, url, 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) } diff --git a/client/instances.go b/client/instances.go index e019ac3..308b903 100644 --- a/client/instances.go +++ b/client/instances.go @@ -31,7 +31,7 @@ type InstanceCreateRequest struct { Name string `json:"name"` Plan string `json:"plan"` Region string `json:"region"` - Version string `json:"rmq_version,omitempty"` + RMQVersion string `json:"rmq_version,omitempty"` Tags []string `json:"tags,omitempty"` VPCSubnet string `json:"vpc_subnet,omitempty"` VPCID *int `json:"vpc_id,omitempty"` @@ -98,8 +98,8 @@ func (c *Client) CreateInstance(req *InstanceCreateRequest) (*InstanceCreateResp } } - if req.Version != "" { - formData.Set("rmq_version", req.Version) + if req.RMQVersion != "" { + formData.Set("rmq_version", req.RMQVersion) } if req.VPCSubnet != "" { diff --git a/cmd/completion_helpers.go b/cmd/completion_helpers.go index aaecee0..aaa5b8a 100644 --- a/cmd/completion_helpers.go +++ b/cmd/completion_helpers.go @@ -167,7 +167,7 @@ formatOutput: } // completeVersions returns available RabbitMQ versions for completion. -// Only suggests versions when the selected plan has a rabbitmq backend. +// 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 { @@ -176,25 +176,35 @@ func completeVersions(cmd *cobra.Command, args []string, toComplete string) ([]s c := client.New(apiKey, Version) - // Check if the selected plan is a rabbitmq plan planName, _ := cmd.Flags().GetString("plan") - if planName != "" { - var plans []client.Plan - if cachedData, ok := getCachedData("plans", plansCacheTTL); ok { - json.Unmarshal(cachedData, &plans) + 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 { - plans, _ = c.ListPlans("") - if len(plans) > 0 { - setCachedData("plans", plansCacheTTL, plans) - } + if len(plans) > 0 { + setCachedData("plans", plansCacheTTL, plans) } - for _, p := range plans { - if p.Name == planName && p.Backend != "rabbitmq" { - return nil, cobra.ShellCompDirectiveNoFileComp - } + } + + 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 { diff --git a/cmd/instance_create.go b/cmd/instance_create.go index c389dbb..b954581 100644 --- a/cmd/instance_create.go +++ b/cmd/instance_create.go @@ -57,11 +57,11 @@ Optional flags: c := client.New(apiKey, Version) req := &client.InstanceCreateRequest{ - Name: instanceName, - Plan: instancePlan, - Region: instanceRegion, - Version: instanceRMQVersion, - Tags: instanceTags, + Name: instanceName, + Plan: instancePlan, + Region: instanceRegion, + RMQVersion: instanceRMQVersion, + Tags: instanceTags, } if instanceVPCSubnet != "" { @@ -121,7 +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)") + 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") From 0bfefd3a15a2a456531bb10260a7a21c2fd346ac Mon Sep 17 00:00:00 2001 From: markus812498 Date: Fri, 17 Apr 2026 13:52:11 +1200 Subject: [PATCH 5/5] added create test with rmq_version --- client/client.go | 2 ++ client/instances_test.go | 25 +++++++++++++++++++++++++ client/regions.go | 2 +- client/regions_test.go | 25 +++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/client/client.go b/client/client.go index d00ce33..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 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 af886a2..87bc366 100644 --- a/client/regions.go +++ b/client/regions.go @@ -38,7 +38,7 @@ func (c *Client) ListRegions(provider string) ([]Region, error) { } func (c *Client) ListVersions() ([]string, error) { - respBody, err := c.makeExternalRequest("GET", "https://api.cloudamqp.com/api/metadata/rabbitmq-versions") + respBody, err := c.makeExternalRequest("GET", MetadataURL+"/metadata/rabbitmq-versions") if err != nil { return nil, err } 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) +}