diff --git a/Makefile b/Makefile index 11ab1d6..4f856c8 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,7 @@ generate: deps mockgen --destination ./internal/mocks/cloud_computing_regions_service.go --package=mocks --source ./vendor/github.com/serverscom/serverscom-go-client/pkg/cloud_computing_regions.go mockgen --destination ./internal/mocks/cloud_block_storage_volumes_service.go --package=mocks --source ./vendor/github.com/serverscom/serverscom-go-client/pkg/cloud_block_storage_volumes.go mockgen --destination ./internal/mocks/cloud_block_storage_backups_service.go --package=mocks --source ./vendor/github.com/serverscom/serverscom-go-client/pkg/cloud_block_storage_backups.go + mockgen --destination ./internal/mocks/metrics_service.go --package=mocks --source ./vendor/github.com/serverscom/serverscom-go-client/pkg/metrics.go sed -i '' 's|github.com/serverscom/srvctl/vendor/github.com/serverscom/serverscom-go-client/pkg|github.com/serverscom/serverscom-go-client/pkg|g' \ ./internal/mocks/ssh_service.go \ ./internal/mocks/hosts_service.go \ @@ -43,7 +44,8 @@ generate: deps ./internal/mocks/cloud_instances_service.go \ ./internal/mocks/cloud_computing_regions_service.go \ ./internal/mocks/cloud_block_storage_volumes_service.go \ - ./internal/mocks/cloud_block_storage_backups_service.go + ./internal/mocks/cloud_block_storage_backups_service.go \ + ./internal/mocks/metrics_service.go docs: go run cmd/gendoc/main.go diff --git a/cmd/base/hooks.go b/cmd/base/hooks.go index 13c7a9f..1f38c40 100644 --- a/cmd/base/hooks.go +++ b/cmd/base/hooks.go @@ -5,6 +5,7 @@ import ( "fmt" "html/template" "os" + "slices" "strings" "github.com/serverscom/srvctl/internal/client" @@ -63,8 +64,17 @@ func InitCmdContext(cmdContext *CmdContext) func(cmd *cobra.Command, args []stri } } +// defaultPassThroughOutputs are output formats printed as is by most commands +var defaultPassThroughOutputs = []string{"json", "yaml"} + // CheckFormatterFlags checks flags related to formatter func CheckFormatterFlags(cmdContext *CmdContext, entities map[string]entities.EntityInterface) func(cmd *cobra.Command, args []string) error { + return CheckFormatterFlagsWithOutputs(cmdContext, entities, defaultPassThroughOutputs) +} + +// CheckFormatterFlagsWithOutputs checks flags related to formatter, allowing the +// "text" output plus the given pass-through outputs, that need no further checks +func CheckFormatterFlagsWithOutputs(cmdContext *CmdContext, entities map[string]entities.EntityInterface, passThroughOutputs []string) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { if entities == nil { return fmt.Errorf("entities is not initialized") @@ -87,12 +97,13 @@ func CheckFormatterFlags(cmdContext *CmdContext, entities map[string]entities.En } output := formatter.GetOutput() - switch output { - case "json", "yaml": - return nil - case "text": - default: - return fmt.Errorf("invalid output %q, allowed values: json, text, yaml", output) + if output != "text" { + if slices.Contains(passThroughOutputs, output) { + return nil + } + allowed := append(slices.Clone(passThroughOutputs), "text") + slices.Sort(allowed) + return fmt.Errorf("invalid output %q, allowed values: %s", output, strings.Join(allowed, ", ")) } tmpl := formatter.GetTemplateStr() diff --git a/cmd/entities/metrics/hosts.go b/cmd/entities/metrics/hosts.go new file mode 100644 index 0000000..f28bae0 --- /dev/null +++ b/cmd/entities/metrics/hosts.go @@ -0,0 +1,69 @@ +package metrics + +import ( + "log" + + "github.com/serverscom/srvctl/cmd/base" + "github.com/serverscom/srvctl/internal/metrics" + "github.com/serverscom/srvctl/internal/output/entities" + "github.com/spf13/cobra" +) + +func newHostsCmd(cmdContext *base.CmdContext) *cobra.Command { + hostMetricEntity, err := entities.Registry.GetEntityFromValue(metrics.HostMetric{}) + if err != nil { + log.Fatal(err) + } + entitiesMap := make(map[string]entities.EntityInterface) + entitiesMap["hosts"] = hostMetricEntity + + cmd := &cobra.Command{ + Use: "hosts", + Short: "Get hosts metrics", + Long: "Get hosts metrics: monthly traffic per host.\n\n" + + "Use --output raw to get metrics in the Prometheus text exposition format as returned by the API.", + PersistentPreRunE: base.CombinePreRunE( + base.CheckFormatterFlagsWithOutputs(cmdContext, entitiesMap, []string{rawOutput}), + checkPaginationFlags(cmdContext), + ), + Args: base.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + manager := cmdContext.GetManager() + + ctx, cancel := base.SetupContext(cmd, manager) + defer cancel() + + base.SetupProxy(cmd, manager) + + scClient := cmdContext.GetClient().SetVerbose(manager.GetVerbose(cmd)).GetScClient() + + raw, err := scClient.Metrics.ListHostsMetrics(ctx) + if err != nil { + return err + } + + formatter := cmdContext.GetOrCreateFormatter(cmd) + if formatter.GetOutput() == rawOutput { + return printRaw(cmd, raw) + } + + samples, err := metrics.Parse(raw) + if err != nil { + return err + } + + rows := metrics.BuildHostRows(samples) + + rows, err = paginate(cmd, rows) + if err != nil { + return err + } + + return formatter.Format(rows) + }, + } + + addFlags(cmd) + + return cmd +} diff --git a/cmd/entities/metrics/metrics.go b/cmd/entities/metrics/metrics.go new file mode 100644 index 0000000..9a666c3 --- /dev/null +++ b/cmd/entities/metrics/metrics.go @@ -0,0 +1,27 @@ +package metrics + +import ( + "github.com/serverscom/srvctl/cmd/base" + "github.com/spf13/cobra" +) + +func NewCmd(cmdContext *base.CmdContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "metrics", + Short: "Get hosts and racks metrics", + Long: "Get hosts and racks metrics.\n\n" + + "With the default text output metrics are folded into a table with one row per host or rack.\n" + + "Use --output raw to get metrics in the Prometheus text exposition format as returned by the API,\n" + + "e.g. to feed a Prometheus textfile collector.", + PersistentPreRunE: base.CheckEmptyContexts(cmdContext), + Args: base.NoArgs, + Run: base.UsageRun, + } + + cmd.AddCommand( + newHostsCmd(cmdContext), + newRacksCmd(cmdContext), + ) + + return cmd +} diff --git a/cmd/entities/metrics/metrics_test.go b/cmd/entities/metrics/metrics_test.go new file mode 100644 index 0000000..e22764e --- /dev/null +++ b/cmd/entities/metrics/metrics_test.go @@ -0,0 +1,261 @@ +package metrics + +import ( + "bytes" + "errors" + "path/filepath" + "testing" + + . "github.com/onsi/gomega" + serverscom "github.com/serverscom/serverscom-go-client/pkg" + "github.com/serverscom/srvctl/cmd/testutils" + "github.com/serverscom/srvctl/internal/mocks" + "go.uber.org/mock/gomock" +) + +var fixtureBasePath = filepath.Join("..", "..", "..", "testdata", "entities", "metrics") + +func readFixture(name string) string { + return string(testutils.ReadFixture(filepath.Join(fixtureBasePath, name))) +} + +type metricsTestCase struct { + name string + args []string + metrics string + expectedOutput string + expectedErrOut string + // noAPICall is set for cases failing before the API is called + noAPICall bool + // apiError makes the API call fail + apiError bool + expectError bool +} + +func TestHostsCmd(t *testing.T) { + hostsMetrics := readFixture("hosts_input.txt") + + testCases := []metricsTestCase{ + { + name: "get hosts metrics in default format", + metrics: hostsMetrics, + expectedOutput: readFixture("hosts.txt"), + }, + { + name: "get hosts metrics in page view", + args: []string{"--page-view"}, + metrics: hostsMetrics, + expectedOutput: readFixture("hosts_page_view.txt"), + }, + { + name: "get hosts metrics without header", + args: []string{"--no-header"}, + metrics: hostsMetrics, + expectedOutput: readFixture("hosts_no_header.txt"), + }, + { + name: "get hosts metrics with fields", + args: []string{"-f", "HostID", "-f", "ChassisName", "-f", "TotalSent"}, + metrics: hostsMetrics, + expectedOutput: readFixture("hosts_field.txt"), + }, + { + name: "get hosts metrics with template", + args: []string{"-t", `{{range .}}{{.HostID}} {{.TotalSent}}\n{{end}}`}, + metrics: hostsMetrics, + expectedOutput: readFixture("hosts_template.txt"), + }, + { + name: "get hosts metrics with pagination", + args: []string{"--per-page", "1", "--page", "2"}, + metrics: hostsMetrics, + expectedOutput: readFixture("hosts_page.txt"), + }, + { + name: "get all hosts metrics", + args: []string{"--per-page", "1", "-A"}, + metrics: hostsMetrics, + expectedOutput: readFixture("hosts.txt"), + }, + { + name: "get empty hosts metrics", + metrics: "", + expectedOutput: readFixture("hosts_empty.txt"), + }, + { + name: "get hosts metrics in raw format", + args: []string{"--output", "raw"}, + metrics: hostsMetrics, + expectedOutput: hostsMetrics, + }, + { + name: "get hosts metrics in unsupported format", + args: []string{"--output", "json"}, + noAPICall: true, + expectError: true, + }, + { + name: "get hosts metrics in raw format with pagination", + args: []string{"--output", "raw", "--page", "2"}, + noAPICall: true, + expectError: true, + }, + { + name: "get all hosts metrics in raw format", + args: []string{"--output", "raw", "-A"}, + noAPICall: true, + expectError: true, + }, + { + name: "get hosts metrics with error", + apiError: true, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + metricsServiceHandler := mocks.NewMockMetricsService(mockCtrl) + scClient := serverscom.NewClientWithEndpoint("", "") + scClient.Metrics = metricsServiceHandler + + if !tc.noAPICall { + var apiErr error + if tc.apiError { + apiErr = errors.New("some error") + } + metricsServiceHandler.EXPECT(). + ListHostsMetrics(gomock.Any()). + Return(tc.metrics, apiErr) + } + + testCmdContext := testutils.NewTestCmdContext(scClient) + metricsCmd := NewCmd(testCmdContext) + + builder := testutils.NewTestCommandBuilder(). + WithCommand(metricsCmd). + WithArgs(append([]string{"metrics", "hosts"}, tc.args...)) + + cmd := builder.Build() + var errOut bytes.Buffer + cmd.SetErr(&errOut) + + err := cmd.Execute() + + if tc.expectError { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).To(BeNil()) + g.Expect(builder.GetOutput()).To(BeEquivalentTo(tc.expectedOutput)) + g.Expect(errOut.String()).To(BeEquivalentTo(tc.expectedErrOut)) + }) + } +} + +func TestRacksCmd(t *testing.T) { + racksMetrics := readFixture("racks_input.txt") + + testCases := []metricsTestCase{ + { + name: "get racks metrics in default format", + metrics: racksMetrics, + expectedOutput: readFixture("racks.txt"), + }, + { + name: "get racks metrics in page view", + args: []string{"--page-view"}, + metrics: racksMetrics, + expectedOutput: readFixture("racks_page_view.txt"), + }, + { + name: "get racks metrics with fields", + args: []string{"-f", "RackID", "-f", "AtsWatts", "-f", "AtsAmperes", "-f", "AtsCount"}, + metrics: racksMetrics, + expectedOutput: readFixture("racks_field.txt"), + }, + { + name: "get racks metrics with pagination", + args: []string{"--per-page", "1"}, + metrics: racksMetrics, + expectedOutput: readFixture("racks_page.txt"), + }, + { + name: "get all racks metrics", + args: []string{"--per-page", "1", "-A"}, + metrics: racksMetrics, + expectedOutput: readFixture("racks.txt"), + }, + { + name: "get empty racks metrics", + metrics: "", + expectedOutput: readFixture("racks_empty.txt"), + }, + { + name: "get racks metrics in raw format", + args: []string{"--output", "raw"}, + metrics: racksMetrics, + expectedOutput: racksMetrics, + }, + { + name: "get racks metrics in unsupported format", + args: []string{"--output", "yaml"}, + noAPICall: true, + expectError: true, + }, + { + name: "get racks metrics with error", + apiError: true, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + metricsServiceHandler := mocks.NewMockMetricsService(mockCtrl) + scClient := serverscom.NewClientWithEndpoint("", "") + scClient.Metrics = metricsServiceHandler + + if !tc.noAPICall { + var apiErr error + if tc.apiError { + apiErr = errors.New("some error") + } + metricsServiceHandler.EXPECT(). + ListRacksMetrics(gomock.Any()). + Return(tc.metrics, apiErr) + } + + testCmdContext := testutils.NewTestCmdContext(scClient) + metricsCmd := NewCmd(testCmdContext) + + builder := testutils.NewTestCommandBuilder(). + WithCommand(metricsCmd). + WithArgs(append([]string{"metrics", "racks"}, tc.args...)) + + cmd := builder.Build() + var errOut bytes.Buffer + cmd.SetErr(&errOut) + + err := cmd.Execute() + + if tc.expectError { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).To(BeNil()) + g.Expect(builder.GetOutput()).To(BeEquivalentTo(tc.expectedOutput)) + g.Expect(errOut.String()).To(BeEquivalentTo(tc.expectedErrOut)) + }) + } +} diff --git a/cmd/entities/metrics/racks.go b/cmd/entities/metrics/racks.go new file mode 100644 index 0000000..4950e26 --- /dev/null +++ b/cmd/entities/metrics/racks.go @@ -0,0 +1,69 @@ +package metrics + +import ( + "log" + + "github.com/serverscom/srvctl/cmd/base" + "github.com/serverscom/srvctl/internal/metrics" + "github.com/serverscom/srvctl/internal/output/entities" + "github.com/spf13/cobra" +) + +func newRacksCmd(cmdContext *base.CmdContext) *cobra.Command { + rackMetricEntity, err := entities.Registry.GetEntityFromValue(metrics.RackMetric{}) + if err != nil { + log.Fatal(err) + } + entitiesMap := make(map[string]entities.EntityInterface) + entitiesMap["racks"] = rackMetricEntity + + cmd := &cobra.Command{ + Use: "racks", + Short: "Get private racks metrics", + Long: "Get private racks metrics: hosts count, monthly traffic and PDU/ATS power draw per rack.\n\n" + + "Use --output raw to get metrics in the Prometheus text exposition format as returned by the API.", + PersistentPreRunE: base.CombinePreRunE( + base.CheckFormatterFlagsWithOutputs(cmdContext, entitiesMap, []string{rawOutput}), + checkPaginationFlags(cmdContext), + ), + Args: base.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + manager := cmdContext.GetManager() + + ctx, cancel := base.SetupContext(cmd, manager) + defer cancel() + + base.SetupProxy(cmd, manager) + + scClient := cmdContext.GetClient().SetVerbose(manager.GetVerbose(cmd)).GetScClient() + + raw, err := scClient.Metrics.ListRacksMetrics(ctx) + if err != nil { + return err + } + + formatter := cmdContext.GetOrCreateFormatter(cmd) + if formatter.GetOutput() == rawOutput { + return printRaw(cmd, raw) + } + + samples, err := metrics.Parse(raw) + if err != nil { + return err + } + + rows := metrics.BuildRackRows(samples) + + rows, err = paginate(cmd, rows) + if err != nil { + return err + } + + return formatter.Format(rows) + }, + } + + addFlags(cmd) + + return cmd +} diff --git a/cmd/entities/metrics/utils.go b/cmd/entities/metrics/utils.go new file mode 100644 index 0000000..adfaaa7 --- /dev/null +++ b/cmd/entities/metrics/utils.go @@ -0,0 +1,88 @@ +package metrics + +import ( + "fmt" + + "github.com/serverscom/srvctl/cmd/base" + "github.com/spf13/cobra" +) + +const ( + // rawOutput prints metrics as returned by the API + rawOutput = "raw" + + // defaultPerPage limits the number of rows printed by default. All the metrics + // come in a single response, so unlike the list commands there is no page size + // coming from the API. + defaultPerPage = 20 +) + +// addFlags adds flags supported by metrics commands +func addFlags(cmd *cobra.Command) { + base.AddFormatFlags(cmd) + + // shadows the global output flag, as metrics support their own set of formats + cmd.PersistentFlags().StringP("output", "o", "text", "output format (text/raw)") + + flags := cmd.Flags() + flags.Int("per-page", defaultPerPage, "Number of items per page") + flags.Int("page", 0, "Page number") + flags.BoolP("all", "A", false, "Get all pages of resources") +} + +// checkPaginationFlags rejects pagination flags with the raw output, as in that +// mode metrics are printed exactly as returned by the API +func checkPaginationFlags(cmdContext *base.CmdContext) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + if cmdContext.GetOrCreateFormatter(cmd).GetOutput() != rawOutput { + return nil + } + for _, flag := range []string{"all", "page", "per-page"} { + if cmd.Flags().Changed(flag) { + return fmt.Errorf("--%s can't be used with the raw output", flag) + } + } + return nil + } +} + +// printRaw prints metrics as returned by the API +func printRaw(cmd *cobra.Command, raw string) error { + _, err := fmt.Fprint(cmd.OutOrStdout(), raw) + return err +} + +// paginate returns a page of rows according to the page and per-page flags. +// The whole set of metrics comes in a single response, so rows are paginated locally. +func paginate[T any](cmd *cobra.Command, rows []T) ([]T, error) { + all, err := cmd.Flags().GetBool("all") + if err != nil { + return nil, err + } + if all { + return rows, nil + } + + perPage, err := cmd.Flags().GetInt("per-page") + if err != nil { + return nil, err + } + if perPage <= 0 { + return rows, nil + } + + page, err := cmd.Flags().GetInt("page") + if err != nil { + return nil, err + } + if page <= 0 { + page = 1 + } + + start := (page - 1) * perPage + if start >= len(rows) { + return rows[:0], nil + } + + return rows[start:min(start+perPage, len(rows))], nil +} diff --git a/cmd/root.go b/cmd/root.go index f353441..7ad2013 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ import ( loadbalancerclusters "github.com/serverscom/srvctl/cmd/entities/load_balancer_clusters" loadbalancers "github.com/serverscom/srvctl/cmd/entities/load_balancers" "github.com/serverscom/srvctl/cmd/entities/locations" + "github.com/serverscom/srvctl/cmd/entities/metrics" networkpools "github.com/serverscom/srvctl/cmd/entities/network-pools" "github.com/serverscom/srvctl/cmd/entities/racks" rbsvolumes "github.com/serverscom/srvctl/cmd/entities/rbs_volumes" @@ -108,6 +109,7 @@ func NewRootCmd(version string) *cobra.Command { cloudvolumes.NewCmd(cmdContext), cloudbackups.NewCmd(cmdContext), rbsvolumes.NewCmd(cmdContext), + metrics.NewCmd(cmdContext), ) cmd.SetHelpCommandGroupID(groupOther) diff --git a/docs/index.md b/docs/index.md index 4a3135a..798fedd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -153,3 +153,6 @@ | [srvctl rbs get-credentials](srvctl-rbs-get-credentials/description.md) | Remote Block Storage | This command provides iSCSI credentials for the selected remote block storage volume. | | [srvctl rbs reset-credentials](srvctl-rbs-reset-credentials/description.md) | Remote Block Storage | This command resets iSCSI credentials for the selected remote block storage volume. | +| [srvctl metrics](srvctl-metrics/description.md) | Metrics | This command allows to get metrics for hosts and private racks. | +| [srvctl metrics hosts](srvctl-metrics-hosts/description.md) | Metrics | This command provides metrics of all hosts of the account. | +| [srvctl metrics racks](srvctl-metrics-racks/description.md) | Metrics | This command provides metrics of all private racks of the account. | diff --git a/docs/srvctl-metrics-hosts/description.md b/docs/srvctl-metrics-hosts/description.md new file mode 100644 index 0000000..8de9ba9 --- /dev/null +++ b/docs/srvctl-metrics-hosts/description.md @@ -0,0 +1,7 @@ +Get metrics of all hosts of the account. + +In the default text format each row represents a host with its monthly traffic split by public and private traffic. Only hosts that have traffic data are listed, as the API labels a host with its id only when it reports traffic counters for it. Use `--field-list` to see all available fields, `--field` to pick the ones you need and `--page-view` to print a field per line. + +All the metrics come in a single API response, so `--per-page`, `--page` and `--all` are applied locally. 20 rows are printed per page by default, use `--all` to print all of them. + +With `--output raw` the metrics are printed in the Prometheus text exposition format as returned by the API, including the hosts count metric that has no dedicated column in the table. diff --git a/docs/srvctl-metrics-hosts/examples.md b/docs/srvctl-metrics-hosts/examples.md new file mode 100644 index 0000000..5d66317 --- /dev/null +++ b/docs/srvctl-metrics-hosts/examples.md @@ -0,0 +1,29 @@ +A command to get hosts metrics as a table: + +``` +srvctl metrics hosts +``` + +A command to get hosts metrics with specific fields: + +``` +srvctl metrics hosts --field HostID --field ChassisName --field TotalSent +``` + +A command to get the second page of hosts metrics: + +``` +srvctl metrics hosts --page 2 +``` + +A command to get metrics of all the hosts at once: + +``` +srvctl metrics hosts -A +``` + +A command to get hosts metrics in the Prometheus text exposition format: + +``` +srvctl metrics hosts --output raw +``` diff --git a/docs/srvctl-metrics-racks/description.md b/docs/srvctl-metrics-racks/description.md new file mode 100644 index 0000000..38698a9 --- /dev/null +++ b/docs/srvctl-metrics-racks/description.md @@ -0,0 +1,7 @@ +Get metrics of all private racks of the account. + +In the default text format each row represents a rack with the number of hosts in it, its monthly traffic and the power draw of its PDU and ATS devices. PDU and ATS values are summed per rack and kept in separate columns, as an ATS feeds the PDUs and summing them would count the same draw twice. Use `--field-list` to see all available fields, `--field` to pick the ones you need and `--page-view` to print a field per line. + +All the metrics come in a single API response, so `--per-page`, `--page` and `--all` are applied locally. 20 rows are printed per page by default, use `--all` to print all of them. + +With `--output raw` the metrics are printed in the Prometheus text exposition format as returned by the API, with power and current reported per device. diff --git a/docs/srvctl-metrics-racks/examples.md b/docs/srvctl-metrics-racks/examples.md new file mode 100644 index 0000000..e9d1a0a --- /dev/null +++ b/docs/srvctl-metrics-racks/examples.md @@ -0,0 +1,17 @@ +A command to get private racks metrics as a table: + +``` +srvctl metrics racks +``` + +A command to get power metrics of private racks: + +``` +srvctl metrics racks --field RackID --field PduWatts --field PduAmperes --field AtsWatts --field AtsAmperes +``` + +A command to get private racks metrics in the Prometheus text exposition format: + +``` +srvctl metrics racks --output raw +``` diff --git a/docs/srvctl-metrics/description.md b/docs/srvctl-metrics/description.md new file mode 100644 index 0000000..aa60252 --- /dev/null +++ b/docs/srvctl-metrics/description.md @@ -0,0 +1,6 @@ +You can get metrics for your hosts and private racks by performing commands listed in `srvctl metrics --help`. + +Metrics support two output formats: + +- `--output text` (default) folds the metrics into a table with one row per host or rack, with traffic humanized. +- `--output raw` prints the metrics in the Prometheus text exposition format exactly as returned by the API, which is handy to feed a Prometheus textfile collector. diff --git a/docs/srvctl-metrics/examples.md b/docs/srvctl-metrics/examples.md new file mode 100644 index 0000000..96c3b7c --- /dev/null +++ b/docs/srvctl-metrics/examples.md @@ -0,0 +1,17 @@ +A command to get metrics of all hosts: + +``` +srvctl metrics hosts +``` + +A command to get metrics of all private racks: + +``` +srvctl metrics racks +``` + +A command to collect hosts metrics for a Prometheus textfile collector: + +``` +srvctl metrics hosts --output raw > /var/lib/node_exporter/textfile_collector/serverscom_hosts.prom +``` diff --git a/internal/metrics/hosts.go b/internal/metrics/hosts.go new file mode 100644 index 0000000..f45ea60 --- /dev/null +++ b/internal/metrics/hosts.go @@ -0,0 +1,102 @@ +package metrics + +import ( + "cmp" + "slices" + "strings" +) + +const ( + hostSentMetric = "serverscom_host_monthly_sent_bytes_total" + hostReceivedMetric = "serverscom_host_monthly_received_bytes_total" +) + +// HostMetric represents metrics of a single host. +type HostMetric struct { + HostID string + Title string + HostType string + ChassisName string + LocationID string + LocationCode string + RackID string + RackType string + PublicSent int64 + PublicReceived int64 + PrivateSent int64 + PrivateReceived int64 + TotalSent int64 + TotalReceived int64 +} + +// BuildHostRows folds hosts metrics samples into one row per host. +// Only hosts with traffic data are labeled with a host id, so hosts without +// any traffic counter can't be represented as a row. +func BuildHostRows(samples []Sample) []HostMetric { + rows := make(map[string]*HostMetric) + + for _, sample := range samples { + if sample.Name != hostSentMetric && sample.Name != hostReceivedMetric { + continue + } + + id := sample.Labels["host_id"] + if id == "" { + continue + } + + row, ok := rows[id] + if !ok { + row = &HostMetric{ + HostID: id, + Title: sample.Labels["title"], + HostType: sample.Labels["host_type"], + ChassisName: sample.Labels["chassis_name"], + LocationID: sample.Labels["location_id"], + LocationCode: sample.Labels["location_code"], + RackID: sample.Labels["rack_id"], + RackType: sample.Labels["rack_type"], + } + rows[id] = row + } + + value := int64(sample.Value) + sent := sample.Name == hostSentMetric + + switch sample.Labels["traffic_type"] { + case "public": + if sent { + row.PublicSent += value + } else { + row.PublicReceived += value + } + case "private": + if sent { + row.PrivateSent += value + } else { + row.PrivateReceived += value + } + } + + // totals cover all traffic types, including the ones without a column + if sent { + row.TotalSent += value + } else { + row.TotalReceived += value + } + } + + result := make([]HostMetric, 0, len(rows)) + for _, row := range rows { + result = append(result, *row) + } + slices.SortFunc(result, func(a, b HostMetric) int { + return cmp.Or( + strings.Compare(a.LocationCode, b.LocationCode), + strings.Compare(a.Title, b.Title), + strings.Compare(a.HostID, b.HostID), + ) + }) + + return result +} diff --git a/internal/metrics/hosts_test.go b/internal/metrics/hosts_test.go new file mode 100644 index 0000000..4b3a7aa --- /dev/null +++ b/internal/metrics/hosts_test.go @@ -0,0 +1,94 @@ +package metrics + +import ( + "os" + "path/filepath" + "testing" + + . "github.com/onsi/gomega" +) + +var fixtureBasePath = filepath.Join("..", "..", "testdata", "entities", "metrics") + +func readFixture(t *testing.T, name string) string { + t.Helper() + + data, err := os.ReadFile(filepath.Join(fixtureBasePath, name)) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestBuildHostRows(t *testing.T) { + g := NewWithT(t) + + samples, err := Parse(readFixture(t, "hosts_input.txt")) + g.Expect(err).To(BeNil()) + + // the count metric reports 3 hosts, only the 2 with traffic data can be rows + rows := BuildHostRows(samples) + + g.Expect(rows).To(Equal([]HostMetric{ + { + HostID: "5VmrzVmx", + Title: "lon1-web-01", + HostType: "dedicated_server", + ChassisName: `Dell R330 - E3-1230 v6 - 3.5"`, + LocationID: "23", + LocationCode: "LON1", + RackID: "5VmrzVmx", + RackType: "shared", + PublicSent: 1319413953331, + PublicReceived: 3775348762345, + TotalSent: 1319413953331, + TotalReceived: 3775348762345, + }, + { + HostID: "jpAAGYJp", + Title: "lux3test3-reordered", + HostType: "dedicated_server", + ChassisName: `Dell R440 - Silver 4114 - 2.5"`, + LocationID: "52", + LocationCode: "LUX3", + RackID: "0pEOrzdl", + RackType: "shared", + PublicSent: 146447194, + PublicReceived: 540736516, + PrivateSent: 145497332, + PrivateReceived: 619636079, + TotalSent: 291944526, + TotalReceived: 1160372595, + }, + })) +} + +func TestBuildHostRowsUnknownTrafficType(t *testing.T) { + g := NewWithT(t) + + // a traffic type without its own column still has to be counted in the totals + samples, err := Parse( + `serverscom_host_monthly_sent_bytes_total{host_id="a",traffic_type="public"} 100` + "\n" + + `serverscom_host_monthly_sent_bytes_total{host_id="a",traffic_type="unknown"} 20` + "\n" + + `serverscom_host_monthly_received_bytes_total{host_id="a",traffic_type="unknown"} 5` + "\n", + ) + g.Expect(err).To(BeNil()) + + rows := BuildHostRows(samples) + + g.Expect(rows).To(HaveLen(1)) + g.Expect(rows[0].PublicSent).To(BeEquivalentTo(100)) + g.Expect(rows[0].TotalSent).To(BeEquivalentTo(120)) + g.Expect(rows[0].TotalReceived).To(BeEquivalentTo(5)) +} + +func TestBuildHostRowsWithoutHostID(t *testing.T) { + g := NewWithT(t) + + samples, err := Parse(`serverscom_host_monthly_sent_bytes_total{traffic_type="public"} 100`) + g.Expect(err).To(BeNil()) + + rows := BuildHostRows(samples) + + g.Expect(rows).To(BeEmpty()) +} diff --git a/internal/metrics/parse.go b/internal/metrics/parse.go new file mode 100644 index 0000000..c76129a --- /dev/null +++ b/internal/metrics/parse.go @@ -0,0 +1,183 @@ +// Package metrics parses the Prometheus text exposition format returned by the +// metrics endpoints and folds it into rows suitable for tabular output. +package metrics + +import ( + "fmt" + "strconv" + "strings" +) + +// Sample represents a single metric sample. +type Sample struct { + Name string + Type string + Help string + Labels map[string]string + Value float64 +} + +// Parse parses metrics in the Prometheus text exposition format. +func Parse(s string) ([]Sample, error) { + var samples []Sample + help := make(map[string]string) + types := make(map[string]string) + + for i, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "#") { + parseComment(line, help, types) + continue + } + + sample, err := parseSample(line) + if err != nil { + return nil, fmt.Errorf("line %d: %w", i+1, err) + } + samples = append(samples, sample) + } + + for i := range samples { + samples[i].Help = help[samples[i].Name] + samples[i].Type = types[samples[i].Name] + } + + return samples, nil +} + +// parseComment fills help and type metadata from a comment line. +// The API emits "# HELP: ", the standard format is "# HELP ", +// both are accepted. Any other comment is ignored. +func parseComment(line string, help, types map[string]string) { + rest := strings.TrimSpace(strings.TrimPrefix(line, "#")) + + var target map[string]string + switch { + case strings.HasPrefix(rest, "HELP"): + rest, target = rest[len("HELP"):], help + case strings.HasPrefix(rest, "TYPE"): + rest, target = rest[len("TYPE"):], types + default: + return + } + if rest == "" || (rest[0] != ':' && rest[0] != ' ' && rest[0] != '\t') { + return + } + + rest = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(rest), ":")) + name, value, _ := strings.Cut(rest, " ") + if name == "" { + return + } + target[name] = strings.TrimSpace(value) +} + +// parseSample parses a single sample line, e.g. name{label="value"} 42. +func parseSample(line string) (Sample, error) { + i := strings.IndexAny(line, "{ \t") + if i <= 0 { + return Sample{}, fmt.Errorf("malformed sample %q", line) + } + + sample := Sample{Name: line[:i]} + rest := line[i:] + + if strings.HasPrefix(rest, "{") { + labels, remainder, err := parseLabels(rest) + if err != nil { + return Sample{}, err + } + sample.Labels = labels + rest = remainder + } + + // a sample value can be followed by an optional timestamp + fields := strings.Fields(rest) + if len(fields) == 0 || len(fields) > 2 { + return Sample{}, fmt.Errorf("malformed sample %q: expected a value, got %d fields", line, len(fields)) + } + + value, err := strconv.ParseFloat(fields[0], 64) + if err != nil { + return Sample{}, fmt.Errorf("malformed sample %q: %w", line, err) + } + sample.Value = value + + return sample, nil +} + +// parseLabels parses a label section and returns the labels along with +// everything that follows the closing brace. +func parseLabels(s string) (map[string]string, string, error) { + labels := make(map[string]string) + + i := 1 // skip the opening brace + for { + for i < len(s) && (s[i] == ',' || s[i] == ' ' || s[i] == '\t') { + i++ + } + if i >= len(s) { + return nil, "", fmt.Errorf("unterminated label section in %q", s) + } + if s[i] == '}' { + return labels, s[i+1:], nil + } + + start := i + for i < len(s) && s[i] != '=' && s[i] != '}' { + i++ + } + name := strings.TrimSpace(s[start:i]) + if i >= len(s) || s[i] != '=' || name == "" { + return nil, "", fmt.Errorf("malformed label name in %q", s) + } + i++ + + if i >= len(s) || s[i] != '"' { + return nil, "", fmt.Errorf("expected a quoted value for label %q in %q", name, s) + } + i++ + + value, next, err := parseLabelValue(s, i) + if err != nil { + return nil, "", err + } + labels[name] = value + i = next + } +} + +// parseLabelValue reads a quoted label value starting at i and returns it +// unescaped along with the position right after the closing quote. +func parseLabelValue(s string, i int) (string, int, error) { + var value strings.Builder + + for i < len(s) { + switch s[i] { + case '"': + return value.String(), i + 1, nil + case '\\': + i++ + if i >= len(s) { + break + } + switch s[i] { + case 'n': + value.WriteByte('\n') + case 't': + value.WriteByte('\t') + default: + value.WriteByte(s[i]) + } + i++ + default: + value.WriteByte(s[i]) + i++ + } + } + + return "", 0, fmt.Errorf("unterminated label value in %q", s) +} diff --git a/internal/metrics/parse_test.go b/internal/metrics/parse_test.go new file mode 100644 index 0000000..fab51b1 --- /dev/null +++ b/internal/metrics/parse_test.go @@ -0,0 +1,148 @@ +package metrics + +import ( + "math" + "testing" + + . "github.com/onsi/gomega" +) + +func TestParse(t *testing.T) { + testCases := []struct { + name string + input string + expected []Sample + }{ + { + name: "metadata in the format returned by the api", + input: "# HELP: serverscom_hosts_count Count of the hosts\n" + + "# TYPE: serverscom_hosts_count gauge\n" + + `serverscom_hosts_count{location_code="LUX3"} 1` + "\n", + expected: []Sample{ + { + Name: "serverscom_hosts_count", + Type: "gauge", + Help: "Count of the hosts", + Labels: map[string]string{"location_code": "LUX3"}, + Value: 1, + }, + }, + }, + { + name: "metadata in the standard format", + input: "# HELP serverscom_hosts_count Count of the hosts\n" + + "# TYPE serverscom_hosts_count gauge\n" + + `serverscom_hosts_count{location_code="LUX3"} 1` + "\n", + expected: []Sample{ + { + Name: "serverscom_hosts_count", + Type: "gauge", + Help: "Count of the hosts", + Labels: map[string]string{"location_code": "LUX3"}, + Value: 1, + }, + }, + }, + { + name: "escaped label value", + input: `serverscom_hosts_count{chassis_name="Dell R440 - Silver 4114 - 2.5\"",rack_type="shared"} 2`, + expected: []Sample{ + { + Name: "serverscom_hosts_count", + Labels: map[string]string{ + "chassis_name": `Dell R440 - Silver 4114 - 2.5"`, + "rack_type": "shared", + }, + Value: 2, + }, + }, + }, + { + name: "sample without labels", + input: "serverscom_hosts_count 42", + expected: []Sample{ + {Name: "serverscom_hosts_count", Value: 42}, + }, + }, + { + name: "sample with empty labels", + input: "serverscom_hosts_count{} 42", + expected: []Sample{ + {Name: "serverscom_hosts_count", Labels: map[string]string{}, Value: 42}, + }, + }, + { + name: "sample with a timestamp", + input: `serverscom_hosts_count{location_code="LUX3"} 42 1700000000000`, + expected: []Sample{ + { + Name: "serverscom_hosts_count", + Labels: map[string]string{"location_code": "LUX3"}, + Value: 42, + }, + }, + }, + { + name: "float and infinite values", + input: "serverscom_rack_pdu_power_watts 620.5\nserverscom_rack_pdu_current_amperes +Inf", + expected: []Sample{ + {Name: "serverscom_rack_pdu_power_watts", Value: 620.5}, + {Name: "serverscom_rack_pdu_current_amperes", Value: math.Inf(1)}, + }, + }, + { + name: "comments and empty lines only", + input: "\n# some comment\n#\n# HELPER not a metadata line\n \n", + expected: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + samples, err := Parse(tc.input) + + g.Expect(err).To(BeNil()) + g.Expect(samples).To(Equal(tc.expected)) + }) + } +} + +func TestParseNaN(t *testing.T) { + g := NewWithT(t) + + samples, err := Parse("serverscom_rack_pdu_power_watts NaN") + + g.Expect(err).To(BeNil()) + g.Expect(samples).To(HaveLen(1)) + g.Expect(math.IsNaN(samples[0].Value)).To(BeTrue()) +} + +func TestParseErrors(t *testing.T) { + testCases := []struct { + name string + input string + }{ + {name: "no value", input: "serverscom_hosts_count"}, + {name: "not a number", input: "serverscom_hosts_count abc"}, + {name: "extra fields", input: "serverscom_hosts_count 1 2 3"}, + {name: "label without value", input: "serverscom_hosts_count{rack_type} 1"}, + {name: "label value without name", input: `serverscom_hosts_count{="shared"} 1`}, + {name: "unquoted label value", input: "serverscom_hosts_count{rack_type=shared} 1"}, + {name: "unterminated label value", input: `serverscom_hosts_count{rack_type="shared} 1`}, + {name: "unterminated label section", input: `serverscom_hosts_count{rack_type="shared"`}, + {name: "no metric name", input: `{rack_type="shared"} 1`}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + samples, err := Parse(tc.input) + + g.Expect(err).To(HaveOccurred()) + g.Expect(samples).To(BeNil()) + }) + } +} diff --git a/internal/metrics/racks.go b/internal/metrics/racks.go new file mode 100644 index 0000000..8bb81eb --- /dev/null +++ b/internal/metrics/racks.go @@ -0,0 +1,148 @@ +package metrics + +import ( + "cmp" + "slices" + "strings" +) + +const ( + rackHostsCountMetric = "serverscom_rack_hosts_count" + rackSentMetric = "serverscom_rack_monthly_sent_bytes_total" + rackReceivedMetric = "serverscom_rack_monthly_received_bytes_total" + rackPduPowerMetric = "serverscom_rack_pdu_power_watts" + rackPduCurrentMetric = "serverscom_rack_pdu_current_amperes" + rackAtsPowerMetric = "serverscom_rack_ats_power_watts" + rackAtsCurrentMetric = "serverscom_rack_ats_current_amperes" +) + +// RackMetric represents metrics of a single rack. +// Power and current are summed per device type. PDU and ATS are kept apart +// because an ATS feeds the PDUs, so summing them would count the same draw twice. +type RackMetric struct { + RackID string + Title string + LocationID string + LocationCode string + Hosts int64 + PublicSent int64 + PublicReceived int64 + PrivateSent int64 + PrivateReceived int64 + TotalSent int64 + TotalReceived int64 + PduWatts float64 + PduAmperes float64 + PduCount int + AtsWatts float64 + AtsAmperes float64 + AtsCount int +} + +// BuildRackRows folds racks metrics samples into one row per rack. +func BuildRackRows(samples []Sample) []RackMetric { + rows := make(map[string]*RackMetric) + // power and current are reported per device, count each device once + devices := make(map[string]map[string]struct{}) + + getRow := func(sample Sample) *RackMetric { + id := sample.Labels["rack_id"] + if id == "" { + return nil + } + row, ok := rows[id] + if !ok { + row = &RackMetric{ + RackID: id, + Title: sample.Labels["rack_title"], + LocationID: sample.Labels["location_id"], + LocationCode: sample.Labels["location_code"], + } + rows[id] = row + } + return row + } + + countDevice := func(id, deviceType, name string) bool { + key := deviceType + "/" + id + if devices[key] == nil { + devices[key] = make(map[string]struct{}) + } + if _, ok := devices[key][name]; ok { + return false + } + devices[key][name] = struct{}{} + return true + } + + for _, sample := range samples { + row := getRow(sample) + if row == nil { + continue + } + + switch sample.Name { + case rackHostsCountMetric: + row.Hosts += int64(sample.Value) + case rackSentMetric, rackReceivedMetric: + value := int64(sample.Value) + sent := sample.Name == rackSentMetric + + switch sample.Labels["traffic_type"] { + case "public": + if sent { + row.PublicSent += value + } else { + row.PublicReceived += value + } + case "private": + if sent { + row.PrivateSent += value + } else { + row.PrivateReceived += value + } + } + + // totals cover all traffic types, including the ones without a column + if sent { + row.TotalSent += value + } else { + row.TotalReceived += value + } + case rackPduPowerMetric: + row.PduWatts += sample.Value + if countDevice(row.RackID, "pdu", sample.Labels["pdu_name"]) { + row.PduCount++ + } + case rackPduCurrentMetric: + row.PduAmperes += sample.Value + if countDevice(row.RackID, "pdu", sample.Labels["pdu_name"]) { + row.PduCount++ + } + case rackAtsPowerMetric: + row.AtsWatts += sample.Value + if countDevice(row.RackID, "ats", sample.Labels["ats_name"]) { + row.AtsCount++ + } + case rackAtsCurrentMetric: + row.AtsAmperes += sample.Value + if countDevice(row.RackID, "ats", sample.Labels["ats_name"]) { + row.AtsCount++ + } + } + } + + result := make([]RackMetric, 0, len(rows)) + for _, row := range rows { + result = append(result, *row) + } + slices.SortFunc(result, func(a, b RackMetric) int { + return cmp.Or( + strings.Compare(a.LocationCode, b.LocationCode), + strings.Compare(a.Title, b.Title), + strings.Compare(a.RackID, b.RackID), + ) + }) + + return result +} diff --git a/internal/metrics/racks_test.go b/internal/metrics/racks_test.go new file mode 100644 index 0000000..66a7511 --- /dev/null +++ b/internal/metrics/racks_test.go @@ -0,0 +1,56 @@ +package metrics + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestBuildRackRows(t *testing.T) { + g := NewWithT(t) + + samples, err := Parse(readFixture(t, "racks_input.txt")) + g.Expect(err).To(BeNil()) + + rows := BuildRackRows(samples) + + g.Expect(rows).To(Equal([]RackMetric{ + { + RackID: "0pEOrzdl", + Title: "rack-a", + LocationID: "52", + LocationCode: "LUX3", + Hosts: 4, + PublicSent: 1319413953331, + PublicReceived: 3775348762345, + PrivateSent: 145497332, + PrivateReceived: 619636079, + TotalSent: 1319559450663, + TotalReceived: 3775968398424, + // summed over both PDUs of the rack + PduWatts: 1240, + PduAmperes: 5.6, + PduCount: 2, + AtsWatts: 1240, + AtsAmperes: 5.6, + AtsCount: 1, + }, + { + // a rack without hosts, traffic and power devices is still listed + RackID: "7xKLmnQp", + Title: "rack-b", + LocationID: "52", + LocationCode: "LUX3", + }, + })) +} + +func TestBuildRackRowsWithoutRackID(t *testing.T) { + g := NewWithT(t) + + // the racks count metric has no rack id and produces no rows + samples, err := Parse(`serverscom_racks_count{location_id="52",location_code="LUX3"} 2`) + g.Expect(err).To(BeNil()) + + g.Expect(BuildRackRows(samples)).To(BeEmpty()) +} diff --git a/internal/mocks/metrics_service.go b/internal/mocks/metrics_service.go new file mode 100644 index 0000000..9154f27 --- /dev/null +++ b/internal/mocks/metrics_service.go @@ -0,0 +1,71 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./vendor/github.com/serverscom/serverscom-go-client/pkg/metrics.go +// +// Generated by this command: +// +// mockgen --destination ./internal/mocks/metrics_service.go --package=mocks --source ./vendor/github.com/serverscom/serverscom-go-client/pkg/metrics.go +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockMetricsService is a mock of MetricsService interface. +type MockMetricsService struct { + ctrl *gomock.Controller + recorder *MockMetricsServiceMockRecorder + isgomock struct{} +} + +// MockMetricsServiceMockRecorder is the mock recorder for MockMetricsService. +type MockMetricsServiceMockRecorder struct { + mock *MockMetricsService +} + +// NewMockMetricsService creates a new mock instance. +func NewMockMetricsService(ctrl *gomock.Controller) *MockMetricsService { + mock := &MockMetricsService{ctrl: ctrl} + mock.recorder = &MockMetricsServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMetricsService) EXPECT() *MockMetricsServiceMockRecorder { + return m.recorder +} + +// ListHostsMetrics mocks base method. +func (m *MockMetricsService) ListHostsMetrics(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListHostsMetrics", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListHostsMetrics indicates an expected call of ListHostsMetrics. +func (mr *MockMetricsServiceMockRecorder) ListHostsMetrics(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListHostsMetrics", reflect.TypeOf((*MockMetricsService)(nil).ListHostsMetrics), ctx) +} + +// ListRacksMetrics mocks base method. +func (m *MockMetricsService) ListRacksMetrics(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRacksMetrics", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListRacksMetrics indicates an expected call of ListRacksMetrics. +func (mr *MockMetricsServiceMockRecorder) ListRacksMetrics(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRacksMetrics", reflect.TypeOf((*MockMetricsService)(nil).ListRacksMetrics), ctx) +} diff --git a/internal/output/entities/handlers.go b/internal/output/entities/handlers.go index 6603fc8..306ce1c 100644 --- a/internal/output/entities/handlers.go +++ b/internal/output/entities/handlers.go @@ -62,6 +62,73 @@ func timeHandler(w io.Writer, v any, indent string, _ *Field) error { } } +// bytesHandler formats a number of bytes in human readable units +func bytesHandler(w io.Writer, v any, indent string, _ *Field) error { + if indent != "" { + indent = "\t" + } + if v == nil { + _, err := fmt.Fprintf(w, "%s", indent) + return err + } + + var bytes float64 + switch v := v.(type) { + case int: + bytes = float64(v) + case int64: + bytes = float64(v) + case uint64: + bytes = float64(v) + case float64: + bytes = v + default: + return fmt.Errorf("unsupported type: %T", v) + } + + _, err := fmt.Fprintf(w, "%s%s", indent, humanBytes(bytes)) + return err +} + +// humanBytes formats a number of bytes using decimal (SI) units, matching how +// network traffic is conventionally reported (e.g. by ISPs, vnstat). +func humanBytes(bytes float64) string { + const unit = 1000 + if bytes < unit { + return fmt.Sprintf("%.0f B", bytes) + } + + units := []string{"KB", "MB", "GB", "TB", "PB", "EB"} + i := -1 + for bytes >= unit && i < len(units)-1 { + bytes /= unit + i++ + } + return fmt.Sprintf("%.1f %s", bytes, units[i]) +} + +// floatHandler formats a float value with a fixed precision to keep columns aligned +func floatHandler(w io.Writer, v any, indent string, _ *Field) error { + if indent != "" { + indent = "\t" + } + if v == nil { + _, err := fmt.Fprintf(w, "%s", indent) + return err + } + + switch v := v.(type) { + case float32: + _, err := fmt.Fprintf(w, "%s%.2f", indent, v) + return err + case float64: + _, err := fmt.Fprintf(w, "%s%.2f", indent, v) + return err + default: + return fmt.Errorf("unsupported type: %T", v) + } +} + func mapPvHandler(w io.Writer, v any, indent string, _ *Field) error { if v == nil { _, err := fmt.Fprintf(w, "%s", indent) diff --git a/internal/output/entities/init.go b/internal/output/entities/init.go index 5eef8b5..c82fa62 100644 --- a/internal/output/entities/init.go +++ b/internal/output/entities/init.go @@ -38,4 +38,6 @@ func init() { RegisterCloudBackupDefinition() RegisterRbsVolumeDefinitions() RegisterRbsVolumeCredentialsDefinition() + RegisterHostMetricDefinition() + RegisterRackMetricDefinition() } diff --git a/internal/output/entities/metrics.go b/internal/output/entities/metrics.go new file mode 100644 index 0000000..7a2f9ed --- /dev/null +++ b/internal/output/entities/metrics.go @@ -0,0 +1,70 @@ +package entities + +import ( + "log" + "reflect" + + "github.com/serverscom/srvctl/internal/metrics" +) + +var ( + HostMetricType = reflect.TypeFor[metrics.HostMetric]() + RackMetricType = reflect.TypeFor[metrics.RackMetric]() +) + +// RegisterHostMetricDefinition registers hosts metrics entity +func RegisterHostMetricDefinition() { + hostMetricEntity := &Entity{ + fields: []Field{ + {ID: "HostID", Name: "Host ID", Path: "HostID", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler, Default: true}, + {ID: "Title", Name: "Title", Path: "Title", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler, Default: true}, + {ID: "LocationCode", Name: "Location", Path: "LocationCode", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler, Default: true}, + {ID: "LocationID", Name: "Location ID", Path: "LocationID", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler}, + {ID: "HostType", Name: "Type", Path: "HostType", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler, Default: true}, + {ID: "ChassisName", Name: "Chassis", Path: "ChassisName", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler}, + {ID: "RackID", Name: "Rack ID", Path: "RackID", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler}, + {ID: "RackType", Name: "Rack Type", Path: "RackType", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler}, + {ID: "PublicSent", Name: "Public Sent", Path: "PublicSent", ListHandlerFunc: bytesHandler, PageViewHandlerFunc: bytesHandler, Default: true}, + {ID: "PublicReceived", Name: "Public Recv", Path: "PublicReceived", ListHandlerFunc: bytesHandler, PageViewHandlerFunc: bytesHandler, Default: true}, + {ID: "PrivateSent", Name: "Private Sent", Path: "PrivateSent", ListHandlerFunc: bytesHandler, PageViewHandlerFunc: bytesHandler, Default: true}, + {ID: "PrivateReceived", Name: "Private Recv", Path: "PrivateReceived", ListHandlerFunc: bytesHandler, PageViewHandlerFunc: bytesHandler, Default: true}, + {ID: "TotalSent", Name: "Total Sent", Path: "TotalSent", ListHandlerFunc: bytesHandler, PageViewHandlerFunc: bytesHandler}, + {ID: "TotalReceived", Name: "Total Recv", Path: "TotalReceived", ListHandlerFunc: bytesHandler, PageViewHandlerFunc: bytesHandler}, + }, + eType: HostMetricType, + } + + if err := Registry.Register(hostMetricEntity); err != nil { + log.Fatal(err) + } +} + +// RegisterRackMetricDefinition registers racks metrics entity +func RegisterRackMetricDefinition() { + rackMetricEntity := &Entity{ + fields: []Field{ + {ID: "RackID", Name: "Rack ID", Path: "RackID", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler, Default: true}, + {ID: "Title", Name: "Title", Path: "Title", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler, Default: true}, + {ID: "LocationCode", Name: "Location", Path: "LocationCode", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler, Default: true}, + {ID: "LocationID", Name: "Location ID", Path: "LocationID", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler}, + {ID: "Hosts", Name: "Hosts", Path: "Hosts", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler, Default: true}, + {ID: "PublicSent", Name: "Public Sent", Path: "PublicSent", ListHandlerFunc: bytesHandler, PageViewHandlerFunc: bytesHandler, Default: true}, + {ID: "PublicReceived", Name: "Public Recv", Path: "PublicReceived", ListHandlerFunc: bytesHandler, PageViewHandlerFunc: bytesHandler, Default: true}, + {ID: "PrivateSent", Name: "Private Sent", Path: "PrivateSent", ListHandlerFunc: bytesHandler, PageViewHandlerFunc: bytesHandler}, + {ID: "PrivateReceived", Name: "Private Recv", Path: "PrivateReceived", ListHandlerFunc: bytesHandler, PageViewHandlerFunc: bytesHandler}, + {ID: "TotalSent", Name: "Total Sent", Path: "TotalSent", ListHandlerFunc: bytesHandler, PageViewHandlerFunc: bytesHandler}, + {ID: "TotalReceived", Name: "Total Recv", Path: "TotalReceived", ListHandlerFunc: bytesHandler, PageViewHandlerFunc: bytesHandler}, + {ID: "PduWatts", Name: "PDU Watts", Path: "PduWatts", ListHandlerFunc: floatHandler, PageViewHandlerFunc: floatHandler, Default: true}, + {ID: "PduAmperes", Name: "PDU Amperes", Path: "PduAmperes", ListHandlerFunc: floatHandler, PageViewHandlerFunc: floatHandler, Default: true}, + {ID: "PduCount", Name: "PDUs", Path: "PduCount", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler}, + {ID: "AtsWatts", Name: "ATS Watts", Path: "AtsWatts", ListHandlerFunc: floatHandler, PageViewHandlerFunc: floatHandler}, + {ID: "AtsAmperes", Name: "ATS Amperes", Path: "AtsAmperes", ListHandlerFunc: floatHandler, PageViewHandlerFunc: floatHandler}, + {ID: "AtsCount", Name: "ATSs", Path: "AtsCount", ListHandlerFunc: stringHandler, PageViewHandlerFunc: stringHandler}, + }, + eType: RackMetricType, + } + + if err := Registry.Register(rackMetricEntity); err != nil { + log.Fatal(err) + } +} diff --git a/testdata/entities/metrics/hosts.txt b/testdata/entities/metrics/hosts.txt new file mode 100644 index 0000000..5eef9fd --- /dev/null +++ b/testdata/entities/metrics/hosts.txt @@ -0,0 +1,3 @@ +Host ID Title Location Type Public Sent Public Recv Private Sent Private Recv +5VmrzVmx lon1-web-01 LON1 dedicated_server 1.3 TB 3.8 TB 0 B 0 B +jpAAGYJp lux3test3-reordered LUX3 dedicated_server 146.4 MB 540.7 MB 145.5 MB 619.6 MB diff --git a/testdata/entities/metrics/hosts_empty.txt b/testdata/entities/metrics/hosts_empty.txt new file mode 100644 index 0000000..1b3cd57 --- /dev/null +++ b/testdata/entities/metrics/hosts_empty.txt @@ -0,0 +1 @@ +Host ID Title Location Type Public Sent Public Recv Private Sent Private Recv diff --git a/testdata/entities/metrics/hosts_field.txt b/testdata/entities/metrics/hosts_field.txt new file mode 100644 index 0000000..0b8ad8c --- /dev/null +++ b/testdata/entities/metrics/hosts_field.txt @@ -0,0 +1,3 @@ +Host ID Chassis Total Sent +5VmrzVmx Dell R330 - E3-1230 v6 - 3.5" 1.3 TB +jpAAGYJp Dell R440 - Silver 4114 - 2.5" 291.9 MB diff --git a/testdata/entities/metrics/hosts_input.txt b/testdata/entities/metrics/hosts_input.txt new file mode 100644 index 0000000..eafd10a --- /dev/null +++ b/testdata/entities/metrics/hosts_input.txt @@ -0,0 +1,16 @@ +# HELP: serverscom_hosts_count Count of the hosts +# TYPE: serverscom_hosts_count gauge +serverscom_hosts_count{chassis_name="Dell R440 - Silver 4114 - 2.5\"",host_type="dedicated_server",location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_type="shared"} 1 +serverscom_hosts_count{chassis_name="Dell R330 - E3-1230 v6 - 3.5\"",host_type="dedicated_server",location_id="23",location_code="LON1",rack_id="5VmrzVmx",rack_type="shared"} 2 + +# HELP: serverscom_host_monthly_sent_bytes_total Host monthly sent bytes total +# TYPE: serverscom_host_monthly_sent_bytes_total counter +serverscom_host_monthly_sent_bytes_total{host_id="jpAAGYJp",title="lux3test3-reordered",traffic_type="private",chassis_name="Dell R440 - Silver 4114 - 2.5\"",host_type="dedicated_server",location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_type="shared"} 145497332 +serverscom_host_monthly_sent_bytes_total{host_id="jpAAGYJp",title="lux3test3-reordered",traffic_type="public",chassis_name="Dell R440 - Silver 4114 - 2.5\"",host_type="dedicated_server",location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_type="shared"} 146447194 +serverscom_host_monthly_sent_bytes_total{host_id="5VmrzVmx",title="lon1-web-01",traffic_type="public",chassis_name="Dell R330 - E3-1230 v6 - 3.5\"",host_type="dedicated_server",location_id="23",location_code="LON1",rack_id="5VmrzVmx",rack_type="shared"} 1319413953331 + +# HELP: serverscom_host_monthly_received_bytes_total Host monthly received bytes total +# TYPE: serverscom_host_monthly_received_bytes_total counter +serverscom_host_monthly_received_bytes_total{host_id="jpAAGYJp",title="lux3test3-reordered",traffic_type="private",chassis_name="Dell R440 - Silver 4114 - 2.5\"",host_type="dedicated_server",location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_type="shared"} 619636079 +serverscom_host_monthly_received_bytes_total{host_id="jpAAGYJp",title="lux3test3-reordered",traffic_type="public",chassis_name="Dell R440 - Silver 4114 - 2.5\"",host_type="dedicated_server",location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_type="shared"} 540736516 +serverscom_host_monthly_received_bytes_total{host_id="5VmrzVmx",title="lon1-web-01",traffic_type="public",chassis_name="Dell R330 - E3-1230 v6 - 3.5\"",host_type="dedicated_server",location_id="23",location_code="LON1",rack_id="5VmrzVmx",rack_type="shared"} 3775348762345 diff --git a/testdata/entities/metrics/hosts_no_header.txt b/testdata/entities/metrics/hosts_no_header.txt new file mode 100644 index 0000000..6aa2d65 --- /dev/null +++ b/testdata/entities/metrics/hosts_no_header.txt @@ -0,0 +1,2 @@ +5VmrzVmx lon1-web-01 LON1 dedicated_server 1.3 TB 3.8 TB 0 B 0 B +jpAAGYJp lux3test3-reordered LUX3 dedicated_server 146.4 MB 540.7 MB 145.5 MB 619.6 MB diff --git a/testdata/entities/metrics/hosts_page.txt b/testdata/entities/metrics/hosts_page.txt new file mode 100644 index 0000000..0a2005a --- /dev/null +++ b/testdata/entities/metrics/hosts_page.txt @@ -0,0 +1,2 @@ +Host ID Title Location Type Public Sent Public Recv Private Sent Private Recv +jpAAGYJp lux3test3-reordered LUX3 dedicated_server 146.4 MB 540.7 MB 145.5 MB 619.6 MB diff --git a/testdata/entities/metrics/hosts_page_view.txt b/testdata/entities/metrics/hosts_page_view.txt new file mode 100644 index 0000000..7fc11e2 --- /dev/null +++ b/testdata/entities/metrics/hosts_page_view.txt @@ -0,0 +1,29 @@ +Host ID: 5VmrzVmx +Title: lon1-web-01 +Location: LON1 +Location ID: 23 +Type: dedicated_server +Chassis: Dell R330 - E3-1230 v6 - 3.5" +Rack ID: 5VmrzVmx +Rack Type: shared +Public Sent: 1.3 TB +Public Recv: 3.8 TB +Private Sent: 0 B +Private Recv: 0 B +Total Sent: 1.3 TB +Total Recv: 3.8 TB +--- +Host ID: jpAAGYJp +Title: lux3test3-reordered +Location: LUX3 +Location ID: 52 +Type: dedicated_server +Chassis: Dell R440 - Silver 4114 - 2.5" +Rack ID: 0pEOrzdl +Rack Type: shared +Public Sent: 146.4 MB +Public Recv: 540.7 MB +Private Sent: 145.5 MB +Private Recv: 619.6 MB +Total Sent: 291.9 MB +Total Recv: 1.2 GB diff --git a/testdata/entities/metrics/hosts_template.txt b/testdata/entities/metrics/hosts_template.txt new file mode 100644 index 0000000..229b818 --- /dev/null +++ b/testdata/entities/metrics/hosts_template.txt @@ -0,0 +1,2 @@ +5VmrzVmx 1319413953331 +jpAAGYJp 291944526 diff --git a/testdata/entities/metrics/racks.txt b/testdata/entities/metrics/racks.txt new file mode 100644 index 0000000..2da009f --- /dev/null +++ b/testdata/entities/metrics/racks.txt @@ -0,0 +1,3 @@ +Rack ID Title Location Hosts Public Sent Public Recv PDU Watts PDU Amperes +0pEOrzdl rack-a LUX3 4 1.3 TB 3.8 TB 1240.00 5.60 +7xKLmnQp rack-b LUX3 0 0 B 0 B 0.00 0.00 diff --git a/testdata/entities/metrics/racks_empty.txt b/testdata/entities/metrics/racks_empty.txt new file mode 100644 index 0000000..6e74a1e --- /dev/null +++ b/testdata/entities/metrics/racks_empty.txt @@ -0,0 +1 @@ +Rack ID Title Location Hosts Public Sent Public Recv PDU Watts PDU Amperes diff --git a/testdata/entities/metrics/racks_field.txt b/testdata/entities/metrics/racks_field.txt new file mode 100644 index 0000000..e44676b --- /dev/null +++ b/testdata/entities/metrics/racks_field.txt @@ -0,0 +1,3 @@ +Rack ID ATS Watts ATS Amperes ATSs +0pEOrzdl 1240.00 5.60 1 +7xKLmnQp 0.00 0.00 0 diff --git a/testdata/entities/metrics/racks_input.txt b/testdata/entities/metrics/racks_input.txt new file mode 100644 index 0000000..0acd240 --- /dev/null +++ b/testdata/entities/metrics/racks_input.txt @@ -0,0 +1,36 @@ +# HELP: serverscom_racks_count Count of the racks +# TYPE: serverscom_racks_count gauge +serverscom_racks_count{location_id="52",location_code="LUX3"} 2 + +# HELP: serverscom_rack_hosts_count Count of the hosts in the rack +# TYPE: serverscom_rack_hosts_count gauge +serverscom_rack_hosts_count{location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_title="rack-a"} 4 +serverscom_rack_hosts_count{location_id="52",location_code="LUX3",rack_id="7xKLmnQp",rack_title="rack-b"} 0 + +# HELP: serverscom_rack_monthly_sent_bytes_total Rack monthly sent bytes total +# TYPE: serverscom_rack_monthly_sent_bytes_total counter +serverscom_rack_monthly_sent_bytes_total{location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_title="rack-a",traffic_type="public"} 1319413953331 +serverscom_rack_monthly_sent_bytes_total{location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_title="rack-a",traffic_type="private"} 145497332 + +# HELP: serverscom_rack_monthly_received_bytes_total Rack monthly received bytes total +# TYPE: serverscom_rack_monthly_received_bytes_total counter +serverscom_rack_monthly_received_bytes_total{location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_title="rack-a",traffic_type="public"} 3775348762345 +serverscom_rack_monthly_received_bytes_total{location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_title="rack-a",traffic_type="private"} 619636079 + +# HELP: serverscom_rack_pdu_power_watts Instantaneous power draw from the rack PDU, measured in watts (W). +# TYPE: serverscom_rack_pdu_power_watts gauge +serverscom_rack_pdu_power_watts{location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_title="rack-a",pdu_name="pdu-01"} 620.5 +serverscom_rack_pdu_power_watts{location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_title="rack-a",pdu_name="pdu-02"} 619.5 + +# HELP: serverscom_rack_pdu_current_amperes Instantaneous electrical current draw from the rack PDU, measured in amperes (A). +# TYPE: serverscom_rack_pdu_current_amperes gauge +serverscom_rack_pdu_current_amperes{location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_title="rack-a",pdu_name="pdu-01"} 2.8 +serverscom_rack_pdu_current_amperes{location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_title="rack-a",pdu_name="pdu-02"} 2.8 + +# HELP: serverscom_rack_ats_power_watts Instantaneous power draw from the rack ATS, measured in watts (W). +# TYPE: serverscom_rack_ats_power_watts gauge +serverscom_rack_ats_power_watts{location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_title="rack-a",ats_name="ats-01"} 1240.0 + +# HELP: serverscom_rack_ats_current_amperes Instantaneous electrical current draw from the rack ATS, measured in amperes (A). +# TYPE: serverscom_rack_ats_current_amperes gauge +serverscom_rack_ats_current_amperes{location_id="52",location_code="LUX3",rack_id="0pEOrzdl",rack_title="rack-a",ats_name="ats-01"} 5.6 diff --git a/testdata/entities/metrics/racks_page.txt b/testdata/entities/metrics/racks_page.txt new file mode 100644 index 0000000..2a02aac --- /dev/null +++ b/testdata/entities/metrics/racks_page.txt @@ -0,0 +1,2 @@ +Rack ID Title Location Hosts Public Sent Public Recv PDU Watts PDU Amperes +0pEOrzdl rack-a LUX3 4 1.3 TB 3.8 TB 1240.00 5.60 diff --git a/testdata/entities/metrics/racks_page_view.txt b/testdata/entities/metrics/racks_page_view.txt new file mode 100644 index 0000000..b615229 --- /dev/null +++ b/testdata/entities/metrics/racks_page_view.txt @@ -0,0 +1,35 @@ +Rack ID: 0pEOrzdl +Title: rack-a +Location: LUX3 +Location ID: 52 +Hosts: 4 +Public Sent: 1.3 TB +Public Recv: 3.8 TB +Private Sent: 145.5 MB +Private Recv: 619.6 MB +Total Sent: 1.3 TB +Total Recv: 3.8 TB +PDU Watts: 1240.00 +PDU Amperes: 5.60 +PDUs: 2 +ATS Watts: 1240.00 +ATS Amperes: 5.60 +ATSs: 1 +--- +Rack ID: 7xKLmnQp +Title: rack-b +Location: LUX3 +Location ID: 52 +Hosts: 0 +Public Sent: 0 B +Public Recv: 0 B +Private Sent: 0 B +Private Recv: 0 B +Total Sent: 0 B +Total Recv: 0 B +PDU Watts: 0.00 +PDU Amperes: 0.00 +PDUs: 0 +ATS Watts: 0.00 +ATS Amperes: 0.00 +ATSs: 0