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"}}