diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 00000000..13566b81
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 00000000..a5563f63
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/ovhcloud-cli.iml b/.idea/ovhcloud-cli.iml
new file mode 100644
index 00000000..5e764c4f
--- /dev/null
+++ b/.idea/ovhcloud-cli.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 00000000..35eb1ddf
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/internal/cmd/cloud_instance_test.go b/internal/cmd/cloud_instance_test.go
index f7760d87..91cfe1c9 100644
--- a/internal/cmd/cloud_instance_test.go
+++ b/internal/cmd/cloud_instance_test.go
@@ -105,10 +105,10 @@ func (ms *MockSuite) TestCloudInstanceNullImageCmd(assert, require *td.T) {
IP addresses:
- IP | Type | Gateway IP
- ------------------------|------------------------|------------------------
- 1.2.3.4 | public | 1.2.3.4
- 2001:db8::1 | public | 2001:db8::ff
+ IP | Type | Gateway IP
+ -------------|--------|--------------
+ 1.2.3.4 | public | 1.2.3.4
+ 2001:db8::1 | public | 2001:db8::ff
## Flavor details
diff --git a/internal/cmd/dedicatedcloud.go b/internal/cmd/dedicatedcloud.go
index 9d9baf39..854f1b1d 100644
--- a/internal/cmd/dedicatedcloud.go
+++ b/internal/cmd/dedicatedcloud.go
@@ -32,5 +32,49 @@ func init() {
Run: dedicatedcloud.GetDedicatedCloud,
})
+ // Datacenter commands
+ dedicatedcloudDatacenterCmd := &cobra.Command{
+ Use: "datacenter",
+ Short: "Manage datacenters of a DedicatedCloud",
+ }
+ dedicatedcloudCmd.AddCommand(dedicatedcloudDatacenterCmd)
+
+ dedicatedcloudDatacenterCmd.AddCommand(withFilterFlag(&cobra.Command{
+ Use: "list ",
+ Aliases: []string{"ls"},
+ Short: "List datacenters of a DedicatedCloud",
+ Args: cobra.ExactArgs(1),
+ Run: dedicatedcloud.ListDatacenter,
+ }))
+
+ datacenterGetCmd := &cobra.Command{
+ Use: "get ",
+ Short: "Get information about a specific datacenter of a DedicatedCloud",
+ Args: cobra.ExactArgs(2),
+ Run: dedicatedcloud.GetDatacenter,
+ }
+ dedicatedcloudDatacenterCmd.AddCommand(datacenterGetCmd)
+
+ dedicatedcloudDatacenterCmd.AddCommand(&cobra.Command{
+ Use: "hosts ",
+ Short: "Get hosts information for a specific datacenter",
+ Args: cobra.ExactArgs(2),
+ Run: dedicatedcloud.GetDatacenterHosts,
+ })
+
+ dedicatedcloudDatacenterCmd.AddCommand(&cobra.Command{
+ Use: "filers ",
+ Short: "Get filers information for a specific datacenter",
+ Args: cobra.ExactArgs(2),
+ Run: dedicatedcloud.GetDatacenterFilers,
+ })
+
+ dedicatedcloudDatacenterCmd.AddCommand(&cobra.Command{
+ Use: "clusters ",
+ Short: "Get clusters information for a specific datacenter",
+ Args: cobra.ExactArgs(2),
+ Run: dedicatedcloud.GetDatacenterClusters,
+ })
+
rootCmd.AddCommand(dedicatedcloudCmd)
}
diff --git a/internal/cmd/dedicatedcloud_test.go b/internal/cmd/dedicatedcloud_test.go
new file mode 100644
index 00000000..5f279685
--- /dev/null
+++ b/internal/cmd/dedicatedcloud_test.go
@@ -0,0 +1,298 @@
+// SPDX-FileCopyrightText: 2025 OVH SAS
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package cmd_test
+
+import (
+ "github.com/jarcoal/httpmock"
+ "github.com/maxatome/go-testdeep/td"
+ "github.com/ovh/ovhcloud-cli/internal/cmd"
+)
+
+func (ms *MockSuite) TestDedicatedCloudListCmd(assert, require *td.T) {
+ // Mock the list of dedicated clouds
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud",
+ httpmock.NewStringResponder(200, `["pcc-12345","pcc-67890"]`).Once())
+
+ // Mock expanded dedicated cloud details
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345",
+ httpmock.NewStringResponder(200, `{
+ "serviceName": "pcc-12345",
+ "location": "pcc-12345",
+ "state": "delivered",
+ "description": "Test PCC",
+ "version": {
+ "major": "8",
+ "minor": "0",
+ "build": "U3e.24674346"
+ }
+ }`).Once())
+
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-67890",
+ httpmock.NewStringResponder(200, `{
+ "serviceName": "pcc-67890",
+ "location": "pcc-67890",
+ "state": "delivered",
+ "description": "Test PCC 2",
+ "version": {
+ "major": "8",
+ "minor": "0",
+ "build": "U3g.24853646"
+ }
+ }`).Once())
+
+ // Mock location list
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/location",
+ httpmock.NewStringResponder(200, `["pcc-12345","pcc-67890"]`).Once())
+
+ // Mock location details
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/location/pcc-12345",
+ httpmock.NewStringResponder(200, `{
+ "regionLocation": "Europe (France - Gravelines)"
+ }`).Once())
+
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/location/pcc-67890",
+ httpmock.NewStringResponder(200, `{
+ "regionLocation": "Europe (United Kingdom - Erith)"
+ }`).Once())
+
+ out, err := cmd.Execute("dedicated-cloud", "list")
+
+ require.CmpNoError(err)
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("pcc-12345"))
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("pcc-67890"))
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Europe (France - Gravelines)"))
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("8.0.U3e.24674346"))
+}
+
+func (ms *MockSuite) TestDedicatedCloudGetCmd(assert, require *td.T) {
+ // Mock dedicated cloud details
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345",
+ httpmock.NewStringResponder(200, `{
+ "serviceName": "pcc-12345",
+ "location": "pcc-12345",
+ "state": "delivered",
+ "generation": "8",
+ "version": {
+ "major": "8",
+ "minor": "0",
+ "build": "U3e.24674346"
+ },
+ "commercialRange": "nsx-t",
+ "billingType": "hourly",
+ "webInterfaceUrl": "https://pcc-12345.ovh.com",
+ "vScopeUrl": "https://vscope.ovh.com/pcc-12345",
+ "advancedSecurity": true,
+ "bandwidth": "1Gbps",
+ "spla": false,
+ "sslV3": false,
+ "userAccessPolicy": "readOnly",
+ "iam": {
+ "displayName": "PCC-12345",
+ "urn": "urn:v1:eu:resource:pccVMware:pcc-12345"
+ }
+ }`).Once())
+
+ // Mock datacenters list
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter",
+ httpmock.NewStringResponder(200, `[1, 2]`).Once())
+
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1",
+ httpmock.NewStringResponder(200, `{
+ "datacenterId": 1,
+ "name": "datacenter-1",
+ "version": "8.0",
+ "commercialName": "Enterprise"
+ }`).Once())
+
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/2",
+ httpmock.NewStringResponder(200, `{
+ "datacenterId": 2,
+ "name": "datacenter-2",
+ "version": "8.0",
+ "commercialName": "Standard"
+ }`).Once())
+
+ // Mock location list and details for regionLocation
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/location",
+ httpmock.NewStringResponder(200, `["pcc-12345"]`).Once())
+
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/location/pcc-12345",
+ httpmock.NewStringResponder(200, `{
+ "regionLocation": "Europe (France - Gravelines)"
+ }`).Once())
+
+ out, err := cmd.Execute("dedicated-cloud", "get", "pcc-12345")
+
+ require.CmpNoError(err)
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("pcc-12345"))
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Europe (France - Gravelines)"))
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("8.0.U3e.24674346"))
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("datacenter-1"))
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("datacenter-2"))
+}
+
+func (ms *MockSuite) TestDedicatedCloudDatacenterListCmd(assert, require *td.T) {
+ // Mock datacenters list
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter",
+ httpmock.NewStringResponder(200, `[1]`).Once())
+
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1",
+ httpmock.NewStringResponder(200, `{
+ "datacenterId": 1,
+ "name": "datacenter-1",
+ "version": "8.0",
+ "commercialName": "Enterprise"
+ }`).Once())
+
+ // Mock hosts for totals calculation
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/host",
+ httpmock.NewStringResponder(200, `[1481, 1511]`).Once())
+
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/host/1481",
+ httpmock.NewStringResponder(200, `{
+ "hostId": 1481,
+ "cpuNum": 32,
+ "ram": {
+ "value": 768,
+ "unit": "GB"
+ },
+ "vmTotal": 36
+ }`).Once())
+
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/host/1511",
+ httpmock.NewStringResponder(200, `{
+ "hostId": 1511,
+ "cpuNum": 32,
+ "ram": {
+ "value": 768,
+ "unit": "GB"
+ },
+ "vmTotal": 16
+ }`).Once())
+
+ // Mock filers for totals calculation
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/filer",
+ httpmock.NewStringResponder(200, `[1557]`).Once())
+
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/filer/1557",
+ httpmock.NewStringResponder(200, `{
+ "filerId": 1557,
+ "size": {
+ "value": 3000,
+ "unit": "GB"
+ }
+ }`).Once())
+
+ // Mock global filers
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/filer",
+ httpmock.NewStringResponder(200, `[]`).Once())
+
+ out, err := cmd.Execute("dedicated-cloud", "datacenter", "list", "pcc-12345")
+
+ require.CmpNoError(err)
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("datacenter-1"))
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("64")) // totalCores: 32 + 32
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("1536 GB")) // totalRAM: 768 + 768
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("52")) // totalVMs: 36 + 16
+}
+
+func (ms *MockSuite) TestDedicatedCloudDatacenterGetCmd(assert, require *td.T) {
+ // Mock datacenter details
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1",
+ httpmock.NewStringResponder(200, `{
+ "datacenterId": 1,
+ "name": "datacenter-1",
+ "version": "8.0",
+ "commercialName": "Enterprise",
+ "commercialRange": "nsx-t",
+ "removable": false
+ }`).Once())
+
+ // Mock clusters
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/cluster",
+ httpmock.NewStringResponder(200, `[25035]`).Once())
+
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/cluster/25035",
+ httpmock.NewStringResponder(200, `{
+ "clusterId": 25035,
+ "name": "Cluster1",
+ "drsStatus": "enabled",
+ "drsMode": "fullyAutomated",
+ "haStatus": "enabled",
+ "evcMode": "disabled"
+ }`).Once())
+
+ // Mock hosts
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/host",
+ httpmock.NewStringResponder(200, `[1481]`).Once())
+
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/host/1481",
+ httpmock.NewStringResponder(200, `{
+ "hostId": 1481,
+ "name": "172.17.136.56",
+ "clusterName": "Cluster1",
+ "clusterId": 25035,
+ "connectionState": "connected",
+ "inMaintenance": false,
+ "cpuNum": 32,
+ "cpu": {
+ "value": 93,
+ "unit": "GHz"
+ },
+ "ram": {
+ "value": 768,
+ "unit": "GB"
+ },
+ "profile": "PRE 768 NSX",
+ "vmTotal": 36,
+ "billingType": "hourly"
+ }`).Once())
+
+ // Mock local filers
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/filer",
+ httpmock.NewStringResponder(200, `[1557]`).Once())
+
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/datacenter/1/filer/1557",
+ httpmock.NewStringResponder(200, `{
+ "filerId": 1557,
+ "name": "ssd-001557",
+ "connectionState": "online",
+ "state": "delivered",
+ "size": {
+ "value": 3000,
+ "unit": "GB"
+ },
+ "spaceFree": {
+ "value": 2972,
+ "unit": "GB"
+ },
+ "master": "cluster5068.example.com",
+ "vmTotal": 0,
+ "billingType": "hourly",
+ "activeNode": "master"
+ }`).Once())
+
+ // Mock global filers
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345/filer",
+ httpmock.NewStringResponder(200, `[]`).Once())
+
+ // Mock dedicated cloud for IAM
+ httpmock.RegisterResponder("GET", "https://eu.api.ovh.com/1.0/dedicatedCloud/pcc-12345",
+ httpmock.NewStringResponder(200, `{
+ "iam": {
+ "displayName": "PCC-12345",
+ "urn": "urn:v1:eu:resource:pccVMware:pcc-12345"
+ }
+ }`).Once())
+
+ out, err := cmd.Execute("dedicated-cloud", "datacenter", "get", "pcc-12345", "1")
+
+ require.CmpNoError(err)
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("datacenter-1"))
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Total CPU (Cores)"))
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Total RAM"))
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Total VMs"))
+ assert.Cmp(cleanWhitespacesHelper(out), td.Contains("Total Disk Space"))
+}
diff --git a/internal/display/display.go b/internal/display/display.go
index a1789869..92c8cacb 100644
--- a/internal/display/display.go
+++ b/internal/display/display.go
@@ -22,7 +22,6 @@ import (
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
- "github.com/charmbracelet/x/term"
"github.com/ghodss/yaml"
"github.com/ovh/ovhcloud-cli/internal/filters"
"gopkg.in/ini.v1"
@@ -273,19 +272,10 @@ func OutputObject(value map[string]any, serviceName, templateContent string, out
exitError("failed to execute template: %s", err)
}
- // Define word wrap for the renderer.
- // Use 80 characters by default, or the terminal width if available.
- wordWrap := 80
- if termFd := os.Stdout.Fd(); term.IsTerminal(termFd) {
- if termWidth, _, _ := term.GetSize(termFd); termWidth > 0 {
- wordWrap = termWidth
- }
- }
-
r, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithPreservedNewLines(),
- glamour.WithWordWrap(wordWrap),
+ glamour.WithWordWrap(0),
)
if err != nil {
exitError("failed to init rendered: %s", err)
diff --git a/internal/services/dedicatedcloud/dedicatedcloud.go b/internal/services/dedicatedcloud/dedicatedcloud.go
index 88fe9e0c..9a1ae694 100644
--- a/internal/services/dedicatedcloud/dedicatedcloud.go
+++ b/internal/services/dedicatedcloud/dedicatedcloud.go
@@ -6,23 +6,649 @@ package dedicatedcloud
import (
_ "embed"
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "strings"
+ "github.com/ovh/ovhcloud-cli/internal/display"
+ "github.com/ovh/ovhcloud-cli/internal/filters"
"github.com/ovh/ovhcloud-cli/internal/flags"
- "github.com/ovh/ovhcloud-cli/internal/services/common"
+ httpLib "github.com/ovh/ovhcloud-cli/internal/http"
"github.com/spf13/cobra"
)
var (
- dedicatedcloudColumnsToDisplay = []string{"serviceName", "location", "state", "description"}
+ dedicatedcloudColumnsToDisplay = []string{"serviceName", "regionLocation", "version", "state", "description"}
//go:embed templates/dedicatedcloud.tmpl
dedicatedcloudTemplate string
+
+ //go:embed templates/datacenter.tmpl
+ datacenterTemplate string
)
+// toFloat64 converts any numeric type to float64
+func toFloat64(v any) float64 {
+ switch n := v.(type) {
+ case float64:
+ return n
+ case int:
+ return float64(n)
+ case json.Number:
+ if f, err := n.Float64(); err == nil {
+ return f
+ }
+ }
+ return 0
+}
+
+// toInt converts any numeric type to int
+func toInt(v any) int {
+ switch n := v.(type) {
+ case int:
+ return n
+ case float64:
+ return int(n)
+ case json.Number:
+ if i, err := n.Int64(); err == nil {
+ return int(i)
+ }
+ }
+ return 0
+}
+
+// getLocationMap fetches the location mapping from the API
+func getLocationMap() (map[string]string, error) {
+ // Fetch location list (array of location IDs)
+ var locationIDs []string
+ if err := httpLib.Client.Get("/dedicatedCloud/location", &locationIDs); err != nil {
+ return nil, fmt.Errorf("failed to fetch location list: %w", err)
+ }
+
+ // Build location map by fetching each location detail
+ locationMap := make(map[string]string)
+ for _, locationID := range locationIDs {
+ var locationInfo map[string]any
+ locationPath := fmt.Sprintf("/dedicatedCloud/location/%s", url.PathEscape(locationID))
+ if err := httpLib.Client.Get(locationPath, &locationInfo); err != nil {
+ // Skip if we can't fetch this location
+ continue
+ }
+ if regionLocation, ok := locationInfo["regionLocation"].(string); ok {
+ locationMap[locationID] = regionLocation
+ }
+ }
+
+ return locationMap, nil
+}
+
func ListDedicatedCloud(_ *cobra.Command, _ []string) {
- common.ManageListRequest("/v1/dedicatedCloud", "", dedicatedcloudColumnsToDisplay, flags.GenericFilters)
+ // Fetch dedicatedcloud list
+ body, err := httpLib.FetchExpandedArray("/dedicatedCloud", "")
+ if err != nil {
+ display.OutputError(&flags.OutputFormatConfig, "failed to fetch results: %s", err)
+ return
+ }
+
+ // Fetch location mapping
+ locationMap, err := getLocationMap()
+ if err != nil {
+ display.OutputError(&flags.OutputFormatConfig, "%s", err)
+ return
+ }
+
+ // Enrich each object with version and regionLocation
+ for _, obj := range body {
+ // Format version
+ if versionObj, ok := obj["version"].(map[string]any); ok {
+ versionStr, _ := versionObj["major"].(string)
+ if minor, ok := versionObj["minor"].(string); ok && minor != "" {
+ versionStr += "." + minor
+ }
+ if build, ok := versionObj["build"].(string); ok && build != "" {
+ versionStr += "." + build
+ }
+ obj["version"] = versionStr
+ }
+
+ // Add regionLocation from location mapping
+ if location, ok := obj["location"].(string); ok {
+ if regionLocation, ok := locationMap[location]; ok {
+ obj["regionLocation"] = regionLocation
+ } else {
+ obj["regionLocation"] = location
+ }
+ }
+ }
+
+ // Filter results
+ body, err = filters.FilterLines(body, flags.GenericFilters)
+ if err != nil {
+ display.OutputError(&flags.OutputFormatConfig, "failed to filter results: %s", err)
+ return
+ }
+
+ // Display table
+ display.RenderTable(body, dedicatedcloudColumnsToDisplay, &flags.OutputFormatConfig)
}
func GetDedicatedCloud(_ *cobra.Command, args []string) {
- common.ManageObjectRequest("/v1/dedicatedCloud", args[0], dedicatedcloudTemplate)
+ endpoint := fmt.Sprintf("/dedicatedCloud/%s", url.PathEscape(args[0]))
+
+ // Fetch dedicatedcloud
+ var object map[string]any
+ if err := httpLib.Client.Get(endpoint, &object); err != nil {
+ display.OutputError(&flags.OutputFormatConfig, "error fetching %s: %s", endpoint, err)
+ return
+ }
+
+ // Fetch location mapping and add regionLocation
+ locationMap, err := getLocationMap()
+ if err == nil {
+ if location, ok := object["location"].(string); ok {
+ if regionLocation, ok := locationMap[location]; ok {
+ object["regionLocation"] = regionLocation
+ } else {
+ object["regionLocation"] = location
+ }
+ }
+ }
+
+ // Fetch datacenters list
+ datacentersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter", url.PathEscape(args[0]))
+ datacenters, err := httpLib.FetchExpandedArray(datacentersEndpoint, "")
+ if err != nil {
+ display.OutputError(&flags.OutputFormatConfig, "error fetching datacenters for %s: %s", args[0], err)
+ return
+ }
+ object["datacenters"] = datacenters
+
+ display.OutputObject(object, args[0], dedicatedcloudTemplate, &flags.OutputFormatConfig)
+}
+
+func ListDatacenter(_ *cobra.Command, args []string) {
+ endpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter", url.PathEscape(args[0]))
+ datacenters, err := httpLib.FetchExpandedArray(endpoint, "")
+ if err != nil {
+ display.OutputError(&flags.OutputFormatConfig, "failed to fetch results: %s", err)
+ return
+ }
+
+ // Enrich each datacenter with totals
+ for i := range datacenters {
+ datacenterId := ""
+ if idRaw, ok := datacenters[i]["datacenterId"]; ok && idRaw != nil {
+ datacenterId = fmt.Sprint(idRaw)
+ }
+
+ if datacenterId == "" {
+ continue
+ }
+
+ // Fetch hosts for this datacenter
+ hostsEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/host", url.PathEscape(args[0]), url.PathEscape(datacenterId))
+ hosts, err := httpLib.FetchExpandedArray(hostsEndpoint, "")
+ if err != nil {
+ // If error, continue with empty totals
+ hosts = []map[string]any{}
+ }
+
+ // Fetch filers for this datacenter
+ localFilersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/filer", url.PathEscape(args[0]), url.PathEscape(datacenterId))
+ localFilers, err := httpLib.FetchExpandedArray(localFilersEndpoint, "")
+ if err != nil {
+ localFilers = []map[string]any{}
+ }
+
+ globalFilersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/filer", url.PathEscape(args[0]))
+ globalFilers, err := httpLib.FetchExpandedArray(globalFilersEndpoint, "")
+ if err != nil {
+ globalFilers = []map[string]any{}
+ }
+ allFilers := append(localFilers, globalFilers...)
+
+ // Calculate totals
+ totalCores := 0
+ totalRAM := 0.0
+ totalVMs := 0
+ totalDiskSpace := 0.0
+
+ // Sum from hosts
+ for _, host := range hosts {
+ // Sum cores
+ if cpuNumRaw, ok := host["cpuNum"]; ok && cpuNumRaw != nil {
+ totalCores += toInt(cpuNumRaw)
+ }
+
+ // Sum RAM
+ if ram, ok := host["ram"].(map[string]any); ok {
+ totalRAM += toFloat64(ram["value"])
+ }
+
+ // Sum VMs
+ if vmTotal, ok := host["vmTotal"]; ok && vmTotal != nil {
+ switch v := vmTotal.(type) {
+ case float64:
+ totalVMs += int(v)
+ case json.Number:
+ if i, err := v.Int64(); err == nil {
+ totalVMs += int(i)
+ }
+ }
+ }
+ }
+
+ // Sum disk space from filers
+ for _, filer := range allFilers {
+ if size, ok := filer["size"].(map[string]any); ok {
+ if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil {
+ sizeValue := toFloat64(sizeValueRaw)
+ // Convert to GB if needed
+ if sizeUnit, ok := size["unit"].(string); ok {
+ if strings.ToUpper(sizeUnit) == "TB" {
+ sizeValue *= 1000 // Convert TB to GB
+ } else if strings.ToUpper(sizeUnit) == "MB" {
+ sizeValue /= 1000 // Convert MB to GB
+ }
+ }
+ totalDiskSpace += sizeValue
+ }
+ }
+ }
+
+ // Add totals to datacenter
+ datacenters[i]["totalCores"] = totalCores
+ datacenters[i]["totalRAM"] = fmt.Sprintf("%.0f GB", totalRAM)
+ datacenters[i]["totalVMs"] = totalVMs
+ datacenters[i]["totalDiskSpace"] = fmt.Sprintf("%.0f GB", totalDiskSpace)
+ }
+
+ // Filter results
+ datacenters, err = filters.FilterLines(datacenters, flags.GenericFilters)
+ if err != nil {
+ display.OutputError(&flags.OutputFormatConfig, "failed to filter results: %s", err)
+ return
+ }
+
+ // Display table
+ display.RenderTable(datacenters, []string{"datacenterId", "name", "version", "commercialName", "totalCores", "totalRAM", "totalVMs", "totalDiskSpace"}, &flags.OutputFormatConfig)
+}
+
+func GetDatacenter(_ *cobra.Command, args []string) {
+ getDatacenterWithOptions(args, false, false, false, true)
+}
+
+func GetDatacenterHosts(_ *cobra.Command, args []string) {
+ getDatacenterWithOptions(args, true, false, false, false)
+}
+
+func GetDatacenterFilers(_ *cobra.Command, args []string) {
+ getDatacenterWithOptions(args, false, true, false, false)
+}
+
+func GetDatacenterClusters(_ *cobra.Command, args []string) {
+ getDatacenterWithOptions(args, false, false, true, false)
+}
+
+func getDatacenterWithOptions(args []string, includeHosts, includeFilers, includeClusters, includeTotals bool) {
+ path := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s", url.PathEscape(args[0]), url.PathEscape(args[1]))
+
+ // Fetch datacenter
+ var object map[string]any
+ if err := httpLib.Client.Get(path, &object); err != nil {
+ display.OutputError(&flags.OutputFormatConfig, "error fetching %s: %s", path, err)
+ return
+ }
+
+ // Fetch hosts list if requested or if we need totals
+ var hosts []map[string]any
+ if includeHosts || includeTotals {
+ hostsEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/host", url.PathEscape(args[0]), url.PathEscape(args[1]))
+ var err error
+ hosts, err = httpLib.FetchExpandedArray(hostsEndpoint, "")
+ if err != nil {
+ display.OutputError(&flags.OutputFormatConfig, "error fetching hosts for %s: %s", args[1], err)
+ return
+ }
+ }
+
+ // Enrich hosts with formatted data and group by cluster
+ hostsByCluster := make(map[string][]map[string]any)
+ if includeHosts {
+ for _, host := range hosts {
+ // Format Core Number with GHz in parentheses
+ coreNumber := ""
+ if cpuNumRaw, ok := host["cpuNum"]; ok && cpuNumRaw != nil {
+ cpuNumValue := toFloat64(cpuNumRaw)
+ if cpuNumValue > 0 {
+ coreNumber = fmt.Sprintf("%.0f", cpuNumValue)
+ if cpu, ok := host["cpu"].(map[string]any); ok {
+ if cpuValueRaw, ok := cpu["value"]; ok && cpuValueRaw != nil {
+ cpuValue := toFloat64(cpuValueRaw)
+ if cpuValue > 0 {
+ // Use format: cores (freqGHz)
+ coreNumber += fmt.Sprintf(" (%.0fGHz)", cpuValue)
+ }
+ }
+ }
+ }
+ }
+ host["coreNumber"] = coreNumber
+
+ // Add VM count
+ switch v := host["vmTotal"].(type) {
+ case float64:
+ host["vmCount"] = int(v)
+ case int:
+ host["vmCount"] = v
+ case json.Number:
+ if i, err := v.Int64(); err == nil {
+ host["vmCount"] = int(i)
+ } else {
+ host["vmCount"] = 0
+ }
+ default:
+ host["vmCount"] = 0
+ }
+
+ // Add maintenance status emoji
+ if inMaintenance, ok := host["inMaintenance"].(bool); ok && inMaintenance {
+ host["maintenanceStatus"] = "🔧"
+ } else {
+ host["maintenanceStatus"] = "✅"
+ }
+
+ // Add connection state indicator
+ connectionStateIndicator := "🔴"
+ if connectionState, ok := host["connectionState"].(string); ok && connectionState == "connected" {
+ connectionStateIndicator = "🟢"
+ }
+ host["connectionStateIndicator"] = connectionStateIndicator
+
+ // Group by cluster
+ clusterName := "Unknown"
+ if cn, ok := host["clusterName"].(string); ok && cn != "" {
+ clusterName = cn
+ }
+ hostsByCluster[clusterName] = append(hostsByCluster[clusterName], host)
+ }
+ if len(hostsByCluster) > 0 {
+ object["hostsByCluster"] = hostsByCluster
+ object["hosts"] = hosts // Keep original for backward compatibility
+ }
+ }
+
+ // Fetch clusters information
+ if includeClusters {
+ clustersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/cluster", url.PathEscape(args[0]), url.PathEscape(args[1]))
+ clustersList, err := httpLib.FetchExpandedArray(clustersEndpoint, "")
+ if err != nil {
+ // If error, just continue with empty list
+ clustersList = []map[string]any{}
+ }
+
+ // Build a map of clusterName to clusterId from hosts
+ clusterNameToId := make(map[string]int)
+ if includeHosts {
+ for _, host := range hosts {
+ if clusterName, ok := host["clusterName"].(string); ok && clusterName != "" {
+ switch v := host["clusterId"].(type) {
+ case float64:
+ clusterId := int(v)
+ if clusterId > 0 {
+ clusterNameToId[clusterName] = clusterId
+ }
+ case int:
+ if v > 0 {
+ clusterNameToId[clusterName] = v
+ }
+ case json.Number:
+ if i, err := v.Int64(); err == nil {
+ clusterId := int(i)
+ if clusterId > 0 {
+ clusterNameToId[clusterName] = clusterId
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Fetch details for clusters
+ clustersWithDetails := make([]map[string]any, 0)
+ // If we have hosts, fetch details for clusters that have hosts
+ if includeHosts && len(hostsByCluster) > 0 {
+ for clusterName := range hostsByCluster {
+ clusterId, found := clusterNameToId[clusterName]
+ if !found {
+ // Try to find clusterId from clustersList by name
+ for _, cluster := range clustersList {
+ if name, ok := cluster["name"].(string); ok && name == clusterName {
+ if idRaw, ok := cluster["clusterId"]; ok && idRaw != nil {
+ clusterId = toInt(idRaw)
+ break
+ }
+ }
+ }
+ }
+ if clusterId > 0 {
+ clusterDetailEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/cluster/%d", url.PathEscape(args[0]), url.PathEscape(args[1]), clusterId)
+ var clusterDetail map[string]any
+ if err := httpLib.Client.Get(clusterDetailEndpoint, &clusterDetail); err == nil {
+ // Format drsStatus
+ if drsStatus, ok := clusterDetail["drsStatus"].(string); ok {
+ if drsStatus == "enabled" {
+ clusterDetail["drsStatusFormatted"] = "🟢 enabled"
+ } else {
+ clusterDetail["drsStatusFormatted"] = "🔴 " + drsStatus
+ }
+ }
+ // Format haStatus
+ if haStatus, ok := clusterDetail["haStatus"].(string); ok {
+ if haStatus == "enabled" {
+ clusterDetail["haStatusFormatted"] = "🟢 enabled"
+ } else {
+ clusterDetail["haStatusFormatted"] = "🔴 " + haStatus
+ }
+ }
+ // Remove unwanted fields
+ delete(clusterDetail, "vmwareClusterId")
+ delete(clusterDetail, "autoscale")
+ clustersWithDetails = append(clustersWithDetails, clusterDetail)
+ }
+ }
+ }
+ } else {
+ // If no hosts, fetch details for all clusters from clustersList
+ for _, cluster := range clustersList {
+ if idRaw, ok := cluster["clusterId"]; ok && idRaw != nil {
+ clusterId := toInt(idRaw)
+ if clusterId > 0 {
+ clusterDetailEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/cluster/%d", url.PathEscape(args[0]), url.PathEscape(args[1]), clusterId)
+ var clusterDetail map[string]any
+ if err := httpLib.Client.Get(clusterDetailEndpoint, &clusterDetail); err == nil {
+ // Format drsStatus
+ if drsStatus, ok := clusterDetail["drsStatus"].(string); ok {
+ if drsStatus == "enabled" {
+ clusterDetail["drsStatusFormatted"] = "🟢 enabled"
+ } else {
+ clusterDetail["drsStatusFormatted"] = "🔴 " + drsStatus
+ }
+ }
+ // Format haStatus
+ if haStatus, ok := clusterDetail["haStatus"].(string); ok {
+ if haStatus == "enabled" {
+ clusterDetail["haStatusFormatted"] = "🟢 enabled"
+ } else {
+ clusterDetail["haStatusFormatted"] = "🔴 " + haStatus
+ }
+ }
+ // Remove unwanted fields
+ delete(clusterDetail, "vmwareClusterId")
+ delete(clusterDetail, "autoscale")
+ clustersWithDetails = append(clustersWithDetails, clusterDetail)
+ }
+ }
+ }
+ }
+ }
+ if len(clustersWithDetails) > 0 {
+ object["clusters"] = clustersWithDetails
+ }
+ }
+
+ // Fetch local filers (datacenter level) if requested or if we need totals
+ var allFilers []map[string]any
+ if includeFilers || includeTotals {
+ localFilersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/datacenter/%s/filer", url.PathEscape(args[0]), url.PathEscape(args[1]))
+ localFilers, err := httpLib.FetchExpandedArray(localFilersEndpoint, "")
+ if err != nil {
+ display.OutputError(&flags.OutputFormatConfig, "error fetching local filers for %s: %s", args[1], err)
+ return
+ }
+
+ // Fetch global filers (service level)
+ globalFilersEndpoint := fmt.Sprintf("/dedicatedCloud/%s/filer", url.PathEscape(args[0]))
+ globalFilers, err := httpLib.FetchExpandedArray(globalFilersEndpoint, "")
+ if err != nil {
+ // If error, just continue with empty list
+ globalFilers = []map[string]any{}
+ }
+
+ // Combine both lists
+ allFilers = make([]map[string]any, 0, len(localFilers)+len(globalFilers))
+ allFilers = append(allFilers, localFilers...)
+ allFilers = append(allFilers, globalFilers...)
+
+ // Enrich filers with formatted data
+ for i, filer := range allFilers {
+ // Set visibility based on source
+ if i < len(localFilers) {
+ filer["visibility"] = "Local"
+ } else {
+ filer["visibility"] = "Global"
+ }
+ // Format size
+ sizeStr := ""
+ if size, ok := filer["size"].(map[string]any); ok {
+ if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil {
+ sizeValue := toFloat64(sizeValueRaw)
+ if sizeValue > 0 {
+ sizeStr = fmt.Sprintf("%.0f", sizeValue)
+ if sizeUnit, ok := size["unit"].(string); ok {
+ sizeStr += " " + sizeUnit
+ }
+ }
+ }
+ }
+ filer["sizeFormatted"] = sizeStr
+
+ // Format spaceFree
+ spaceFreeStr := ""
+ if spaceFreeRaw, ok := filer["spaceFree"]; ok && spaceFreeRaw != nil {
+ spaceFreeValue := toFloat64(spaceFreeRaw)
+ if spaceFreeValue > 0 {
+ spaceFreeStr = fmt.Sprintf("%.0f GB", spaceFreeValue)
+ }
+ }
+ filer["spaceFreeFormatted"] = spaceFreeStr
+
+ // Extract cluster name from master (first part of domain)
+ clusterName := ""
+ if master, ok := filer["master"].(string); ok && master != "" {
+ // Extract first part before first dot
+ parts := strings.Split(master, ".")
+ if len(parts) > 0 {
+ clusterName = parts[0]
+ }
+ }
+ filer["clusterName"] = clusterName
+
+ // Add VM count
+ switch v := filer["vmTotal"].(type) {
+ case float64:
+ filer["vmCount"] = int(v)
+ case int:
+ filer["vmCount"] = v
+ case json.Number:
+ if i, err := v.Int64(); err == nil {
+ filer["vmCount"] = int(i)
+ } else {
+ filer["vmCount"] = 0
+ }
+ default:
+ filer["vmCount"] = 0
+ }
+
+ // Add connection state indicator
+ connectionStateIndicator := "🔴"
+ if connectionState, ok := filer["connectionState"].(string); ok && connectionState == "online" {
+ connectionStateIndicator = "🟢"
+ }
+ filer["connectionStateIndicator"] = connectionStateIndicator
+ }
+ if includeFilers && len(allFilers) > 0 {
+ object["filers"] = allFilers
+ }
+ }
+
+ // Calculate totals only if requested
+ if includeTotals {
+ totalCores := 0
+ totalRAM := 0.0
+ totalVMs := 0
+ totalDiskSpace := 0.0
+
+ // Sum from hosts
+ for _, host := range hosts {
+ // Sum cores
+ if cpuNumRaw, ok := host["cpuNum"]; ok && cpuNumRaw != nil {
+ totalCores += toInt(cpuNumRaw)
+ }
+
+ // Sum RAM
+ if ram, ok := host["ram"].(map[string]any); ok {
+ totalRAM += toFloat64(ram["value"])
+ }
+
+ // Sum VMs
+ if vmTotal, ok := host["vmTotal"]; ok && vmTotal != nil {
+ switch v := vmTotal.(type) {
+ case float64:
+ totalVMs += int(v)
+ case json.Number:
+ if i, err := v.Int64(); err == nil {
+ totalVMs += int(i)
+ }
+ }
+ }
+ }
+
+ // Sum disk space from filers
+ for _, filer := range allFilers {
+ if size, ok := filer["size"].(map[string]any); ok {
+ if sizeValueRaw, ok := size["value"]; ok && sizeValueRaw != nil {
+ sizeValue := toFloat64(sizeValueRaw)
+ // Convert to GB if needed
+ if sizeUnit, ok := size["unit"].(string); ok {
+ if strings.ToUpper(sizeUnit) == "TB" {
+ sizeValue *= 1000 // Convert TB to GB
+ } else if strings.ToUpper(sizeUnit) == "MB" {
+ sizeValue /= 1000 // Convert MB to GB
+ }
+ }
+ totalDiskSpace += sizeValue
+ }
+ }
+ }
+
+ // Format totals
+ object["totalCores"] = totalCores
+ object["totalRAM"] = fmt.Sprintf("%.0f GB", totalRAM)
+ object["totalVMs"] = totalVMs
+ object["totalDiskSpace"] = fmt.Sprintf("%.0f GB", totalDiskSpace)
+ }
+
+ display.OutputObject(object, args[1], datacenterTemplate, &flags.OutputFormatConfig)
}
diff --git a/internal/services/dedicatedcloud/templates/datacenter.tmpl b/internal/services/dedicatedcloud/templates/datacenter.tmpl
new file mode 100644
index 00000000..57d2e002
--- /dev/null
+++ b/internal/services/dedicatedcloud/templates/datacenter.tmpl
@@ -0,0 +1,52 @@
+🏢 Datacenter {{.ServiceName}}
+=======
+
+## General information
+
+**ID**: {{index .Result "datacenterId"}}
+**Name**: {{index .Result "name"}}
+**Version**: {{index .Result "version"}}
+**Commercial name**: {{index .Result "commercialName"}}
+{{if index .Result "commercialRangeName"}}**Commercial range**: {{index .Result "commercialRangeName"}}
+{{end}}**Removable**: {{index .Result "isRemovable"}}
+{{if index .Result "description"}}**Description**: {{index .Result "description"}}
+{{end}}{{if index .Result "horizonViewName"}}**Horizon View**: {{index .Result "horizonViewName"}}
+{{end}}
+{{if index .Result "totalCores"}}**Total CPU (Cores)**: {{index .Result "totalCores"}}
+{{end}}{{if index .Result "totalRAM"}}**Total RAM**: {{index .Result "totalRAM"}}
+{{end}}{{if index .Result "totalVMs"}}**Total VMs**: {{index .Result "totalVMs"}}
+{{end}}{{if index .Result "totalDiskSpace"}}**Total Disk Space**: {{index .Result "totalDiskSpace"}}
+{{end}}
+
+{{if index .Result "clusters"}}
+## Clusters
+
+| ID | Name | DRS Status | DRS Mode | HA Status | EVC Mode |
+| --- | --- | --- | --- | --- | --- |
+{{ range index .Result "clusters" }}| {{index . "clusterId"}} | {{index . "name"}} | {{index . "drsStatusFormatted"}} | {{index . "drsMode"}} | {{index . "haStatusFormatted"}} | {{index . "evcMode"}} |
+{{end}}
+{{end}}
+
+{{if index .Result "hostsByCluster"}}
+## Hosts
+
+{{ range $clusterName, $hosts := (index .Result "hostsByCluster") }}
+### Cluster: {{$clusterName}}
+
+| ID | Name | Connection | State | Cores | RAM | Profile | VMs | Billing |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+{{ range $hosts }}| {{index . "hostId"}} | {{index . "name"}} | {{index . "connectionStateIndicator"}} | {{index . "maintenanceStatus"}} | {{index . "coreNumber"}} | {{if index . "ram"}}{{index . "ram" "value"}}{{if index . "ram" "unit"}} {{index . "ram" "unit"}}{{end}}{{end}} | {{index . "profile"}} | {{index . "vmCount"}} | {{index . "billingType"}} |
+{{end}}
+{{end}}
+{{end}}
+
+{{if index .Result "filers"}}
+## Filers
+
+| ID | Name | Connection | State | Size | Space Free | Cluster | VMs | Billing | Active Node | Visibility |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+{{ range index .Result "filers" }}| {{index . "filerId"}} | {{index . "name"}} | {{index . "connectionStateIndicator"}} | {{index . "state"}} | {{index . "sizeFormatted"}} | {{index . "spaceFreeFormatted"}} | {{index . "clusterName"}} | {{index . "vmCount"}} | {{index . "billingType"}} | {{index . "activeNode"}} | {{index . "visibility"}} |
+{{end}}
+{{end}}
+
+💡 Use option --json or --yaml to get the raw output with all information
diff --git a/internal/services/dedicatedcloud/templates/dedicatedcloud.tmpl b/internal/services/dedicatedcloud/templates/dedicatedcloud.tmpl
index b993aebd..04584689 100644
--- a/internal/services/dedicatedcloud/templates/dedicatedcloud.tmpl
+++ b/internal/services/dedicatedcloud/templates/dedicatedcloud.tmpl
@@ -6,7 +6,7 @@ _{{index .Result "iam" "displayName"}}_
## General information
-**Location**: {{index .Result "location"}}
+**Location**: {{if index .Result "regionLocation"}}{{index .Result "regionLocation"}}{{else}}{{index .Result "location"}}{{end}}
**State**: {{index .Result "state"}}
**Generation**: {{index .Result "generation"}}
**Version**: {{index .Result "version" "major"}}.{{index .Result "version" "minor"}}.{{index .Result "version" "build"}}
@@ -26,6 +26,15 @@ _{{index .Result "iam" "displayName"}}_
| SSLv3 | {{index .Result "sslV3"}} |
| User access policy | {{index .Result "userAccessPolicy"}} |
+{{if index .Result "datacenters"}}
+## Datacenters
+
+| ID | Name | Version | Commercial name |
+| --- | --- | --- | --- |
+{{ range index .Result "datacenters" }}| {{index . "datacenterId"}} | {{index . "name"}} | {{index . "version"}} | {{index . "commercialName"}} |
+{{end}}
+{{end}}
+
## IAM information
**URN**: {{index .Result "iam" "urn"}}