Add parsing API for both rows and columns

This CL adds a parsing API that parses output with row and
column headers. It also adds documentation for the parsing
functions.

Change-Id: Ia0379f077939b0930781b0652f7b053b831b1adb
Reviewed-on: https://cos-review.googlesource.com/c/cos/tools/+/19150
Reviewed-by: Vaibhav Rustagi <vaibhavrustagi@google.com>
Reviewed-by: Dexter Rivera <riverade@google.com>
Tested-by: Vaibhav Rustagi <vaibhavrustagi@google.com>
Cloud-Build: Vaibhav Rustagi <vaibhavrustagi@google.com>
diff --git a/src/pkg/nodeprofiler/utils/parsing.go b/src/pkg/nodeprofiler/utils/parsing.go
index 540f46e..ee32ea0 100644
--- a/src/pkg/nodeprofiler/utils/parsing.go
+++ b/src/pkg/nodeprofiler/utils/parsing.go
@@ -2,6 +2,7 @@
 
 import (
 	"fmt"
+	"regexp"
 	"strings"
 )
 
@@ -11,47 +12,94 @@
 type ParsedOutput map[string][]string
 
 // ParseColumns parses command outputs which are in a column table.
-// It takes in an optional titles argument which specifies the
+// The function takes in an optional titles argument which specifies the
 // columns to parse. If this argument is missing, then all columns are parsed.
 //
