diff --git a/doc/ovhcloud_cloud.md b/doc/ovhcloud_cloud.md index 8d25c3ce..a68390b7 100644 --- a/doc/ovhcloud_cloud.md +++ b/doc/ovhcloud_cloud.md @@ -33,6 +33,7 @@ Manage your projects and services in the Public Cloud universe (MKS, MPR, MRS, O * [ovhcloud cloud alerting](ovhcloud_cloud_alerting.md) - Manage billing alert configurations in the given cloud project * [ovhcloud cloud container-registry](ovhcloud_cloud_container-registry.md) - Manage container registries in the given cloud project * [ovhcloud cloud database-service](ovhcloud_cloud_database-service.md) - Manage database services in the given cloud project +* [ovhcloud cloud floating-ip](ovhcloud_cloud_floating-ip.md) - Manage floating IPs in the given cloud project * [ovhcloud cloud instance](ovhcloud_cloud_instance.md) - Manage instances in the given cloud project * [ovhcloud cloud ip-failover](ovhcloud_cloud_ip-failover.md) - Manage failover IPs in the given cloud project * [ovhcloud cloud kube](ovhcloud_cloud_kube.md) - Manage Kubernetes clusters in the given cloud project diff --git a/doc/ovhcloud_cloud_floating-ip.md b/doc/ovhcloud_cloud_floating-ip.md new file mode 100644 index 00000000..7b33facd --- /dev/null +++ b/doc/ovhcloud_cloud_floating-ip.md @@ -0,0 +1,38 @@ +## ovhcloud cloud floating-ip + +Manage floating IPs in the given cloud project + +### Options + +``` + --cloud-project string Cloud project ID + -h, --help help for floating-ip + --region string Filter by region or specify the region of the floating IP +``` + +### Options inherited from parent commands + +``` + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file +``` + +### SEE ALSO + +* [ovhcloud cloud](ovhcloud_cloud.md) - Manage your projects and services in the Public Cloud universe (MKS, MPR, MRS, Object Storage...) +* [ovhcloud cloud floating-ip delete](ovhcloud_cloud_floating-ip_delete.md) - Delete a floating IP +* [ovhcloud cloud floating-ip get](ovhcloud_cloud_floating-ip_get.md) - Get information about a floating IP +* [ovhcloud cloud floating-ip list](ovhcloud_cloud_floating-ip_list.md) - List floating IPs + diff --git a/doc/ovhcloud_cloud_floating-ip_delete.md b/doc/ovhcloud_cloud_floating-ip_delete.md new file mode 100644 index 00000000..aaae786c --- /dev/null +++ b/doc/ovhcloud_cloud_floating-ip_delete.md @@ -0,0 +1,39 @@ +## ovhcloud cloud floating-ip delete + +Delete a floating IP + +``` +ovhcloud cloud floating-ip delete [flags] +``` + +### Options + +``` + -h, --help help for delete +``` + +### Options inherited from parent commands + +``` + --cloud-project string Cloud project ID + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file + --region string Filter by region or specify the region of the floating IP +``` + +### SEE ALSO + +* [ovhcloud cloud floating-ip](ovhcloud_cloud_floating-ip.md) - Manage floating IPs in the given cloud project + diff --git a/doc/ovhcloud_cloud_floating-ip_get.md b/doc/ovhcloud_cloud_floating-ip_get.md new file mode 100644 index 00000000..58f59b02 --- /dev/null +++ b/doc/ovhcloud_cloud_floating-ip_get.md @@ -0,0 +1,39 @@ +## ovhcloud cloud floating-ip get + +Get information about a floating IP + +``` +ovhcloud cloud floating-ip get [flags] +``` + +### Options + +``` + -h, --help help for get +``` + +### Options inherited from parent commands + +``` + --cloud-project string Cloud project ID + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file + --region string Filter by region or specify the region of the floating IP +``` + +### SEE ALSO + +* [ovhcloud cloud floating-ip](ovhcloud_cloud_floating-ip.md) - Manage floating IPs in the given cloud project + diff --git a/doc/ovhcloud_cloud_floating-ip_list.md b/doc/ovhcloud_cloud_floating-ip_list.md new file mode 100644 index 00000000..b2a4dd08 --- /dev/null +++ b/doc/ovhcloud_cloud_floating-ip_list.md @@ -0,0 +1,46 @@ +## ovhcloud cloud floating-ip list + +List floating IPs + +``` +ovhcloud cloud floating-ip list [flags] +``` + +### Options + +``` + --filter stringArray Filter results by any property using https://github.com/PaesslerAG/gval syntax + Examples: + --filter 'state="running"' + --filter 'name=~"^my.*"' + --filter 'nested.property.subproperty>10' + --filter 'startDate>="2023-12-01"' + --filter 'name=~"something" && nbField>10' + -h, --help help for list +``` + +### Options inherited from parent commands + +``` + --cloud-project string Cloud project ID + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file + --region string Filter by region or specify the region of the floating IP +``` + +### SEE ALSO + +* [ovhcloud cloud floating-ip](ovhcloud_cloud_floating-ip.md) - Manage floating IPs in the given cloud project + diff --git a/doc/ovhcloud_cloud_loadbalancer_create.md b/doc/ovhcloud_cloud_loadbalancer_create.md index f7c9d4dd..8f648c97 100644 --- a/doc/ovhcloud_cloud_loadbalancer_create.md +++ b/doc/ovhcloud_cloud_loadbalancer_create.md @@ -25,7 +25,7 @@ There are three ways to define the parameters: 3. Using only CLI flags: - ovhcloud cloud loadbalancer create --name my-lb --flavor + ovhcloud cloud loadbalancer create --name my-lb --size small ``` @@ -36,7 +36,6 @@ ovhcloud cloud loadbalancer create [flags] ``` --editor Use a text editor to define parameters - --flavor string Flavor ID (can be retrieved with 'cloud reference loadbalancer list-flavors ') --floating-ip string Floating IP ID to associate to the loadbalancer --from-file string File containing parameters --gateway string Gateway ID to associate to the loadbalancer @@ -45,6 +44,7 @@ ovhcloud cloud loadbalancer create [flags] --name string Name of the loadbalancer --network-id string Network ID --replace Replace parameters file if it already exists + --size string Size of the loadbalancer (e.g. small, medium, large) or flavor UUID --subnet-id string Subnet ID ``` diff --git a/doc/ovhcloud_cloud_loadbalancer_edit.md b/doc/ovhcloud_cloud_loadbalancer_edit.md index f004f294..f6ea25aa 100644 --- a/doc/ovhcloud_cloud_loadbalancer_edit.md +++ b/doc/ovhcloud_cloud_loadbalancer_edit.md @@ -11,9 +11,9 @@ ovhcloud cloud loadbalancer edit [flags] ``` --description string Description of the loadbalancer --editor Use a text editor to define parameters - --flavor string Flavor ID of the loadbalancer (can be retrieved with 'cloud reference loadbalancer list-flavors ') -h, --help help for edit --name string Name of the loadbalancer + --size string Size of the loadbalancer (e.g. small, medium, large) or flavor UUID ``` ### Options inherited from parent commands diff --git a/doc/ovhcloud_cloud_loadbalancer_listener_list.md b/doc/ovhcloud_cloud_loadbalancer_listener_list.md index 0f74f231..d7a2250b 100644 --- a/doc/ovhcloud_cloud_loadbalancer_listener_list.md +++ b/doc/ovhcloud_cloud_loadbalancer_listener_list.md @@ -9,14 +9,15 @@ ovhcloud cloud loadbalancer listener list [flags] ### Options ``` - --filter stringArray Filter results by any property using https://github.com/PaesslerAG/gval syntax - Examples: - --filter 'state="running"' - --filter 'name=~"^my.*"' - --filter 'nested.property.subproperty>10' - --filter 'startDate>="2023-12-01"' - --filter 'name=~"something" && nbField>10' - -h, --help help for list + --filter stringArray Filter results by any property using https://github.com/PaesslerAG/gval syntax + Examples: + --filter 'state="running"' + --filter 'name=~"^my.*"' + --filter 'nested.property.subproperty>10' + --filter 'startDate>="2023-12-01"' + --filter 'name=~"something" && nbField>10' + -h, --help help for list + --loadbalancer-id string Filter listeners by loadbalancer ID ``` ### Options inherited from parent commands diff --git a/doc/ovhcloud_cloud_loadbalancer_pool_member_create.md b/doc/ovhcloud_cloud_loadbalancer_pool_member_create.md index 9ac6e0b3..0688cb94 100644 --- a/doc/ovhcloud_cloud_loadbalancer_pool_member_create.md +++ b/doc/ovhcloud_cloud_loadbalancer_pool_member_create.md @@ -9,11 +9,15 @@ ovhcloud cloud loadbalancer pool member create [flags] ### Options ``` - --editor Use a text editor to define parameters - --from-file string File containing parameters - -h, --help help for create - --init-file string Create a file with example parameters - --replace Replace parameters file if it already exists + --address string IP address of the member + --editor Use a text editor to define parameters + --from-file string File containing parameters + -h, --help help for create + --init-file string Create a file with example parameters + --name string Name of the member + --protocol-port int Protocol port number of the member + --replace Replace parameters file if it already exists + --weight int Weight of the member (1-256) ``` ### Options inherited from parent commands diff --git a/internal/cmd/cloud_floating_ip.go b/internal/cmd/cloud_floating_ip.go new file mode 100644 index 00000000..b08a3ed5 --- /dev/null +++ b/internal/cmd/cloud_floating_ip.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/ovh/ovhcloud-cli/internal/services/cloud" + "github.com/spf13/cobra" +) + +func initCloudFloatingIPCommand(cloudCmd *cobra.Command) { + floatingIPCmd := &cobra.Command{ + Use: "floating-ip", + Short: "Manage floating IPs in the given cloud project", + } + floatingIPCmd.PersistentFlags().StringVar(&cloud.CloudProject, "cloud-project", "", "Cloud project ID") + floatingIPCmd.PersistentFlags().StringVar(&cloud.CloudFloatingIPRegionFilter, "region", "", "Filter by region or specify the region of the floating IP") + + floatingIPListCmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List floating IPs", + Run: cloud.ListFloatingIPs, + } + floatingIPCmd.AddCommand(withFilterFlag(floatingIPListCmd)) + + floatingIPCmd.AddCommand(&cobra.Command{ + Use: "get ", + Short: "Get information about a floating IP", + Run: cloud.GetFloatingIP, + Args: cobra.ExactArgs(1), + }) + + floatingIPCmd.AddCommand(&cobra.Command{ + Use: "delete ", + Short: "Delete a floating IP", + Run: cloud.DeleteFloatingIP, + Args: cobra.ExactArgs(1), + }) + + cloudCmd.AddCommand(floatingIPCmd) +} diff --git a/internal/cmd/cloud_floating_ip_test.go b/internal/cmd/cloud_floating_ip_test.go new file mode 100644 index 00000000..7206d92a --- /dev/null +++ b/internal/cmd/cloud_floating_ip_test.go @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "encoding/json" + "net/http" + + "github.com/jarcoal/httpmock" + "github.com/maxatome/go-testdeep/td" + "github.com/ovh/ovhcloud-cli/internal/cmd" +) + +func registerFloatingIPRegionMocks() { + httpmock.RegisterResponder(http.MethodGet, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region", + httpmock.NewStringResponder(200, `["GRA11", "SBG5", "BHS5"]`)) + + httpmock.RegisterResponder(http.MethodGet, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/GRA11", + httpmock.NewStringResponder(200, `{ + "name": "GRA11", + "type": "region", + "status": "UP", + "services": [{"name": "network", "status": "UP"}] + }`)) + + httpmock.RegisterResponder(http.MethodGet, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/SBG5", + httpmock.NewStringResponder(200, `{ + "name": "SBG5", + "type": "region", + "status": "UP", + "services": [{"name": "network", "status": "UP"}] + }`)) + + httpmock.RegisterResponder(http.MethodGet, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/BHS5", + httpmock.NewStringResponder(200, `{ + "name": "BHS5", + "type": "region", + "status": "UP", + "services": [] + }`)) +} + +// --------------------------------------------------------------------------- +// Floating IP – list +// --------------------------------------------------------------------------- + +func (ms *MockSuite) TestCloudFloatingIPListCmd(assert, require *td.T) { + registerFloatingIPRegionMocks() + + httpmock.RegisterResponder(http.MethodGet, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/GRA11/floatingip", + httpmock.NewStringResponder(200, `[ + { + "id": "fip-gra-001", + "ip": "1.2.3.4", + "status": "active", + "region": "GRA11", + "networkId": "net-001", + "associatedEntity": null + } + ]`)) + + httpmock.RegisterResponder(http.MethodGet, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/SBG5/floatingip", + httpmock.NewStringResponder(200, `[ + { + "id": "fip-sbg-001", + "ip": "5.6.7.8", + "status": "active", + "region": "SBG5", + "networkId": "net-002", + "associatedEntity": {"id": "port-001", "type": "instance"} + } + ]`)) + + out, err := cmd.Execute("cloud", "floating-ip", "ls", "--cloud-project", "fakeProjectID", "-o", "json") + require.CmpNoError(err) + assert.Cmp(json.RawMessage(out), td.JSON(`[ + { + "id": "fip-gra-001", + "ip": "1.2.3.4", + "status": "active", + "region": "GRA11", + "networkId": "net-001", + "associatedEntity": null + }, + { + "id": "fip-sbg-001", + "ip": "5.6.7.8", + "status": "active", + "region": "SBG5", + "networkId": "net-002", + "associatedEntity": {"id": "port-001", "type": "instance"} + } + ]`)) +} + +func (ms *MockSuite) TestCloudFloatingIPListWithRegionFilterCmd(assert, require *td.T) { + httpmock.RegisterResponder(http.MethodGet, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/GRA11/floatingip", + httpmock.NewStringResponder(200, `[ + { + "id": "fip-gra-001", + "ip": "1.2.3.4", + "status": "active", + "region": "GRA11", + "networkId": "net-001", + "associatedEntity": null + } + ]`)) + + out, err := cmd.Execute("cloud", "floating-ip", "ls", "--cloud-project", "fakeProjectID", "--region", "GRA11", "-o", "json") + require.CmpNoError(err) + assert.Cmp(json.RawMessage(out), td.JSON(`[ + { + "id": "fip-gra-001", + "ip": "1.2.3.4", + "status": "active", + "region": "GRA11", + "networkId": "net-001", + "associatedEntity": null + } + ]`)) +} + +// --------------------------------------------------------------------------- +// Floating IP – get +// --------------------------------------------------------------------------- + +func (ms *MockSuite) TestCloudFloatingIPGetCmd(assert, require *td.T) { + httpmock.RegisterResponder(http.MethodGet, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/GRA11/floatingip/fip-gra-001", + httpmock.NewStringResponder(200, `{ + "id": "fip-gra-001", + "ip": "1.2.3.4", + "status": "active", + "region": "GRA11", + "networkId": "net-001", + "associatedEntity": null + }`)) + + out, err := cmd.Execute("cloud", "floating-ip", "get", "fip-gra-001", "--cloud-project", "fakeProjectID", "--region", "GRA11", "-o", "json") + require.CmpNoError(err) + assert.Cmp(json.RawMessage(out), td.JSON(`{ + "id": "fip-gra-001", + "ip": "1.2.3.4", + "status": "active", + "region": "GRA11", + "networkId": "net-001", + "associatedEntity": null + }`)) +} + +// --------------------------------------------------------------------------- +// Floating IP – delete +// --------------------------------------------------------------------------- + +func (ms *MockSuite) TestCloudFloatingIPDeleteCmd(assert, require *td.T) { + httpmock.RegisterResponder(http.MethodDelete, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/GRA11/floatingip/fip-gra-001", + httpmock.NewStringResponder(200, `null`)) + + out, err := cmd.Execute("cloud", "floating-ip", "delete", "fip-gra-001", "--cloud-project", "fakeProjectID", "--region", "GRA11", "-o", "json") + require.CmpNoError(err) + assert.Cmp(json.RawMessage(out), td.JSON(`{ + "message": "✅ Floating IP fip-gra-001 deleted successfully" + }`)) +} diff --git a/internal/cmd/cloud_loadbalancer.go b/internal/cmd/cloud_loadbalancer.go index a41edd38..c6f24e05 100644 --- a/internal/cmd/cloud_loadbalancer.go +++ b/internal/cmd/cloud_loadbalancer.go @@ -40,7 +40,7 @@ func initCloudLoadbalancerCommand(cloudCmd *cobra.Command) { } editLoadbalancerCmd.Flags().StringVar(&cloud.CloudLoadbalancerUpdateSpec.Name, "name", "", "Name of the loadbalancer") editLoadbalancerCmd.Flags().StringVar(&cloud.CloudLoadbalancerUpdateSpec.Description, "description", "", "Description of the loadbalancer") - editLoadbalancerCmd.Flags().StringVar(&cloud.CloudLoadbalancerUpdateSpec.FlavorId, "flavor", "", "Flavor ID of the loadbalancer (can be retrieved with 'cloud reference loadbalancer list-flavors ')") + editLoadbalancerCmd.Flags().StringVar(&cloud.CloudLoadbalancerUpdateSpec.Size, "size", "", "Size of the loadbalancer (e.g. small, medium, large) or flavor UUID") addInteractiveEditorFlag(editLoadbalancerCmd) loadbalancerCmd.AddCommand(editLoadbalancerCmd) @@ -114,14 +114,14 @@ There are three ways to define the parameters: 3. Using only CLI flags: - ovhcloud cloud loadbalancer create --name my-lb --flavor + ovhcloud cloud loadbalancer create --name my-lb --size small `, Run: cloud.CreateCloudLoadbalancer, Args: cobra.ExactArgs(1), } cmd.Flags().StringVar(&cloud.CloudLoadbalancerCreateSpec.Name, "name", "", "Name of the loadbalancer") - cmd.Flags().StringVar(&cloud.CloudLoadbalancerCreateSpec.FlavorId, "flavor", "", "Flavor ID (can be retrieved with 'cloud reference loadbalancer list-flavors ')") + cmd.Flags().StringVar(&cloud.CloudLoadbalancerCreateSpec.Size, "size", "", "Size of the loadbalancer (e.g. small, medium, large) or flavor UUID") cmd.Flags().StringVar(&cloud.CloudLoadbalancerCreateSpec.Network.Private.Network.Id, "network-id", "", "Network ID") cmd.Flags().StringVar(&cloud.CloudLoadbalancerCreateSpec.Network.Private.Network.SubnetId, "subnet-id", "", "Subnet ID") cmd.Flags().StringVar(&cloud.CloudLoadbalancerCreateSpec.Network.Private.FloatingIp.Id, "floating-ip", "", "Floating IP ID to associate to the loadbalancer") @@ -178,12 +178,14 @@ func initListenerSubCommands(loadbalancerCmd *cobra.Command) { } loadbalancerCmd.AddCommand(listenerCmd) - listenerCmd.AddCommand(withFilterFlag(&cobra.Command{ + listenerListCmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List all listeners", Run: cloud.ListCloudLoadbalancerListeners, - })) + } + listenerListCmd.Flags().StringVar(&cloud.CloudLoadbalancerListenerLoadbalancerIDFilter, "loadbalancer-id", "", "Filter listeners by loadbalancer ID") + listenerCmd.AddCommand(withFilterFlag(listenerListCmd)) listenerCmd.AddCommand(&cobra.Command{ Use: "get ", @@ -354,6 +356,11 @@ func getPoolMemberCreationCmd() *cobra.Command { Args: cobra.ExactArgs(1), } + cmd.Flags().StringVar(&cloud.CloudLoadbalancerPoolMemberCreateSpec.Address, "address", "", "IP address of the member") + cmd.Flags().StringVar(&cloud.CloudLoadbalancerPoolMemberCreateSpec.Name, "name", "", "Name of the member") + cmd.Flags().IntVar(&cloud.CloudLoadbalancerPoolMemberCreateSpec.ProtocolPort, "protocol-port", 0, "Protocol port number of the member") + cmd.Flags().IntVar(&cloud.CloudLoadbalancerPoolMemberCreateSpec.Weight, "weight", 0, "Weight of the member (1-256)") + addParameterFileFlags(cmd, false, assets.CloudOpenapiSchema, "/cloud/project/{serviceName}/region/{regionName}/loadbalancing/pool/{poolId}/member", "post", cloud.LoadbalancerPoolMemberCreationExample, nil) addInteractiveEditorFlag(cmd) cmd.MarkFlagsMutuallyExclusive("from-file", "editor") diff --git a/internal/cmd/cloud_loadbalancer_test.go b/internal/cmd/cloud_loadbalancer_test.go index 8f468a4d..5b1b7edf 100644 --- a/internal/cmd/cloud_loadbalancer_test.go +++ b/internal/cmd/cloud_loadbalancer_test.go @@ -10,6 +10,7 @@ import ( "github.com/jarcoal/httpmock" "github.com/maxatome/go-testdeep/td" + "github.com/maxatome/tdhttpmock" "github.com/ovh/ovhcloud-cli/internal/cmd" ) @@ -271,6 +272,66 @@ func (ms *MockSuite) TestCloudLoadbalancerStatsCmd(assert, require *td.T) { }`)) } +// --------------------------------------------------------------------------- +// Loadbalancer – create with --size name resolution +// --------------------------------------------------------------------------- + +func (ms *MockSuite) TestCloudLoadbalancerCreateWithSizeCmd(assert, require *td.T) { + // Mock flavor list for size resolution + httpmock.RegisterResponder(http.MethodGet, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/GRA11/loadbalancing/flavor", + httpmock.NewStringResponder(200, `[ + { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "small", + "region": "GRA11" + }, + { + "id": "ffffffff-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": "medium", + "region": "GRA11" + } + ]`)) + + httpmock.RegisterMatcherResponder(http.MethodPost, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/GRA11/loadbalancing/loadbalancer", + tdhttpmock.JSONBody(td.JSON(`{ + "name": "my-lb", + "flavorId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "network": { + "private": { + "network": { + "id": "net-001", + "subnetId": "sub-001" + } + } + } + }`)), + httpmock.NewStringResponder(200, `{ + "id": "lb-new-001", + "name": "my-lb", + "region": "GRA11" + }`), + ) + + out, err := cmd.Execute("cloud", "loadbalancer", "create", "GRA11", + "--cloud-project", "fakeProjectID", + "--name", "my-lb", + "--size", "small", + "--network-id", "net-001", + "--subnet-id", "sub-001", + "-o", "json") + require.CmpNoError(err) + assert.Cmp(json.RawMessage(out), td.JSON(`{ + "message": "✅ Loadbalancer created successfully (ID: lb-new-001)", + "details": { + "id": "lb-new-001", + "name": "my-lb", + "region": "GRA11" + } + }`)) +} + // --------------------------------------------------------------------------- // Listener – list // --------------------------------------------------------------------------- @@ -309,6 +370,40 @@ func (ms *MockSuite) TestCloudLoadbalancerListenerListCmd(assert, require *td.T) ]`)) } +func (ms *MockSuite) TestCloudLoadbalancerListenerListWithLoadbalancerIDCmd(assert, require *td.T) { + registerLoadbalancingRegionMocks() + + httpmock.RegisterResponder(http.MethodGet, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/GRA11/loadbalancing/listener?loadbalancerId=lb-gra-001", + httpmock.NewStringResponder(200, `[ + { + "id": "lis-001", + "name": "my-listener", + "protocol": "http", + "port": 80, + "operatingStatus": "online", + "provisioningStatus": "active" + } + ]`)) + + httpmock.RegisterResponder(http.MethodGet, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/SBG5/loadbalancing/listener?loadbalancerId=lb-gra-001", + httpmock.NewStringResponder(200, `[]`)) + + out, err := cmd.Execute("cloud", "loadbalancer", "listener", "ls", "--cloud-project", "fakeProjectID", "--loadbalancer-id", "lb-gra-001", "-o", "json") + require.CmpNoError(err) + assert.Cmp(json.RawMessage(out), td.JSON(`[ + { + "id": "lis-001", + "name": "my-listener", + "protocol": "http", + "port": 80, + "operatingStatus": "online", + "provisioningStatus": "active" + } + ]`)) +} + // --------------------------------------------------------------------------- // Listener – get // --------------------------------------------------------------------------- @@ -597,6 +692,71 @@ func (ms *MockSuite) TestCloudLoadbalancerPoolMemberDeleteCmd(assert, require *t }`)) } +// --------------------------------------------------------------------------- +// Pool Member – create with flags +// --------------------------------------------------------------------------- + +func (ms *MockSuite) TestCloudLoadbalancerPoolMemberCreateWithFlagsCmd(assert, require *td.T) { + registerLoadbalancingRegionMocks() + + // Locate pool + httpmock.RegisterResponder(http.MethodGet, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/GRA11/loadbalancing/pool/pool-001", + httpmock.NewStringResponder(200, `{"id":"pool-001"}`)) + + httpmock.RegisterMatcherResponder(http.MethodPost, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/GRA11/loadbalancing/pool/pool-001/member", + tdhttpmock.JSONBody(td.JSON(`{ + "members": [ + { + "address": "10.0.0.42", + "name": "my-member", + "protocolPort": 8080, + "weight": 5 + } + ] + }`)), + httpmock.NewStringResponder(200, `{ + "members": [ + { + "id": "mem-new-001", + "address": "10.0.0.42", + "name": "my-member", + "protocolPort": 8080, + "weight": 5, + "operatingStatus": "online", + "provisioningStatus": "active" + } + ] + }`), + ) + + out, err := cmd.Execute("cloud", "loadbalancer", "pool", "member", "create", "pool-001", + "--cloud-project", "fakeProjectID", + "--address", "10.0.0.42", + "--name", "my-member", + "--protocol-port", "8080", + "--weight", "5", + "-o", "json") + require.CmpNoError(err) + assert.Cmp(json.RawMessage(out), td.JSON(`{ + "message": "✅ Pool member(s) created successfully", + "details": { + "members": [ + { + "id": "mem-new-001", + "address": "10.0.0.42", + "name": "my-member", + "protocolPort": 8080, + "weight": 5, + "operatingStatus": "online", + "provisioningStatus": "active" + } + ] + } + }`)) +} + // --------------------------------------------------------------------------- // Health Monitor – list // --------------------------------------------------------------------------- diff --git a/internal/cmd/cloud_network_test.go b/internal/cmd/cloud_network_test.go index 343d7df9..48877753 100644 --- a/internal/cmd/cloud_network_test.go +++ b/internal/cmd/cloud_network_test.go @@ -191,3 +191,66 @@ func (ms *MockSuite) TestCloudPrivateNetworkSubnetCreateCmd(assert, require *td. }`)) } +func (ms *MockSuite) TestCloudPrivateNetworkSubnetCreateCmdInferIPVersion(assert, require *td.T) { + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/BHS5/network/pn-123456", + httpmock.NewStringResponder(200, `{ + "id": "pn-123456", + "name": "test-network", + "region": "BHS5", + "visibility": "private", + "vlanId": 0 + }`), + ) + + httpmock.RegisterMatcherResponder(http.MethodPost, + "https://eu.api.ovh.com/v1/cloud/project/fakeProjectID/region/BHS5/network/pn-123456/subnet", + tdhttpmock.JSONBody(td.JSON(` + { + "name": "my-subnet", + "cidr": "192.168.1.0/24", + "ipVersion": 4, + "enableDhcp": true, + "enableGatewayIp": true + }`), + ), + httpmock.NewStringResponder(200, ` + { + "cidr": "192.168.1.0/24", + "gatewayIp": "192.168.1.1", + "id": "subnet-inferred-v4", + "name": "my-subnet", + "ipVersion": 4, + "dhcpEnabled": true, + "allocationPools": [ + { + "start": "192.168.1.2", + "end": "192.168.1.254" + } + ] + }`, + ), + ) + + // Note: --ip-version is NOT provided, it should be inferred from the CIDR + out, err := cmd.Execute("cloud", "network", "private", "subnet", "create", "pn-123456", "--region", "BHS5", "--cloud-project", "fakeProjectID", + "--name", "my-subnet", "--cidr", "192.168.1.0/24", "--enable-dhcp", "--enable-gateway-ip", "-o", "json") + require.CmpNoError(err) + assert.Cmp(json.RawMessage(out), td.JSON(`{ + "message": "✅ Subnet subnet-inferred-v4 created successfully", + "details": { + "cidr": "192.168.1.0/24", + "gatewayIp": "192.168.1.1", + "id": "subnet-inferred-v4", + "name": "my-subnet", + "ipVersion": 4, + "dhcpEnabled": true, + "allocationPools": [ + { + "start": "192.168.1.2", + "end": "192.168.1.254" + } + ] + } + }`)) +} + diff --git a/internal/cmd/cloud_project.go b/internal/cmd/cloud_project.go index dbcca82b..e0ce2cf2 100644 --- a/internal/cmd/cloud_project.go +++ b/internal/cmd/cloud_project.go @@ -119,6 +119,7 @@ func init() { initCloudReferenceCmd(cloudCmd) initCloudSavingsPlanCommand(cloudCmd) initCloudIPFailoverCommand(cloudCmd) + initCloudFloatingIPCommand(cloudCmd) initCloudAlertingCommand(cloudCmd) cloudCmd.AddCommand(cloudprojectCmd) diff --git a/internal/services/cloud/cloud_floating_ip.go b/internal/services/cloud/cloud_floating_ip.go new file mode 100644 index 00000000..134bedeb --- /dev/null +++ b/internal/services/cloud/cloud_floating_ip.go @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package cloud + +import ( + _ "embed" + "fmt" + "net/url" + + "github.com/ovh/ovhcloud-cli/internal/display" + filtersLib "github.com/ovh/ovhcloud-cli/internal/filters" + "github.com/ovh/ovhcloud-cli/internal/flags" + httpLib "github.com/ovh/ovhcloud-cli/internal/http" + "github.com/ovh/ovhcloud-cli/internal/services/common" + "github.com/spf13/cobra" +) + +var ( + // CloudFloatingIPRegionFilter is used to filter floating IPs by region + CloudFloatingIPRegionFilter string + + cloudFloatingIPColumnsToDisplay = []string{"id", "ip", "status", "region", "networkId", "associatedEntity"} + + //go:embed templates/cloud_floating_ip.tmpl + cloudFloatingIPTemplate string +) + +func ListFloatingIPs(_ *cobra.Command, _ []string) { + projectID, err := getConfiguredCloudProject() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + var regions []any + + if CloudFloatingIPRegionFilter != "" { + regions = []any{CloudFloatingIPRegionFilter} + } else { + regions, err = getCloudRegionsWithFeatureAvailable(projectID, "network") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to fetch regions with network feature available: %s", err) + return + } + } + + baseURL := fmt.Sprintf("/v1/cloud/project/%s/region", projectID) + floatingIPs, err := httpLib.FetchObjectsParallel[[]map[string]any](baseURL+"/%s/floatingip", regions, true) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to fetch floating IPs: %s", err) + return + } + + var allFloatingIPs []map[string]any + for i, regionFloatingIPs := range floatingIPs { + for _, fip := range regionFloatingIPs { + if _, ok := fip["region"]; !ok { + fip["region"] = fmt.Sprint(regions[i]) + } + allFloatingIPs = append(allFloatingIPs, fip) + } + } + + allFloatingIPs, err = filtersLib.FilterLines(allFloatingIPs, flags.GenericFilters) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to filter results: %s", err) + return + } + + display.RenderTable(allFloatingIPs, cloudFloatingIPColumnsToDisplay, &flags.OutputFormatConfig) +} + +func GetFloatingIP(_ *cobra.Command, args []string) { + projectID, err := getConfiguredCloudProject() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + if CloudFloatingIPRegionFilter == "" { + display.OutputError(&flags.OutputFormatConfig, "--region flag is required for get command") + return + } + + path := fmt.Sprintf("/v1/cloud/project/%s/region/%s/floatingip", + projectID, url.PathEscape(CloudFloatingIPRegionFilter)) + + common.ManageObjectRequest(path, args[0], cloudFloatingIPTemplate) +} + +func DeleteFloatingIP(_ *cobra.Command, args []string) { + projectID, err := getConfiguredCloudProject() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + if CloudFloatingIPRegionFilter == "" { + display.OutputError(&flags.OutputFormatConfig, "--region flag is required for delete command") + return + } + + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/floatingip/%s", + projectID, url.PathEscape(CloudFloatingIPRegionFilter), url.PathEscape(args[0])) + + if err := httpLib.Client.Delete(endpoint, nil); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to delete floating IP: %s", err) + return + } + + display.OutputInfo(&flags.OutputFormatConfig, nil, "✅ Floating IP %s deleted successfully", args[0]) +} diff --git a/internal/services/cloud/cloud_instance.go b/internal/services/cloud/cloud_instance.go index b0f03a78..4f70098b 100644 --- a/internal/services/cloud/cloud_instance.go +++ b/internal/services/cloud/cloud_instance.go @@ -298,6 +298,10 @@ func CreateInstance(cmd *cobra.Command, args []string) { } } + if s := &InstanceCreationParameters.Network.Private.NetworkCreate.Subnet; s.IPVersion == 0 && s.CIDR != "" { + s.IPVersion = ipVersionFromCIDR(s.CIDR) + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/instance", projectID, region) operation, err := common.CreateResource( cmd, diff --git a/internal/services/cloud/cloud_loadbalancer.go b/internal/services/cloud/cloud_loadbalancer.go index bba058c9..38351211 100644 --- a/internal/services/cloud/cloud_loadbalancer.go +++ b/internal/services/cloud/cloud_loadbalancer.go @@ -8,6 +8,8 @@ import ( _ "embed" "fmt" "net/url" + "regexp" + "strings" "github.com/ovh/ovhcloud-cli/internal/assets" "github.com/ovh/ovhcloud-cli/internal/display" @@ -39,10 +41,12 @@ var ( CloudLoadbalancerUpdateSpec struct { Description string `json:"description,omitempty"` Name string `json:"name,omitempty"` + Size string `json:"-"` FlavorId string `json:"flavorId,omitempty"` } CloudLoadbalancerCreateSpec struct { + Size string `json:"-"` FlavorId string `json:"flavorId,omitempty"` Name string `json:"name,omitempty"` Network struct { @@ -113,6 +117,44 @@ func locateLoadbalancingResource(projectID, resourceType, resourceID string) (st return "", nil, fmt.Errorf("no %s found with id %s", resourceType, resourceID) } +var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// resolveLoadbalancerSize resolves a size name (e.g. "small", "medium") to its +// flavor UUID for the given region. If the value is already a UUID it is returned as-is. +func resolveLoadbalancerSize(projectID, region, size string) (string, error) { + if size == "" { + return "", nil + } + if uuidRegex.MatchString(size) { + return size, nil + } + + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/loadbalancing/flavor", + projectID, url.PathEscape(region)) + + var flavors []map[string]any + if err := httpLib.Client.Get(endpoint, &flavors); err != nil { + return "", fmt.Errorf("failed to fetch loadbalancer flavors: %w", err) + } + + for _, f := range flavors { + if name, ok := f["name"].(string); ok && strings.EqualFold(name, size) { + if id, ok := f["id"].(string); ok { + return id, nil + } + } + } + + var available []string + for _, f := range flavors { + if name, ok := f["name"].(string); ok { + available = append(available, name) + } + } + + return "", fmt.Errorf("unknown loadbalancer size %q, available sizes: %s", size, strings.Join(available, ", ")) +} + // listLoadbalancingResources fetches a loadbalancing resource type across all regions in parallel. func listLoadbalancingResources(resourceType string, columnsToDisplay []string) { projectID, err := getConfiguredCloudProject() @@ -193,6 +235,16 @@ func EditCloudLoadbalancer(cmd *cobra.Command, args []string) { return } + // Resolve --size name to flavor UUID + if CloudLoadbalancerUpdateSpec.Size != "" { + flavorID, err := resolveLoadbalancerSize(projectID, region, CloudLoadbalancerUpdateSpec.Size) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + CloudLoadbalancerUpdateSpec.FlavorId = flavorID + } + if err := common.EditResource( cmd, "/cloud/project/{serviceName}/region/{regionName}/loadbalancing/loadbalancer/{loadBalancerId}", @@ -213,6 +265,17 @@ func CreateCloudLoadbalancer(cmd *cobra.Command, args []string) { } region := args[0] + + // Resolve --size name to flavor UUID + if CloudLoadbalancerCreateSpec.Size != "" { + flavorID, err := resolveLoadbalancerSize(projectID, region, CloudLoadbalancerCreateSpec.Size) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + CloudLoadbalancerCreateSpec.FlavorId = flavorID + } + endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/loadbalancing/loadbalancer", projectID, url.PathEscape(region)) diff --git a/internal/services/cloud/cloud_loadbalancer_listener.go b/internal/services/cloud/cloud_loadbalancer_listener.go index 36417150..9c67e146 100644 --- a/internal/services/cloud/cloud_loadbalancer_listener.go +++ b/internal/services/cloud/cloud_loadbalancer_listener.go @@ -40,10 +40,21 @@ var ( Port int `json:"port,omitempty"` Protocol string `json:"protocol,omitempty"` } + + // CloudLoadbalancerListenerLoadbalancerIDFilter filters listeners by loadbalancer ID + CloudLoadbalancerListenerLoadbalancerIDFilter string ) func ListCloudLoadbalancerListeners(_ *cobra.Command, _ []string) { - listLoadbalancingResources("listener", listenerColumnsToDisplay) + if CloudLoadbalancerListenerLoadbalancerIDFilter == "" { + listLoadbalancingResources("listener", listenerColumnsToDisplay) + return + } + + listLoadbalancingResources( + "listener?loadbalancerId="+url.QueryEscape(CloudLoadbalancerListenerLoadbalancerIDFilter), + listenerColumnsToDisplay, + ) } func GetCloudLoadbalancerListener(_ *cobra.Command, args []string) { diff --git a/internal/services/cloud/cloud_loadbalancer_pool.go b/internal/services/cloud/cloud_loadbalancer_pool.go index ca1a4d9b..2c8cff13 100644 --- a/internal/services/cloud/cloud_loadbalancer_pool.go +++ b/internal/services/cloud/cloud_loadbalancer_pool.go @@ -50,8 +50,17 @@ var ( Name string `json:"name,omitempty"` Weight int `json:"weight,omitempty"` } + + CloudLoadbalancerPoolMemberCreateSpec cloudLoadbalancerPoolMemberCreateSpec ) +type cloudLoadbalancerPoolMemberCreateSpec struct { + Address string `json:"address,omitempty"` + Name string `json:"name,omitempty"` + ProtocolPort int `json:"protocolPort,omitempty"` + Weight int `json:"weight,omitempty"` +} + // Pool operations func ListCloudLoadbalancerPools(_ *cobra.Command, _ []string) { @@ -214,6 +223,27 @@ func CreateCloudLoadbalancerPoolMember(cmd *cobra.Command, args []string) { endpoint := fmt.Sprintf("/v1/cloud/project/%s/region/%s/loadbalancing/pool/%s/member", projectID, url.PathEscape(region), url.PathEscape(args[0])) + // When using CLI flags (not --from-file or --editor), wrap the single member + // into the members array expected by the API, and POST directly. + // For --from-file/--editor, fall back to CreateResource which handles those input methods. + if !flags.ParametersViaEditor && flags.ParametersFile == "" && + (CloudLoadbalancerPoolMemberCreateSpec.Address != "" || CloudLoadbalancerPoolMemberCreateSpec.ProtocolPort != 0) { + body := struct { + Members []cloudLoadbalancerPoolMemberCreateSpec `json:"members"` + }{ + Members: []cloudLoadbalancerPoolMemberCreateSpec{CloudLoadbalancerPoolMemberCreateSpec}, + } + + var result map[string]any + if err := httpLib.Client.Post(endpoint, body, &result); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to create pool member: %s", err) + return + } + + display.OutputInfo(&flags.OutputFormatConfig, result, "✅ Pool member(s) created successfully") + return + } + result, err := common.CreateResource( cmd, "/cloud/project/{serviceName}/region/{regionName}/loadbalancing/pool/{poolId}/member", diff --git a/internal/services/cloud/cloud_network.go b/internal/services/cloud/cloud_network.go index bc12da9e..c8e5333e 100644 --- a/internal/services/cloud/cloud_network.go +++ b/internal/services/cloud/cloud_network.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "log" + "net" "net/url" "strings" "sync" @@ -378,6 +379,10 @@ func CreatePrivateNetworkSubnet(cmd *cobra.Command, args []string) { }) } + if CloudNetworkSubnetSpec.IPVersion == 0 && CloudNetworkSubnetSpec.Cidr != "" { + CloudNetworkSubnetSpec.IPVersion = ipVersionFromCIDR(CloudNetworkSubnetSpec.Cidr) + } + subnet, err := common.CreateResource( cmd, "/cloud/project/{serviceName}/region/{regionName}/network/{networkId}/subnet", @@ -577,6 +582,10 @@ func CreateGateway(cmd *cobra.Command, args []string) { endpoint = fmt.Sprintf("/v1/cloud/project/%s/region/%s/gateway", projectID, url.PathEscape(region)) } + if CloudGatewaySpec.Network.Subnet.IPVersion == 0 && CloudGatewaySpec.Network.Subnet.Cidr != "" { + CloudGatewaySpec.Network.Subnet.IPVersion = ipVersionFromCIDR(CloudGatewaySpec.Network.Subnet.Cidr) + } + // Create resource task, err := common.CreateResource( cmd, @@ -698,3 +707,16 @@ func DeleteGatewayInterface(_ *cobra.Command, args []string) { display.OutputInfo(&flags.OutputFormatConfig, nil, "✅ Gateway %s interface %s deleted successfully", args[0], args[1]) } + +// ipVersionFromCIDR returns 4 or 6 based on the CIDR string. +// Returns 0 if the CIDR cannot be parsed. +func ipVersionFromCIDR(cidr string) int { + ip, _, err := net.ParseCIDR(cidr) + if err != nil { + return 0 + } + if ip.To4() != nil { + return 4 + } + return 6 +} diff --git a/internal/services/cloud/templates/cloud_floating_ip.tmpl b/internal/services/cloud/templates/cloud_floating_ip.tmpl new file mode 100644 index 00000000..b18a4ed8 --- /dev/null +++ b/internal/services/cloud/templates/cloud_floating_ip.tmpl @@ -0,0 +1,15 @@ +🌐 Floating IP {{.ServiceName}} +======= + +## General information + +**IP**: {{index .Result "ip"}} +**Status**: {{index .Result "status"}} +**Region**: {{index .Result "region"}} +**Network ID**: {{index .Result "networkId"}} + +## Associated entity + +**Entity**: {{index .Result "associatedEntity"}} + +💡 Use option -o json or -o yaml to get the raw output with all information \ No newline at end of file