From d5aaecb075e36706c3c250d109432652a44c6b67 Mon Sep 17 00:00:00 2001 From: Arthur Amstutz Date: Fri, 26 Sep 2025 19:52:27 +0000 Subject: [PATCH] feat: Add subnet+gateway information in output when creating a private network Signed-off-by: Arthur Amstutz --- README.md | 2 +- internal/cmd/cloud_network_test.go | 157 +++++++++++++++++++++++ internal/services/cloud/cloud_network.go | 86 +++++++++++-- 3 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 internal/cmd/cloud_network_test.go diff --git a/README.md b/README.md index c100659..1e3e2f3 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ You can also run the CLI using Docker: docker run -it --rm -v ovhcloud-cli-config-files:/config ovhcom/ovhcloud-cli login ``` -## Install using HomeBrew +## Install using Homebrew ```sh brew install ovh/tap/ovhcloud-cli diff --git a/internal/cmd/cloud_network_test.go b/internal/cmd/cloud_network_test.go new file mode 100644 index 0000000..de3846f --- /dev/null +++ b/internal/cmd/cloud_network_test.go @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "net/http" + + "github.com/jarcoal/httpmock" + "github.com/maxatome/go-testdeep/td" + "github.com/maxatome/tdhttpmock" + "github.com/ovh/ovhcloud-cli/internal/cmd" +) + +func (ms *MockSuite) TestCloudPrivateNetworkCreateCmd(assert, require *td.T) { + httpmock.RegisterMatcherResponder(http.MethodPost, + "https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/region/BHS5/network", + tdhttpmock.JSONBody(td.JSON(` + { + "gateway": { + "model": "s", + "name": "TestFromTheCLI" + }, + "name": "TestFromTheCLI", + "subnet": { + "cidr": "10.0.0.2/24", + "enableDhcp": false, + "enableGatewayIp": true, + "ipVersion": 4 + } + }`), + ), + httpmock.NewStringResponder(200, `{"id": "operation-12345"}`), + ) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/operation/operation-12345", + httpmock.NewStringResponder(200, ` + { + "id": "6610ec10-9b09-11f0-a8ac-0050568ce122", + "action": "network#create", + "createdAt": "2025-09-26T20:43:14.376907+02:00", + "startedAt": "2025-09-26T20:43:14.376907+02:00", + "completedAt": "2025-09-26T20:43:36.631086+02:00", + "progress": 0, + "regions": [ + "BHS5" + ], + "resourceId": "80c1de3e-9b09-11f0-993b-0050568ce122", + "status": "completed", + "subOperations": [ + { + "id": "8c0806ba-9b09-11f0-9a54-0050568ce122", + "action": "gateway#create", + "startedAt": "2025-09-26T20:43:14.376907+02:00", + "completedAt": "2025-09-26T20:43:36.631086+02:00", + "progress": 0, + "regions": [ + "BHS5" + ], + "resourceId": "97a2703c-9b09-11f0-9b6c-0050568ce122", + "status": "completed" + } + ] + }`), + ) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/network/private", + httpmock.NewStringResponder(200, `[ + { + "id": "pn-example", + "name": "TestFromTheCLI", + "vlanId": 1234, + "regions": [ + { + "region": "BHS5", + "status": "ACTIVE", + "openstackId": "80c1de3e-9b09-11f0-993b-0050568ce122" + } + ], + "type": "private", + "status": "ACTIVE" + } + ]`), + ) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/region/BHS5/network/80c1de3e-9b09-11f0-993b-0050568ce122/subnet", + httpmock.NewStringResponder(200, `[ + { + "id": "c59a3fdc-9b0f-11f0-ac97-0050568ce122", + "name": "TestFromTheCLI", + "cidr": "10.0.0.0/24", + "ipVersion": 4, + "dhcpEnabled": false, + "gatewayIp": "10.0.0.1", + "allocationPools": [ + { + "start": "10.0.0.2", + "end": "10.0.0.254" + } + ] + } + ]`), + ) + + httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/cloud/project/fakeProjectID/region/BHS5/gateway?subnetId=c59a3fdc-9b0f-11f0-ac97-0050568ce122", + httpmock.NewStringResponder(200, `[ + { + "id": "e7045f34-8f2b-41a4-a734-97b7b0e323de", + "status": "active", + "name": "TestFromTheCLI", + "interfaces": [ + { + "id": "56d17852-9b11-11f0-8d13-0050568ce122", + "ip": "10.0.0.1", + "subnetId": "56d17852-9b11-11f0-8d13-0050568ce122", + "networkId": "c59a3fdc-9b0f-11f0-ac97-0050568ce122" + }, + { + "id": "56d17852-9b11-11f0-8d13-0050568ce122", + "ip": "10.0.0.218", + "subnetId": "56d17852-9b11-11f0-8d13-0050568ce122", + "networkId": "c59a3fdc-9b0f-11f0-ac97-0050568ce122" + } + ], + "externalInformation": { + "ips": [ + { + "ip": "1.2.3.4", + "subnetId": "981c226c-57da-4766-966b-3b45db0cfc84" + } + ], + "networkId": "c59a3fdc-9b0f-11f0-ac97-0050568ce122" + }, + "region": "BHS5", + "model": "s" + } + ]`), + ) + + out, err := cmd.Execute("cloud", "network", "private", "create", "BHS5", "--cloud-project", "fakeProjectID", + "--gateway-model", "s", "--gateway-name", "TestFromTheCLI", "--name", "TestFromTheCLI", "--subnet-cidr", + "10.0.0.2/24", "--subnet-ip-version", "4", "--wait", "--subnet-enable-gateway-ip", "--yaml") + require.CmpNoError(err) + assert.String(out, `details: + id: pn-example + openstackId: 80c1de3e-9b09-11f0-993b-0050568ce122 + region: BHS5 + subnets: + - gateways: + - id: e7045f34-8f2b-41a4-a734-97b7b0e323de + name: TestFromTheCLI + id: c59a3fdc-9b0f-11f0-ac97-0050568ce122 + name: TestFromTheCLI +message: '✅ Network pn-example created successfully (Openstack ID: 80c1de3e-9b09-11f0-993b-0050568ce122)' +`) +} diff --git a/internal/services/cloud/cloud_network.go b/internal/services/cloud/cloud_network.go index d1b8641..b497b9f 100644 --- a/internal/services/cloud/cloud_network.go +++ b/internal/services/cloud/cloud_network.go @@ -80,8 +80,8 @@ var ( Subnet struct { Name string `json:"name,omitempty"` Cidr string `json:"cidr,omitempty"` - EnableDhcp bool `json:"enableDhcp,omitempty"` - EnableGatewayIp bool `json:"enableGatewayIp,omitempty"` + EnableDhcp bool `json:"enableDhcp"` + EnableGatewayIp bool `json:"enableGatewayIp"` GatewayIp string `json:"gatewayIp,omitempty"` DnsNameServers []string `json:"dnsNameServers,omitempty"` UseDefaultPublicDNSResolver bool `json:"useDefaultPublicDNSResolver,omitempty"` @@ -115,6 +115,16 @@ type ( Destination string `json:"destination,omitempty"` NextHop string `json:"nextHop,omitempty"` } + + NetworkRegionDetails struct { + OpenstackID string `json:"openstackId"` + Region string `json:"region"` + } + + PrivateNetwork struct { + ID string `json:"id"` + Regions []NetworkRegionDetails `json:"regions"` + } ) func ListPrivateNetworks(_ *cobra.Command, _ []string) { @@ -283,29 +293,81 @@ You can check the status of the operation with: 'ovhcloud cloud operation get %[ } // Fetch all private networks - var networks []struct { - ID string `json:"id"` - Regions []struct { - OpenstackID string `json:"openstackId"` - Region string `json:"region"` - } `json:"regions"` - } + var networks []PrivateNetwork if err := httpLib.Client.Get(fmt.Sprintf("/cloud/project/%s/network/private", projectID), &networks); err != nil { display.OutputError(&flags.OutputFormatConfig, "failed to fetch private networks: %s", err) return } // Find the created network + var ( + foundNetwork *PrivateNetwork + foundRegionNetwork *NetworkRegionDetails + ) + +eachNetwork: for _, network := range networks { for _, regionDetails := range network.Regions { if regionDetails.OpenstackID == networkID && regionDetails.Region == region { - display.OutputInfo(&flags.OutputFormatConfig, regionDetails, "✅ Network %s created successfully (Openstack ID: %s)", network.ID, regionDetails.OpenstackID) - return + foundNetwork = &network + foundRegionNetwork = ®ionDetails + break eachNetwork } } } - display.OutputError(&flags.OutputFormatConfig, "created network not found, this is unexpected") + if foundNetwork == nil { + display.OutputError(&flags.OutputFormatConfig, "created network not found, this is unexpected") + return + } + + // Fetch subnets of created network + endpoint = fmt.Sprintf("/cloud/project/%s/region/%s/network/%s/subnet", projectID, url.PathEscape(region), url.PathEscape(foundRegionNetwork.OpenstackID)) + var subnets []map[string]any + if err := httpLib.Client.Get(endpoint, &subnets); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to fetch subnets of created network: %s", err) + return + } + + // Fetch gateway of created subnets and prepare output + var outputSubnets []map[string]any + for _, subnet := range subnets { + endpoint = fmt.Sprintf("/cloud/project/%s/region/%s/gateway?subnetId=%s", + projectID, + url.PathEscape(region), + url.PathEscape(subnet["id"].(string)), + ) + + var gateways []map[string]any + if err := httpLib.Client.Get(endpoint, &gateways); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to fetch gateways of created network: %s", err) + return + } + + var outputGateways []map[string]any + for _, gateway := range gateways { + outputGateway := map[string]any{ + "id": gateway["id"], + "name": gateway["name"], + } + outputGateways = append(outputGateways, outputGateway) + } + + outputSubnets = append(outputSubnets, map[string]any{ + "id": subnet["id"], + "name": subnet["name"], + "gateways": outputGateways, + }) + } + + networkObject := map[string]any{ + "id": foundNetwork.ID, + "openstackId": foundRegionNetwork.OpenstackID, + "region": foundRegionNetwork.Region, + "subnets": outputSubnets, + } + + display.OutputInfo(&flags.OutputFormatConfig, networkObject, "✅ Network %s created successfully (Openstack ID: %s)", foundNetwork.ID, foundRegionNetwork.OpenstackID) } func DeletePrivateNetwork(_ *cobra.Command, args []string) {