-// Eg, ParseColumns(["total        used        free      shared",
-// 					"14868916     12660    14830916          0"],
-// 					["total", "used"]) = map[string][]string {
-//					"total": ["14868916"]
-//					"used": ["12660"]}
+// Eg, ParseColumns(["r        b        swpd      buff",
+//                  "10        0    14831128        0"],
+//                  ["r", "b"]) = map[string][]string {
+//                  "r": ["10"]
+//                  "b": ["0"]}
+//
+// The output needs to have titles on all its columns else the function will
+// return an error:
+//
+// Eg ParseColumns(["              total        used",
+//                  "Mem:          14520          12",
+//                  "Swap:             0           0"],
+//                  ["total", "used"])
+//                  err : "row has different number of columns from header row"
+//
+// Some edge cases that will be parsed by this function include:
+//
+// rows with repeated headers, eg with the output of iostat:
+// [] string {"Device      tps    kB_read/s    kB_wrtn/s    kB_read    kB_wrtn",
+//            "vdb        2.39        57.39        69.83     855576    1041132",
+//            "                                                               ",
+//            "Device      tps    kB_read/s    kB_wrtn/s    kB_read    kB_wrtn",
+//            "                                                                ",
+//            "Device      tps    kB_read/s    kB_wrtn/s    kB_read    kB_wrtn"} =>
+// map[string][]string {
+// 	"tps" : {"2.39"},
+//  "kB_read/s" : {"57.39"},
+//  ...
+// }
 func ParseColumns(rows []string, titles ...string) (map[string][]string, error) {
-	// parsedOutput is a map that stores col titles as key and entries as values.
+
 	parsedOutput := ParsedOutput{}
-	// maps column title to its index eg columns["r"] = 0 wth vmstat.
+	// maps each column title to its index eg columns["r"] = 0 wth vmstat.
 	columns := make(map[string]int)
+	// header saves the row that contains the titles
+	var header string
+
 	for i, row := range rows {
-		// break the row into slice
-		tokens := strings.Fields(row)
-		if len(tokens) == 0 {
+		// skip empty lines
+		if row = strings.TrimSpace(row); row == "" {
 			continue
 		}
-		// find index of column titles
+		tokens := strings.Fields(row)
+
 		if i == 0 {
+			header = row
 			// if no titles were specified, use all of them
 			if len(titles) == 0 {
 				titles = tokens
 			}
-			// map header name to its index
+			// map column title to its index eg "r" : 0 with vmstat
 			for index, str := range tokens {
 				columns[str] = index
 			}
 			continue
 		}
-		// loop over titles, get column number of the title using map,
-		// use that to get the actual values, append to list.
+		// if a row is similar to the row with headers, ignore it
+		if row == header {
+			continue
+		}
+		// checks that number of columns in row is equal to number of column
+		// titles that were in header. Thus trying to parse the rows below
+		// will return an error here:
+		// "             total        used", (len 2)
+		// "Mem:         14520          12", (len 3)
+		// "Swap:            0           0",(len 3)
+		if len(columns) != len(tokens) {
+			err := "row has different number of columns from header row: \n" +
+				"header row: \n %q \n " +
+				"current row: \n %q"
+			return nil, fmt.Errorf(err, header, row)
+		}
+
+		// loop over titles, get index of each title using map,
+		// use that to get the actual values, add title and its value(s) to
+		// parsedOutput.
 		for _, title := range titles {
-			// for example columns["us"] = 12
 			index, ok := columns[title]
 			if !ok {
-				return nil, fmt.Errorf("unknown Column title %s", title)
+				return nil, fmt.Errorf("unknown column title %q", title)
 			}
-			// for example if vmstat was run, tokens[0] will give the
-			// value of a running process for some update.
+			// Get the actual value from the row. Eg, if 'r' is the title and
+			// the lines to parse were below, then index = 0 and tokens[index] = 5
+			// "r   b   swpd ..."
+			// "5   0      0 ..."
 			value := tokens[index]
 			parsedOutput[title] = append(parsedOutput[title], value)
 		}
@@ -59,50 +107,211 @@
 	return parsedOutput, nil
 }
 
-// ParseRows parses command outputs which are in a row table.
-// It takes in an optional titles argument which specifies which rows
-// to parse. If missing, all rows are parsed.
+// ParseRows parses command outputs which are in a row table. It takes in a
+// string which specifies the delimiter that separates row title from values.
+// The function does not support '\n' as a delimiter for now. It takes in an
+// optional titles argument which specifies which rows to parse.
 //
 // Eg, ParseRows(["avg-cpu:  %user %nice %system  %iowait %steal  %idle"],
-// 				 [avg-cpu]) = map[string][]string {
-//				 avg-cpu: [%user, %nice, %system, %iowait, %steal, %idle]}
-func ParseRows(lines []string, titles ...string) (map[string][]string, error) {
-	// parsedOutput stores titles passed to function as key and entries as values.
+//               [avg-cpu]) = map[string][]string {
+//               avg-cpu: [%user, %nice, %system, %iowait, %steal, %idle]}
+//
+// If the wrong delimiter is passed in, the function returns an error:
+//
+// Eg [FAIL] ParseRows(["Device  tps   kB_read/s   kB_wrtn/s",
+//                     "vdb 	1.13	  19.48 	  33.61"
+//                     "vda    0.02       0.86       0.00"], ":", ["vda"])
+//                     err: "failed to split row into row title and value"
+//
+// Some edge cases parsed by this function include:
+//
+// Rows whose delimiter have whitespaces around it. For example,
+// [] string { "processor:     7",
+//             "CPU family:    6"} =>
+// map[string][]string {
+//	  "processor"  : {"7"}
+//    "cpu family" : {"6"}
+// }
+//
+// OR
+//
+// [] string { "processor      : 7",
+//             "cpu family     : 6"} =>
+// map[string][]string {
+// 	  "processor" : {"7"}
+//    "cpu family" : {"6"}
+// }
+func ParseRows(lines []string, delim string, titles ...string) (map[string][]string, error) {
+
 	parsedOutput := ParsedOutput{}
-	// rows stores each title in rows as key and the rest of the row as value.
+	// rows maps each row title to their value(s) which is the rest of the row
+	// after delimiter
 	rows := make(map[string][]string)
-	// loop over lines and map each line title to value(s)
+
 	for _, line := range lines {
-		// split by ':' for titles that are multi worded
-		tokens := strings.Split(line, ":")
-		// tokens is always at least of length 1 since an empty string when
-		// split, will be a slice of length 1.
-		if len(tokens) == 1 {
+		// skip empty lines
+		if line = strings.TrimSpace(line); line == "" {
 			continue
 		}
-		header := strings.Trim(tokens[0], "\\s*")
-		// everything to the right of title is one string since
-		// row was split by the character ':'.
-		value := tokens[1]
-		// now split value according to white spaces
-		tokens = strings.Fields(value)
+		tokens := strings.Split(line, delim)
+		// if row did not split, return an error.
+		if len(tokens) == 1 {
+			err := "failed to split %q by the delimiter %q"
+			return nil, fmt.Errorf(err, line, delim)
+		}
+		// removes white space from title
+		header := strings.TrimSpace(tokens[0])
+		value := tokens[1:]
+		// remove any extra white spaces in the values. Since values is a
+		// slice, first join all the strings into 1 and split it. For example,
+		//
+		// "Architecture:     x86_64" will be split into ["Architecture", "    x86_64"]
+		// To remove the whitespaces in [ "    x86_64"], join slice into
+		// "x86_64" then split to make it a slice again ["x86_64"].
+		tokens = strings.Fields(strings.Join(value, " "))
 		rows[header] = tokens
 	}
-	// if empty titles slice was passed, use all the row titles.
+
+	// if no titles were passed, use all the row titles.
 	if len(titles) == 0 {
 		for key := range rows {
 			titles = append(titles, key)
 		}
 	}
-	// loop over titles passed to function and get their values from the map.
+	// loop over titles passed to function (or initiliazed above), get their
+	// values from the map, add to parsedOutput
 	for _, title := range titles {
 		var values []string
 		var ok bool
-		// check if any additional titles were passed in.
 		if values, ok = rows[title]; !ok {
-			return nil, fmt.Errorf("could not find the row title %s", title)
+			return nil, fmt.Errorf("unknown row title %q", title)
 		}
 		parsedOutput[title] = values
 	}
 	return parsedOutput, nil
 }
+
+// ParseRowsAndColumns parses command outputs that have row and column headers.
+// It takes in an optional titles argument which specifies the row column
+// combination to parse. If the titles argument is missing, then an empty map
+// is returned.
+//
+// Eg, ParseRowsAndColumns(["		total   used   free   shared",
+//                         "Mem:   14520     12   14482        0",
+//                         "Swap:      0      0       0        "],
+//                         ["Mem:used", "Swap:total"]) = map[string][]string {
+//                         "Mem:used": ["12"]
+//                         "Swap:total" : ["0"]}
+//
+// The titles should be in the format "row:column". Else, an error is returned:
+//
+// Eg [FAIL], ParseRowsAndColumns(["       total   used   free   shared",
+//                                  "Mem:   14520     12   14482       0",
+//                                  "Swap:      0      0       0        "],
+//                                  ["Mem+used", "Swap+total"])
+//                                  err : "title string not well-formatted"
+//
+// Here are some edge cases parsed by the function:
+//
+// Rows with non-empty strings on row 0 column 0 E.g., with iostat (The default is
+// an empty string on row 0 column 0):
+// "Device             tps    kB_read/s    kB_wrtn/s    kB_read    kB_wrtn",
+// "vdb               0.74        10.39        23.23     859900    1922916",
+// "vda               0.01         0.46         0.00      37829          0"
+func ParseRowsAndColumns(lines []string, titles ...string) (map[string][]string, error) {
+
+	parsedOutput := make(ParsedOutput)
+	// columns maps column title to its index eg columns["total"] = 0 wth free.
+	columns := make(map[string]int)
+	// titlesMap maps a row title to columns titles based on the titles passed
+	// into function:
+	// Eg "Mem" : ["total", "used"] for "Mem:total", "Mem:used"
+	//    "Swap": ["total", "used"] for "Swap:total", "Swap:used"
+	titlesMap := make(map[string][]string)
+
+	// loop over titles and split them by row and column titles.
+	for _, title := range titles {
+		headers := strings.Split(strings.Trim(title, ":"), ":")
+		if length := len(headers); length == 2 {
+			titlesMap[headers[0]] = append(titlesMap[headers[0]], headers[1])
+		} else {
+			err := "title string not well-formed: each title should " +
+				"be in the form <row>:<column>, where row is the name " +
+				"of the row header and column is the name of the " +
+				"column header but got %q"
+			return nil, fmt.Errorf(err, title)
+		}
+	}
+
+	var diff int
+	// rows stores each title in rows as key and the rest of the row as value.
+	rows := make(map[string][]string)
+	// loop over each row, mapping its title to the rest of the row (which is
+	// its value).
+	for i, line := range lines {
+		tokens := strings.Fields(line)
+		if len(tokens) == 0 {
+			continue
+		}
+
+		if i == 0 {
+			// Looking at the edge case example above (iostat's output), since
+			// rows are split by whitespaces, the index of "tps" will be 1
+			// after split. When the second row is split, and divided into row
+			// title and values, the following will result:
+			// "vdb" : {"0.74", "10.39", "23.23", "859900", "1922916"}
+			//
+			// Index of column titles will be used to access values from slice
+			// above. Index of "tps" = 1 and index 1 of slice above is 10.39
+			// (which is incorrect). The correct value is in index 0 (which we
+			// we would have gotten if col 0 row 0 was empty). To deal with this,
+			// if column 0 of row 0 is a non-empty string, then 1 is subtracted
+			// from the actual index of the rest of the colums in row 0. Thus the
+			// need for the diff variable.
+			exp := regexp.MustCompile(`\s*`)
+			chars := exp.Split(line, -1)
+			if chars[0] != "" {
+				diff = -1
+			}
+			// map header name to its index
+			for index, str := range tokens {
+				columns[str] = index + diff
+			}
+			continue
+		}
+		rHeader := strings.Trim(tokens[0], ":")
+		//everything to the right of the row title is its value
+		rows[rHeader] = tokens[1:]
+	}
+
+	// loop over the titlesMap and use the row titles to access all
+	// the values for that row. From those values, access the columns
+	// we're interested in
+	// Eg with free's output below:
+	// "              total        used        free", (len 3)
+	// "Mem:          14520          13       14482", (len 4)
+	// "Swap:             0           0           0"  (len 4)
+	//
+	// Assuming the titlesMap is: "Mem"  : {"total", "used"}
+	//						      "Swap" : {"total", "used"}
+	//
+	// When we loop over the map above, we first access the values for the
+	// the row titles:  "Mem": {"14520", "13", "14482"}
+	//                  "Swap": {"0", "0", "0"}
+	// Then to access the values we're interested eg "Mem:total", use the index of
+	// the column title "total" to index into the slice of values, i.e,
+	// columns["total"] = 0 which corresponds to "14520" in {"14520", "13", "14482"}
+	for rowTitle, colTitles := range titlesMap {
+		values := rows[rowTitle]
+		for _, columnTitle := range colTitles {
+
+			index := columns[columnTitle]
+			value := values[index]
+			// combine the row and column title again when adding to the parsed
+			// output map.
+			combined := rowTitle + ":" + columnTitle
+			parsedOutput[combined] = append(parsedOutput[combined], value)
+		}
+	}
+	return parsedOutput, nil
+}
diff --git a/src/pkg/nodeprofiler/utils/parsing_test.go b/src/pkg/nodeprofiler/utils/parsing_test.go
index 73ff963..71c8416 100644
--- a/src/pkg/nodeprofiler/utils/parsing_test.go
+++ b/src/pkg/nodeprofiler/utils/parsing_test.go
@@ -16,11 +16,14 @@
 	}{
 
 		{
-			name: "basic",
+			name: "vmstat's output with spaced rows",
 			rows: []string{
 				"r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st",
+				"                                                                                ",
 				"5  0      0 14827096      0  25608    0    0     2     5   57    2  1  0 99  0  0",
+				"                                                                                ",
 				"2  0      0 14827096      0  25608    0    0     0     0 1131 1594  2  1 97  0  1",
+				"                                                                                ",
 				"2  0      0 14827096      0  25608    0    0     0     0 5283 8037  7  3 90  0  0",
 			},
 			titles: []string{"us", "sy", "st"},
@@ -31,31 +34,6 @@
 			},
 		},
 		{
-			name: "spaced rows",
-			rows: []string{
-				"r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st",
-				"                                                                                ",
-				"2  0      0 14827096      0  25608    0    0     0     0 1131 1594  2  1 97  0  0",
-				"                                                                                ",
-				"5  0      0 14827780      0  25740    0    0     0     5    3    7  1  0 96  3  0",
-				"                                                                                ",
-				"1  0      0 14827724      0  25608    0    0     1     6   10   37  1  0 96  2  0",
-			},
-			titles: []string{"r"},
-			want: map[string][]string{
-				"r": {"2", "5", "1"},
-			},
-		},
-		{
-			name: "unknown titles",
-			rows: []string{
-				"r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st",
-				"5  0      0 14827096      0  25608    0    0     2     5   57    2  1  0 99  0  0",
-			},
-			titles:  []string{"us", "sy", "steal"},
-			wantErr: true,
-		},
-		{
 			name: "empty slice",
 			rows: []string{},
 			want: map[string][]string{},
@@ -74,15 +52,52 @@
 				"buff": {"0"},
 			},
 		},
+		{
+			name: "repeated headers",
+			rows: []string{
+				"Device            r/s     w/s     rkB/s     wkB/s   rrqm/s   wrqm/s  %rrqm  %wrqm r_await w_await aqu-sz rareq-sz wareq-sz  svctm  %util",
+				"vdb              0.01    0.58      0.86     20.79     0.00     0.19   0.24  25.02    8.44 1552.07   0.90    96.44    35.68  95.00   5.62",
+				"vda              0.00    0.00      0.04      0.00     0.00     0.00   2.73   0.00    3.08    0.00   0.00    62.55     0.00   2.20   0.00",
+				"																																		 ",
+				"Device            r/s     w/s     rkB/s     wkB/s   rrqm/s   wrqm/s  %rrqm  %wrqm r_await w_await aqu-sz rareq-sz wareq-sz  svctm  %util",
+				"																																		 ",
+				"Device            r/s     w/s     rkB/s     wkB/s   rrqm/s   wrqm/s  %rrqm  %wrqm r_await w_await aqu-sz rareq-sz wareq-sz  svctm  %util",
+				"																																		 ",
+				"Device            r/s     w/s     rkB/s     wkB/s   rrqm/s   wrqm/s  %rrqm  %wrqm r_await w_await aqu-sz rareq-sz wareq-sz  svctm  %util",
+			},
+			titles: []string{"%util"},
+			want: map[string][]string{
+				"%util": {"5.62", "0.00"},
+			},
+		},
+		{
+			name: "missing titles on some columns",
+			rows: []string{
+				"              total        used        free      shared  buff/cache   available",
+				"Mem:          14520          13       14481           0          25       14506",
+				"Swap:             0           0           0",
+			},
+			titles:  []string{"total"},
+			wantErr: true,
+		},
+		{
+			name: "unknown titles",
+			rows: []string{
+				"r  b   swpd    free   buff   cache",
+				"5  0      0 14827096     0   25608",
+			},
+			titles:  []string{"r", "b", "used"},
+			wantErr: true,
+		},
 	}
 
 	for _, test := range tests {
 		got, err := ParseColumns(test.rows, test.titles...)
 		if gotErr := err != nil; gotErr != test.wantErr {
-			t.Fatalf("ParseColumns(%v, %v) err %q, wantErr: %v", test.rows, test.titles, err, test.wantErr)
+			t.Fatalf("ParseColumns(%v, %v) err %v, wantErr: %t", test.rows, test.titles, err, test.wantErr)
 		}
 		if diff := cmp.Diff(test.want, got); diff != "" {
-			t.Errorf("Ran ParseColumns(%v, %v), but got mismatch between got and want (-got, +want): \n diff %s", test.rows, test.titles, diff)
+			t.Errorf("Ran ParseColumns(%v, %v), but got mismatch between got and want (+got, -want): \n diff %s", test.rows, test.titles, diff)
 		}
 	}
 }
