Modify utils package to include parsing APIs
This CL defines logic for running commands and parsing the
output of such commands. It also defines a command interface
that can be implemented to execute shell commands.
Change-Id: I8bfbe6b115bc27399231e049ae32090f024ff508
Reviewed-on: https://cos-review.googlesource.com/c/cos/tools/+/18810
Cloud-Build: GCB Service account <228075978874@cloudbuild.gserviceaccount.com>
Reviewed-by: Dexter Rivera <riverade@google.com>
Reviewed-by: Vaibhav Rustagi <vaibhavrustagi@google.com>
Tested-by: Dexter Rivera <riverade@google.com>
diff --git a/src/pkg/nodeprofiler/profiler/commands.go b/src/pkg/nodeprofiler/profiler/commands.go
new file mode 100644
index 0000000..a41d779
--- /dev/null
+++ b/src/pkg/nodeprofiler/profiler/commands.go
@@ -0,0 +1,90 @@
+package profiler
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "cos.googlesource.com/cos/tools.git/src/pkg/nodeprofiler/utils"
+)
+
+// Command interface defines functions that can be implemented by
+// structs to execute shell commands.
+type Command interface {
+ Name() string
+ Run(opts Options) (map[string][]string, error)
+}
+
+// Options stores the options that can be passed to a command and
+// to parsing functions.
+type Options struct {
+ // Delay specifies times between updates in seconds.
+ Delay int
+ // Count specifies number of updates.
+ Count int
+ // Titles specifies the titles to get values for.
+ Titles []string
+}
+
+// vmstat represents a vmstat command.
+type vmstat struct {
+ name string
+}
+
+// lscpu represents an lscpu command.
+type lscpu struct {
+ name string
+}
+
+// Name returns the name for vmstat command.
+func (v *vmstat) Name() string {
+ return v.name
+}
+
+// Run executes the vmstat command, parses the output and returns it as
+// a map of titles to their values.
+func (v *vmstat) Run(opts Options) (map[string][]string, error) {
+ // delay and count not set
+ if opts.Delay == 0 {
+ opts.Delay = 1
+ }
+ if opts.Count == 0 {
+ opts.Count = 5
+ }
+ interval := strconv.Itoa(opts.Delay)
+ count := strconv.Itoa(opts.Count)
+ out, err := utils.RunCommand(v.Name(), "-n", interval, count)
+ if err != nil {
+ return nil, fmt.Errorf("failed to run vmstat command: %v", err)
+ }
+
+ s := string(out)
+ lines := strings.Split(strings.Trim(s, "\n"), "\n")
+ // ignore the first row in vmstat's output
+ lines = lines[1:]
+ titles := opts.Titles
+ // parse output by columns
+ output, err := utils.ParseColumns(lines, titles...)
+ return output, err
+
+}
+
+// Name returns the name for the lscpu command.
+func (l *lscpu) Name() string {
+ return l.name
+}
+
+// Run executes the lscpu command, parses the output and returns a
+// a map of title(s) to their values.
+func (l *lscpu) Run(opts Options) (map[string][]string, error) {
+ out, err := utils.RunCommand(l.Name())
+ if err != nil {
+ return nil, fmt.Errorf("failed to run vmstat command: %v", err)
+ }
+ s := string(out)
+ lines := strings.Split(strings.Trim(s, "\n"), "\n")
+ titles := opts.Titles
+ // parse output by rows
+ output, err := utils.ParseRows(lines, titles...)
+ return output, err
+}
diff --git a/src/pkg/nodeprofiler/profiler/commands_test.go b/src/pkg/nodeprofiler/profiler/commands_test.go
new file mode 100644
index 0000000..fcb8b80
--- /dev/null
+++ b/src/pkg/nodeprofiler/profiler/commands_test.go
@@ -0,0 +1,83 @@
+package profiler
+
+import (
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+func TestRun(t *testing.T) {
+ tests := []struct {
+ name string
+ fakeCmd Command
+ opts Options
+ want map[string][]string
+ wantErr bool
+ }{
+ {
+ name: "vmstat",
+ fakeCmd: &vmstat{"testdata/vmstat.sh"},
+ opts: Options{
+ Delay: 1,
+ Count: 3,
+ Titles: []string{"us", "st", "sy"},
+ },
+ want: map[string][]string{
+ "us": {"1", "2", "7"},
+ "sy": {"0", "1", "3"},
+ "st": {"0", "0", "0"},
+ },
+ },
+ {
+ name: "lscpu",
+ fakeCmd: &lscpu{"testdata/lscpu.sh"},
+ opts: Options{
+ Titles: []string{"CPU(s)"},
+ },
+ want: map[string][]string{
+ "CPU(s)": {"8"},
+ },
+ },
+ {
+ name: "no titles",
+ fakeCmd: &vmstat{"testdata/vmstat.sh"},
+ opts: Options{
+ Delay: 1,
+ Count: 2,
+ },
+ want: map[string][]string{
+ "r": {"3", "1"},
+ "us": {"1", "2"},
+ "sy": {"0", "1"},
+ "id": {"96", "98"},
+ "wa": {"3", "0"},
+ "st": {"0", "0"},
+ },
+ },
+ {
+ name: "spaced rows",
+ fakeCmd: &vmstat{"testdata/vmstat.sh"},
+ opts: Options{
+ Delay: 1,
+ Count: 4,
+ Titles: []string{"us", "st", "sy"},
+ },
+ want: map[string][]string{
+ "us": {"7", "3", "1", "1"},
+ "sy": {"2", "2", "2", "0"},
+ "st": {"0", "0", "0", "0"},
+ },
+ },
+ }
+
+ for _, test := range tests {
+ got, err := test.fakeCmd.Run(test.opts)
+
+ if gotErr := err != nil; gotErr != test.wantErr {
+ t.Fatalf("Run(%v) err %q, wantErr %v", test.opts, err, test.wantErr)
+ }
+ if diff := cmp.Diff(test.want, got); diff != "" {
+ t.Errorf("Ran Run(%v), but got mismatch between got and want (-got, +want): \n diff %s", test.opts, diff)
+ }
+ }
+}
diff --git a/src/pkg/nodeprofiler/profiler/testdata/lscpu.sh b/src/pkg/nodeprofiler/profiler/testdata/lscpu.sh
new file mode 100755
index 0000000..ed68e82
--- /dev/null
+++ b/src/pkg/nodeprofiler/profiler/testdata/lscpu.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+
+main() {
+ cat <<EOF
+ 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
+ Thread(s) per core: 1
+ Core(s) per socket: 8
+ Socket(s): 1
+ Vendor ID: GenuineIntel
+ CPU family: 6
+ Model: 142
+ Model name: 06/8e
+ Stepping: 12
+ CPU MHz: 2303.980
+ BogoMIPS: 4607.96
+ Virtualization: VT-x
+ Hypervisor vendor: KVM
+ Virtualization type: full
+ L1i cache: 32K
+ L2 cache: 256K
+ L3 cache: 8192K
+ Flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon rep_good nopl xtopology nonstop_tsc cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single ssbd ibrs ibpb stibp ibrs_enhanced tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 avx2 smep bmi2 erms invpcid mpx rdseed adx smap clflushopt xsaveopt xsavec xgetbv1 xsaves arat umip md_clear arch_capabilities
+EOF
+}
+
+main "$#"
\ No newline at end of file
diff --git a/src/pkg/nodeprofiler/profiler/testdata/vmstat.sh b/src/pkg/nodeprofiler/profiler/testdata/vmstat.sh
new file mode 100755
index 0000000..125066c
--- /dev/null
+++ b/src/pkg/nodeprofiler/profiler/testdata/vmstat.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+
+main () {
+ if [[ "$#" -ne 3 ]]; then
+ echo "command not called with 3 arguments" >&2 return 1
+ fi
+ if [[ $3 -lt 0 ]]; then
+ echo "$3 is not a valid argument" >&2 return 1
+ fi
+ case "$3" in
+ "3")
+ cat << EOF
+ procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
+ 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 0
+ 2 0 0 14827096 0 25608 0 0 0 0 5283 8037 7 3 90 0 0
+EOF
+ ;;
+ "0")
+ cat <<EOF
+ procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
+ r b swpd free buff cache si so bi bo in cs us sy id wa st
+ 9 0 0 14828152 0 25740 0 0 5 5 69 98 1 0 90 9 0
+EOF
+ ;;
+ "+5")
+ cat <<EOF
+ procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
+ r b swpd free buff cache si so bi bo in cs us sy id wa st
+ 3 0 0 14828112 0 25740 0 0 3 6 85 121 1 0 93 6 0
+ 1 0 0 14828112 0 25740 0 0 0 0 854 1098 1 1 98 0 0
+ 1 0 0 14828112 0 25740 0 0 0 0 1012 1399 2 1 98 0 0
+ 1 0 0 14828112 0 25740 0 0 0 0 2991 4478 5 2 92 0 0
+ 2 0 0 14828112 0 25740 0 0 0 0 7698 8623 18 6 75 0 1
+EOF
+ ;;
+ "1")
+ cat <<EOF
+ procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
+ r b swpd free buff cache si so bi bo in cs us sy id wa st
+ 2 0 0 14827724 0 25608 0 0 1 6 10 37 1 0 96 2 0
+EOF
+ ;;
+ "4")
+ cat <<EOF
+ procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
+ r b swpd free buff cache si so bi bo in cs us sy id wa st
+
+
+ 1 0 0 14827712 0 25608 0 0 0 0 2561 3731 7 2 91 0 0
+ 0 0 0 14827712 0 25608 0 0 0 0 1885 2684 3 2 95 0 0
+
+ 0 0 0 14827712 0 25608 0 0 0 0 827 894 1 2 98 0 0
+
+ 5 0 0 14827780 0 25740 0 0 0 5 3 7 1 0 96 3 0
+EOF
+ ;;
+ "2")
+ cat <<EOF
+ procs--sys--cpu--
+ r us sy id wa st
+ 3 1 0 96 3 0
+ 1 2 1 98 0 0
+EOF
+ ;;
+ esac
+}
+
+main "$@"
+
+
diff --git a/src/pkg/nodeprofiler/utils/parsing.go b/src/pkg/nodeprofiler/utils/parsing.go
new file mode 100644
index 0000000..540f46e
--- /dev/null
+++ b/src/pkg/nodeprofiler/utils/parsing.go
@@ -0,0 +1,108 @@
+package utils
+
+import (
+ "fmt"
+ "strings"
+)
+
+// ParsedOutput is a data structure that holds the parsed output
+// of certain shell commands whose output takes the form of a
+// table.
+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
+// 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"]}
+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.
+ columns := make(map[string]int)
+ for i, row := range rows {
+ // break the row into slice
+ tokens := strings.Fields(row)
+ if len(tokens) == 0 {
+ continue
+ }
+ // find index of column titles
+ if i == 0 {
+ // if no titles were specified, use all of them
+ if len(titles) == 0 {
+ titles = tokens
+ }
+ // map header name to its index
+ 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.
+ for _, title := range titles {
+ // for example columns["us"] = 12
+ index, ok := columns[title]
+ if !ok {
+ return nil, fmt.Errorf("unknown Column title %s", title)
+ }
+ // for example if vmstat was run, tokens[0] will give the
+ // value of a running process for some update.
+ value := tokens[index]
+ parsedOutput[title] = append(parsedOutput[title], value)
+ }
+ }
+ 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.
+//
+// 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.
+ parsedOutput := ParsedOutput{}
+ // rows stores each title in rows as key and the rest of the row as value.
+ 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 {
+ 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)
+ rows[header] = tokens
+ }
+ // if empty titles slice was 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.
+ 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)
+ }
+ parsedOutput[title] = values
+ }
+ return parsedOutput, nil
+}
diff --git a/src/pkg/nodeprofiler/utils/parsing_test.go b/src/pkg/nodeprofiler/utils/parsing_test.go
new file mode 100644
index 0000000..73ff963
--- /dev/null
+++ b/src/pkg/nodeprofiler/utils/parsing_test.go
@@ -0,0 +1,184 @@
+package utils
+
+import (
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+func TestParseColumns(t *testing.T) {
+ tests := []struct {
+ name string
+ rows []string
+ titles []string
+ want map[string][]string
+ wantErr bool
+ }{
+
+ {
+ name: "basic",
+ 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"},
+ want: map[string][]string{
+ "us": {"1", "2", "7"},
+ "sy": {"0", "1", "3"},
+ "st": {"0", "1", "0"},
+ },
+ },
+ {
+ 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{},
+ },
+ {
+ name: "empty titles",
+ rows: []string{
+ "r b swpd free buff",
+ "5 0 0 14827096 0",
+ },
+ want: map[string][]string{
+ "r": {"5"},
+ "b": {"0"},
+ "swpd": {"0"},
+ "free": {"14827096"},
+ "buff": {"0"},
+ },
+ },
+ }
+
+ 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)
+ }
+ 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)
+ }
+ }
+}
+
+func TestParseRows(t *testing.T) {
+ tests := []struct {
+ name string
+ rows []string
+ titles []string
+ want map[string][]string
+ wantErr bool
+ }{
+ {
+ name: "lscpu's output",
+ 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",
+ },
+
+ titles: []string{"CPU(s)"},
+ want: map[string][]string{
+ "CPU(s)": {"8"},
+ },
+ },
+ {
+ name: "spaced rows",
+ rows: []string{
+ "Architecture: x86_64",
+ " ",
+ "CPU op-mode(s): 32-bit, 64-bit",
+ " ",
+ "CPU(s): 8",
+ },
+ titles: []string{"CPU(s)"},
+ want: map[string][]string{
+ "CPU(s)": {"8"},
+ },
+ },
+ {
+ 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{},
+ },
+ {
+ name: "empty titles",
+ rows: []string{
+ "processor: 0",
+ "vendor_id: GenuineIntel",
+ "cpu family: 6",
+ "model: 142",
+ "model name: 06/8e",
+ },
+ titles: []string{},
+ want: map[string][]string{
+ "processor": {"0"},
+ "vendor_id": {"GenuineIntel"},
+ "cpu family": {"6"},
+ "model": {"142"},
+ "model name": {"06/8e"},
+ },
+ },
+ }
+
+ for _, test := range tests {
+ got, err := ParseRows(test.rows, test.titles...)
+ if gotErr := err != nil; gotErr != test.wantErr {
+ t.Fatalf("ParseRows(%v, %v) = %q, wantErr: %v", 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)
+ }
+ }
+}
diff --git a/src/pkg/nodeprofiler/utils/utils.go b/src/pkg/nodeprofiler/utils/utils.go
index 8b07b71..a41873d 100644
--- a/src/pkg/nodeprofiler/utils/utils.go
+++ b/src/pkg/nodeprofiler/utils/utils.go
@@ -1,74 +1,18 @@
-// Package utils defines common structs used by the COS Node Profiler Agent
+// Package utils defines parsing and run command functions
+// that can be used outside nodeprofiler.
package utils
-import "time"
+import (
+ "fmt"
+ "os/exec"
+)
-// USEMetrics contain the USE metrics (utilization, saturation, errors)
-// for a particular component of the system.
-type USEMetrics struct {
- // Timestamp refers to the point in time when the USE metrics for this
- // component was collected.
- Timestamp time.Time
- // Interval refers to the time interval for which the USE metrics for this
- // component was collected.
- Interval time.Duration
- // Utilization is the percent over a time interval for which the resource
- // was busy servicing work.
- Utilization float64
- // Saturation is the degree to which the resource has extra work which it
- // can’t service. The value for Saturation has different meanings
- // depending on the component being analyzed. But for simplicity sake
- // Saturation here is just a bool which tells us whether this specific
- // component is saturated or not.
- Saturation bool
- // Errors is the number of errors seen in the component over a given
- // time interval.
- Errors int64
-}
-
-// USEReport contains the USE Report from a single run of the node profiler.
-// The USE Report contains helpful information to help diagnose performance
-// issues seen by customers on their k8s clusters.
-type USEReport struct {
- // Components contains the USE Metrics for each component of the system.
- // Such components include CPU, memory, network, storage, etc.
- Components []USEMetrics
- // Analysis provides insights into the USE metrics collected, including
- // a guess as to which component may be causing performance issues.
- Analysis string
-}
-
-// ProfilerReport contains debugging information provided by the profiler
-// tool. Currently, it will only provide USEMetrics (Utilization,
-// Saturation, Errors), kernel trace outputs, and the outputs of
-// arbitrary shell commands provided by the user.
-// In future, we can add following different types of dynamic reports:
-//
-// type PerfReport - Captures perf command output
-// type STraceReport - Captures strace output
-// type BPFTraceReport - Allows users to add eBPF hooks and capture its
-// output
-type ProfilerReport struct {
- // Static reports
- // USEMetrics provides Utilization, Saturation and Errors for different
- // components on the system
- USEInfo USEReport
- // RawCommands captures the output of arbitrary shell commands provided
- // by the user. Example usage: count # of systemd units; count # of
- // cgroups
- RawCommands map[string][]byte
- // Dynamic tracing reports
- // KernelTraces captures the output of the ftrace command. The key is the
- // kernel trace point and the value is the output
- KernelTraces map[string][]byte
-}
-
-// ProfilerConfig tells the profiler which dynamic reports it should
-// generate and capture.
-type ProfilerConfig struct {
- // KernelTracePoints are the trace points we should insert and capture.
- KernelTracePoints []string
- // RawCommands are the shell commands that we should run and capture
- // output.
- RawCommands []string
+// 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) {
+ out, err := exec.Command(cmd, args...).CombinedOutput()
+ if err != nil {
+ return nil, fmt.Errorf("failed to run %s, %v: %v", cmd, args, err)
+ }
+ return out, nil
}