@@ -91,63 +106,43 @@
 	tests := []struct {
 		name    string
 		rows    []string
+		delim   string
 		titles  []string
 		want    map[string][]string
 		wantErr bool
 	}{
 		{
-			name: "lscpu's output",
+			name: "lscpu's output with spaced rows",
 			rows: []string{
 				"Architecture:        x86_64",
+				"							 ",
 				"CPU op-mode(s):      32-bit, 64-bit",
 				"Byte Order:          Little Endian",
+				"								 ",
 				"Address sizes:       39 bits physical, 48 bits virtual",
+				"							 ",
 				"CPU(s):              8",
 				"On-line CPU(s) list: 0-7",
 			},
-
+			delim:  ":",
 			titles: []string{"CPU(s)"},
 			want: map[string][]string{
 				"CPU(s)": {"8"},
 			},
 		},
 		{
-			name: "spaced rows",
+			name: "whitespace delimiter",
 			rows: []string{
-				"Architecture:        x86_64",
-				"							 ",
-				"CPU op-mode(s):      32-bit, 64-bit",
-				"							 ",
-				"CPU(s):              8",
+				"vdb               0.63         0.86        22.19     760760   19680952",
+				"vda               0.00         0.04         0.00      37845          0",
 			},
-			titles: []string{"CPU(s)"},
+			delim: " ",
 			want: map[string][]string{
-				"CPU(s)": {"8"},
+				"vdb": {"0.63", "0.86", "22.19", "760760", "19680952"},
+				"vda": {"0.00", "0.04", "0.00", "37845", "0"},
 			},
 		},
 		{
-			name: "free's output",
-			rows: []string{
-				"Mem:          14518          13       14480           0          25       14505",
-				"Swap:             0           0           0									",
-			},
-			titles: []string{"Mem", "Swap"},
-			want: map[string][]string{
-				"Mem":  {"14518", "13", "14480", "0", "25", "14505"},
-				"Swap": {"0", "0", "0"},
-			},
-		},
-		{
-			name: "unknown titles",
-			rows: []string{
-				"Device:             tps    kB_read/s    kB_wrtn/s    kB_read    kB_wrtn",
-				"vdb:               0.60         1.54        21.54     836516   11703972",
-				"vda:               0.00         0.07         0.00      37901          0",
-			},
-			titles:  []string{"sda"},
-			wantErr: true,
-		},
-		{
 			name: "empty slice",
 			rows: []string{},
 			want: map[string][]string{},
@@ -155,12 +150,13 @@
 		{
 			name: "empty titles",
 			rows: []string{
-				"processor: 0",
-				"vendor_id: GenuineIntel",
-				"cpu family: 6",
-				"model: 142",
-				"model name: 06/8e",
+				"processor       : 0",
+				"vendor_id       : GenuineIntel",
+				"cpu family      : 6",
+				"model           : 142",
+				"model name      : 06/8e",
 			},
+			delim:  ":",
 			titles: []string{},
 			want: map[string][]string{
 				"processor":  {"0"},
@@ -170,15 +166,98 @@
 				"model name": {"06/8e"},
 			},
 		},
+		{
+			name: "wrong delimiter",
+			rows: []string{
+				"vdb               0.63         0.86        22.30     760860   19808528",
+				"vda               0.00         0.04         0.00      37845          0",
+			},
+			delim:   ":",
+			wantErr: true,
+		},
+		{
+			name: "unknown titles",
+			rows: []string{
+				"Device           tps    kB_read/s    kB_wrtn/s    kB_read    kB_wrtn",
+				"vdb             0.60         1.54        21.54     836516   11703972",
+				"vda             0.00         0.07         0.00      37901          0",
+			},
+			delim:   " ",
+			titles:  []string{"sda"},
+			wantErr: true,
+		},
 	}
 
 	for _, test := range tests {
-		got, err := ParseRows(test.rows, test.titles...)
+		got, err := ParseRows(test.rows, test.delim, test.titles...)
 		if gotErr := err != nil; gotErr != test.wantErr {
-			t.Fatalf("ParseRows(%v, %v) = %q, wantErr: %v", test.rows, test.titles, err, test.wantErr)
+			t.Fatalf("ParseRows(%v, %v) = %v, wantErr: %t", test.rows, test.titles, err, test.wantErr)
 		}
 		if diff := cmp.Diff(test.want, got); diff != "" {
-			t.Errorf("Ran ParseRows(%v, %v), but got mismatch between got and want (-got, +want): \n diff %s", test.rows, test.titles, diff)
+			t.Errorf("Ran ParseRows(%v, %v), but got mismatch between got and want (+got,-want): \n diff %s", test.rows, test.titles, diff)
 		}
 	}
 }
+
+func TestParseRowsAndColumns(t *testing.T) {
+	tests := []struct {
+		name    string
+		rows    []string
+		titles  []string
+		want    map[string][]string
+		wantErr bool
+	}{
+		{
+			name: "free",
+			rows: []string{
+				"              total        used        free      shared  buff/cache   available",
+				"Mem:          14520          13       14481           0          25       14506",
+				"Swap:             0           0           0",
+			},
+			titles: []string{"Mem:used", "Mem:total", "Swap:used", "Swap:total"},
+			want: map[string][]string{
+				"Mem:used":   {"13"},
+				"Mem:total":  {"14520"},
+				"Swap:used":  {"0"},
+				"Swap:total": {"0"},
+			},
+		},
+		{
+			name: "iostat with spaced rows",
+			rows: []string{
+				"Device             tps    kB_read/s    kB_wrtn/s    kB_read    kB_wrtn",
+				"                                                                      ",
+				"vdb               1.27         3.79        41.80     732408    8072028",
+				"                                                                       ",
+				"vda               0.00         0.20         0.00      37845          0",
+			},
+			titles: []string{"vdb:tps", "vda:kB_read", "vdb:kB_wrtn"},
+			want: map[string][]string{
+				"vdb:tps":     {"1.27"},
+				"vdb:kB_wrtn": {"8072028"},
+				"vda:kB_read": {"37845"},
+			},
+		},
+		{
+			name: "wrongly formatted titles",
+			rows: []string{
+				"Device             tps    kB_read/s    kB_wrtn/s    kB_read    kB_wrtn",
+				"vdb               1.27         3.79        41.80     732408    8072028",
+				"vda               0.00         0.20         0.00      37845          0",
+			},
+			titles:  []string{":tps"},
+			wantErr: true,
+		},
+	}
+
+	for _, test := range tests {
+		got, err := ParseRowsAndColumns(test.rows, test.titles...)
+		if gotErr := err != nil; gotErr != test.wantErr {
+			t.Fatalf("ParseRowsAndColumns(%v, %v) = %v, wantErr: %t", test.rows, test.titles, err, test.wantErr)
+		}
+		if diff := cmp.Diff(test.want, got); diff != "" {
+			t.Errorf("Ran ParseRowsAndColumns(%v, %v), but got mismatch between got and want (+got, -want): \n diff %s", test.rows, test.titles, diff)
+		}
+	}
+
+}
diff --git a/src/pkg/nodeprofiler/utils/utils.go b/src/pkg/nodeprofiler/utils/utils.go
index a41873d..a4afccd 100644
--- a/src/pkg/nodeprofiler/utils/utils.go
+++ b/src/pkg/nodeprofiler/utils/utils.go
@@ -5,14 +5,49 @@
 import (
 	"fmt"
 	"os/exec"
+	"strconv"
+
+	log "github.com/sirupsen/logrus"
 )
 
 // RunCommand is a wrapper function for exec.Command that will run the command
 // specified return its output and/or error.
 func RunCommand(cmd string, args ...string) ([]byte, error) {
+	log.Printf("running %q", cmd)
+
 	out, err := exec.Command(cmd, args...).CombinedOutput()
 	if err != nil {
-		return nil, fmt.Errorf("failed to run %s, %v: %v", cmd, args, err)
+		return nil, fmt.Errorf("failed to run %q, %v: %v", cmd, args, err)
 	}
+
+	log.Printf("finished running %s command successfully", cmd)
 	return out, nil
 }
+
+// SumAtoi converts all the strings in a slice to integers, sums them up and returns
+// the result. A non-nil error is returned if an error occurred.
+func SumAtoi(a []string) (int, error) {
+	var sum int
+	for _, str := range a {
+		val, err := strconv.Atoi(str)
+		if err != nil {
+			return 0, fmt.Errorf("could not convert %q to an int: %v", str, err)
+		}
+		sum += val
+	}
+	return sum, nil
+}
+
+// SumParseFloat converts all the strings in a slice to floating points, sums them up, and
+// returns the result as a floating point. A non-nil error is returned if an error occurred.
+func SumParseFloat(a []string) (float64, error) {
+	var sum float64
+	for _, str := range a {
+		val, err := strconv.ParseFloat(str, 64)
+		if err != nil {
+			return 0, fmt.Errorf("could not convert %q to float64: %v", str, err)
+		}
+		sum += val
+	}
+	return sum, nil
+}