2016-02-22 18:52:20 +00:00
/ *
2016-06-03 00:25:58 +00:00
Copyright 2016 The Kubernetes Authors .
2016-02-22 18:52:20 +00:00
Licensed under the Apache License , Version 2.0 ( the "License" ) ;
you may not use this file except in compliance with the License .
You may obtain a copy of the License at
http : //www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing , software
distributed under the License is distributed on an "AS IS" BASIS ,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND , either express or implied .
See the License for the specific language governing permissions and
limitations under the License .
* /
2016-08-12 06:30:04 +00:00
package remote
2016-02-22 18:52:20 +00:00
import (
"flag"
"fmt"
"io/ioutil"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
2016-06-04 00:50:21 +00:00
"sync"
2016-08-20 04:39:45 +00:00
"time"
2016-02-22 18:52:20 +00:00
"github.com/golang/glog"
2016-05-23 20:16:47 +00:00
utilerrors "k8s.io/kubernetes/pkg/util/errors"
2016-10-24 17:46:07 +00:00
"k8s.io/kubernetes/test/e2e_node/builder"
2016-02-22 18:52:20 +00:00
)
var sshOptions = flag . String ( "ssh-options" , "" , "Commandline options passed to ssh." )
var sshEnv = flag . String ( "ssh-env" , "" , "Use predefined ssh options for environment. Options: gce" )
2016-08-20 04:39:45 +00:00
var testTimeoutSeconds = flag . Duration ( "test-timeout" , 45 * time . Minute , "How long (in golang duration format) to wait for ginkgo tests to complete." )
2016-05-23 20:16:47 +00:00
var resultsDir = flag . String ( "results-dir" , "/tmp/" , "Directory to scp test results to." )
2016-02-22 18:52:20 +00:00
var sshOptionsMap map [ string ] string
2016-06-29 17:15:33 +00:00
const (
2016-10-22 00:36:17 +00:00
archiveName = "e2e_node_test.tar.gz"
CNIRelease = "07a8a28637e97b22eb8dfe710eeae1344f69d16e"
CNIDirectory = "cni"
2016-06-29 17:15:33 +00:00
)
2016-07-05 23:49:47 +00:00
var CNIURL = fmt . Sprintf ( "https://storage.googleapis.com/kubernetes-release/network-plugins/cni-%s.tar.gz" , CNIRelease )
2016-02-22 18:52:20 +00:00
2016-06-04 00:50:21 +00:00
var hostnameIpOverrides = struct {
sync . RWMutex
m map [ string ] string
} { m : make ( map [ string ] string ) }
2016-02-22 18:52:20 +00:00
func init ( ) {
usr , err := user . Current ( )
if err != nil {
glog . Fatal ( err )
}
sshOptionsMap = map [ string ] string {
2016-05-24 23:54:24 +00:00
"gce" : fmt . Sprintf ( "-i %s/.ssh/google_compute_engine -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o CheckHostIP=no -o StrictHostKeyChecking=no -o ServerAliveInterval=30 -o LogLevel=ERROR" , usr . HomeDir ) ,
2016-02-22 18:52:20 +00:00
}
}
2016-06-04 00:50:21 +00:00
func AddHostnameIp ( hostname , ip string ) {
hostnameIpOverrides . Lock ( )
defer hostnameIpOverrides . Unlock ( )
hostnameIpOverrides . m [ hostname ] = ip
}
func GetHostnameOrIp ( hostname string ) string {
hostnameIpOverrides . RLock ( )
defer hostnameIpOverrides . RUnlock ( )
if ip , found := hostnameIpOverrides . m [ hostname ] ; found {
return ip
}
return hostname
}
2016-02-22 18:52:20 +00:00
// CreateTestArchive builds the local source and creates a tar archive e2e_node_test.tar.gz containing
// the binaries k8s required for node e2e tests
2016-06-04 00:50:21 +00:00
func CreateTestArchive ( ) ( string , error ) {
2016-02-22 18:52:20 +00:00
// Build the executables
2016-10-24 17:46:07 +00:00
if err := builder . BuildGo ( ) ; err != nil {
2016-08-17 01:02:43 +00:00
return "" , fmt . Errorf ( "failed to build the depedencies: %v" , err )
}
2016-02-22 18:52:20 +00:00
2016-05-31 16:35:10 +00:00
// Make sure we can find the newly built binaries
2016-10-24 17:46:07 +00:00
buildOutputDir , err := builder . GetK8sBuildOutputDir ( )
2016-02-22 18:52:20 +00:00
if err != nil {
2016-08-17 01:02:43 +00:00
return "" , fmt . Errorf ( "failed to locate kubernetes build output directory %v" , err )
2016-02-22 18:52:20 +00:00
}
2016-05-31 16:35:10 +00:00
2016-02-22 18:52:20 +00:00
glog . Infof ( "Building archive..." )
tardir , err := ioutil . TempDir ( "" , "node-e2e-archive" )
if err != nil {
2016-06-04 00:50:21 +00:00
return "" , fmt . Errorf ( "failed to create temporary directory %v." , err )
2016-02-22 18:52:20 +00:00
}
defer os . RemoveAll ( tardir )
// Copy binaries
2016-08-15 02:30:02 +00:00
requiredBins := [ ] string { "kubelet" , "e2e_node.test" , "ginkgo" }
for _ , bin := range requiredBins {
source := filepath . Join ( buildOutputDir , bin )
if _ , err := os . Stat ( source ) ; err != nil {
return "" , fmt . Errorf ( "failed to locate test binary %s: %v" , bin , err )
}
out , err := exec . Command ( "cp" , source , filepath . Join ( tardir , bin ) ) . CombinedOutput ( )
if err != nil {
return "" , fmt . Errorf ( "failed to copy %q: %v Output: %q" , bin , err , out )
}
2016-02-22 18:52:20 +00:00
}
2016-11-04 23:10:57 +00:00
// Include the GCI mounter artifacts in the deployed tarball
2016-10-24 17:46:07 +00:00
k8sDir , err := builder . GetK8sRootDir ( )
2016-10-19 20:50:36 +00:00
if err != nil {
return "" , fmt . Errorf ( "Could not find K8s root dir! Err: %v" , err )
}
localSource := "cluster/gce/gci/mounter/mounter"
source := filepath . Join ( k8sDir , localSource )
// Require the GCI mounter script, we want to make sure the remote test runner stays up to date if the mounter file moves
if _ , err := os . Stat ( source ) ; err != nil {
return "" , fmt . Errorf ( "Could not find GCI mounter script at %q! If this script has been (re)moved, please update the e2e node remote test runner accordingly! Err: %v" , source , err )
}
bindir := "cluster/gce/gci/mounter"
bin := "mounter"
destdir := filepath . Join ( tardir , bindir )
dest := filepath . Join ( destdir , bin )
out , err := exec . Command ( "mkdir" , "-p" , filepath . Join ( tardir , bindir ) ) . CombinedOutput ( )
if err != nil {
return "" , fmt . Errorf ( "failed to create directory %q for GCI mounter script. Err: %v. Output:\n%s" , destdir , err , out )
}
out , err = exec . Command ( "cp" , source , dest ) . CombinedOutput ( )
if err != nil {
return "" , fmt . Errorf ( "failed to copy GCI mounter script to the archive bin. Err: %v. Output:\n%s" , err , out )
}
2016-02-22 18:52:20 +00:00
// Build the tar
2016-10-19 20:50:36 +00:00
out , err = exec . Command ( "tar" , "-zcvf" , archiveName , "-C" , tardir , "." ) . CombinedOutput ( )
2016-02-22 18:52:20 +00:00
if err != nil {
2016-06-04 00:50:21 +00:00
return "" , fmt . Errorf ( "failed to build tar %v. Output:\n%s" , err , out )
2016-02-22 18:52:20 +00:00
}
dir , err := os . Getwd ( )
if err != nil {
2016-06-04 00:50:21 +00:00
return "" , fmt . Errorf ( "failed to get working directory %v." , err )
2016-02-22 18:52:20 +00:00
}
2016-06-04 00:50:21 +00:00
return filepath . Join ( dir , archiveName ) , nil
2016-02-22 18:52:20 +00:00
}
2016-05-27 22:40:49 +00:00
// Returns the command output, whether the exit was ok, and any errors
2016-08-19 21:40:23 +00:00
func RunRemote ( archive string , host string , cleanup bool , junitFilePrefix string , setupNode bool , testArgs string , ginkgoFlags string ) ( string , bool , error ) {
2016-05-26 22:21:38 +00:00
if setupNode {
uname , err := user . Current ( )
if err != nil {
return "" , false , fmt . Errorf ( "could not find username: %v" , err )
}
2016-08-19 04:50:33 +00:00
output , err := SSH ( host , "usermod" , "-a" , "-G" , "docker" , uname . Username )
2016-05-26 22:21:38 +00:00
if err != nil {
2016-06-04 00:50:21 +00:00
return "" , false , fmt . Errorf ( "instance %s not running docker daemon - Command failed: %s" , host , output )
2016-05-26 22:21:38 +00:00
}
}
2016-02-22 18:52:20 +00:00
// Create the temp staging directory
2016-05-17 01:33:51 +00:00
glog . Infof ( "Staging test binaries on %s" , host )
2016-10-29 05:56:23 +00:00
workspace := fmt . Sprintf ( "/tmp/node-e2e-%s" , getTimestamp ( ) )
2016-08-19 04:50:33 +00:00
// Do not sudo here, so that we can use scp to copy test archive to the directdory.
2016-11-08 02:32:38 +00:00
if output , err := SSHNoSudo ( host , "mkdir" , workspace ) ; err != nil {
2016-05-27 22:40:49 +00:00
// Exit failure with the error
2016-11-08 02:32:38 +00:00
return "" , false , fmt . Errorf ( "failed to create workspace directory: %v output: %q" , err , output )
2016-02-22 18:52:20 +00:00
}
2016-04-18 20:56:07 +00:00
if cleanup {
2016-02-25 16:38:06 +00:00
defer func ( ) {
2016-10-29 05:56:23 +00:00
output , err := SSH ( host , "rm" , "-rf" , workspace )
2016-02-25 16:38:06 +00:00
if err != nil {
2016-10-29 05:56:23 +00:00
glog . Errorf ( "failed to cleanup workspace %s on host %v. Output:\n%s" , workspace , err , output )
2016-02-25 16:38:06 +00:00
}
} ( )
}
2016-02-22 18:52:20 +00:00
2016-07-05 23:49:47 +00:00
// Install the cni plugin.
2016-10-29 05:56:23 +00:00
cniPath := filepath . Join ( workspace , CNIDirectory )
2016-08-19 04:50:33 +00:00
cmd := getSSHCommand ( " ; " ,
fmt . Sprintf ( "mkdir -p %s" , cniPath ) ,
fmt . Sprintf ( "wget -O - %s | tar -xz -C %s" , CNIURL , cniPath ) ,
)
2016-11-08 02:32:38 +00:00
if output , err := SSH ( host , "sh" , "-c" , cmd ) ; err != nil {
2016-07-05 23:49:47 +00:00
// Exit failure with the error
2016-11-08 02:32:38 +00:00
return "" , false , fmt . Errorf ( "failed to install cni plugin: %v output: %q" , err , output )
2016-07-05 23:49:47 +00:00
}
2016-10-04 21:06:25 +00:00
// Configure iptables firewall rules
// TODO: consider calling bootstrap script to configure host based on OS
2016-08-19 04:50:33 +00:00
cmd = getSSHCommand ( "&&" ,
2016-10-04 21:06:25 +00:00
` iptables -L INPUT | grep "Chain INPUT (policy DROP)" ` ,
"(iptables -C INPUT -w -p TCP -j ACCEPT || iptables -A INPUT -w -p TCP -j ACCEPT)" ,
"(iptables -C INPUT -w -p UDP -j ACCEPT || iptables -A INPUT -w -p UDP -j ACCEPT)" ,
"(iptables -C INPUT -w -p ICMP -j ACCEPT || iptables -A INPUT -w -p ICMP -j ACCEPT)" )
2016-08-19 04:50:33 +00:00
output , err := SSH ( host , "sh" , "-c" , cmd )
2016-10-04 21:06:25 +00:00
if err != nil {
glog . Errorf ( "Failed to configured firewall: %v output: %v" , err , output )
}
2016-08-19 04:50:33 +00:00
cmd = getSSHCommand ( "&&" ,
2016-10-04 21:06:25 +00:00
` iptables -L FORWARD | grep "Chain FORWARD (policy DROP)" > /dev/null ` ,
"(iptables -C FORWARD -w -p TCP -j ACCEPT || iptables -A FORWARD -w -p TCP -j ACCEPT)" ,
"(iptables -C FORWARD -w -p UDP -j ACCEPT || iptables -A FORWARD -w -p UDP -j ACCEPT)" ,
"(iptables -C FORWARD -w -p ICMP -j ACCEPT || iptables -A FORWARD -w -p ICMP -j ACCEPT)" )
2016-08-19 04:50:33 +00:00
output , err = SSH ( host , "sh" , "-c" , cmd )
2016-10-04 21:06:25 +00:00
if err != nil {
glog . Errorf ( "Failed to configured firewall: %v output: %v" , err , output )
}
2016-02-22 18:52:20 +00:00
// Copy the archive to the staging directory
2016-11-08 02:32:38 +00:00
if output , err = runSSHCommand ( "scp" , archive , fmt . Sprintf ( "%s:%s/" , GetHostnameOrIp ( host ) , workspace ) ) ; err != nil {
2016-05-27 22:40:49 +00:00
// Exit failure with the error
2016-11-08 02:32:38 +00:00
return "" , false , fmt . Errorf ( "failed to copy test archive: %v, output: %q" , err , output )
2016-02-22 18:52:20 +00:00
}
// Kill any running node processes
2016-08-19 04:50:33 +00:00
cmd = getSSHCommand ( " ; " ,
"pkill kubelet" ,
"pkill kube-apiserver" ,
"pkill etcd" ,
2016-02-24 21:12:42 +00:00
)
2016-02-22 18:52:20 +00:00
// No need to log an error if pkill fails since pkill will fail if the commands are not running.
// If we are unable to stop existing running k8s processes, we should see messages in the kubelet/apiserver/etcd
// logs about failing to bind the required ports.
2016-05-17 01:33:51 +00:00
glog . Infof ( "Killing any existing node processes on %s" , host )
2016-08-19 04:50:33 +00:00
SSH ( host , "sh" , "-c" , cmd )
2016-02-22 18:52:20 +00:00
2016-05-27 22:40:49 +00:00
// Extract the archive
2016-08-19 04:50:33 +00:00
cmd = getSSHCommand ( " && " ,
2016-10-29 05:56:23 +00:00
fmt . Sprintf ( "cd %s" , workspace ) ,
2016-08-19 04:50:33 +00:00
fmt . Sprintf ( "tar -xzvf ./%s" , archiveName ) ,
)
2016-05-27 22:40:49 +00:00
glog . Infof ( "Extracting tar on %s" , host )
2016-11-08 02:32:38 +00:00
if output , err = SSH ( host , "sh" , "-c" , cmd ) ; err != nil {
2016-05-27 22:40:49 +00:00
// Exit failure with the error
2016-11-08 02:32:38 +00:00
return "" , false , fmt . Errorf ( "failed to extract test archive: %v, output: %q" , err , output )
2016-05-27 22:40:49 +00:00
}
2016-10-19 20:50:36 +00:00
// If we are testing on a GCI node, we chmod 544 the mounter and specify a different mounter path in the test args.
2016-10-29 05:56:23 +00:00
// We do this here because the local var `workspace` tells us which /tmp/node-e2e-%d is relevant to the current test run.
2016-10-19 20:50:36 +00:00
// Determine if the GCI mounter script exists locally.
2016-10-24 17:46:07 +00:00
k8sDir , err := builder . GetK8sRootDir ( )
2016-10-19 20:50:36 +00:00
if err != nil {
return "" , false , fmt . Errorf ( "Could not find K8s root dir! Err: %v" , err )
}
localSource := "cluster/gce/gci/mounter/mounter"
source := filepath . Join ( k8sDir , localSource )
// Require the GCI mounter script, we want to make sure the remote test runner stays up to date if the mounter file moves
if _ , err = os . Stat ( source ) ; err != nil {
return "" , false , fmt . Errorf ( "Could not find GCI mounter script at %q! If this script has been (re)moved, please update the e2e node remote test runner accordingly! Err: %v" , source , err )
}
// Determine if tests will run on a GCI node.
2016-08-19 04:50:33 +00:00
output , err = SSH ( host , "sh" , "-c" , "'cat /etc/os-release'" )
2016-10-19 20:50:36 +00:00
if err != nil {
glog . Errorf ( "Issue detecting node's OS via node's /etc/os-release. Err: %v, Output:\n%s" , err , output )
return "" , false , fmt . Errorf ( "Issue detecting node's OS via node's /etc/os-release. Err: %v, Output:\n%s" , err , output )
}
if strings . Contains ( output , "ID=gci" ) {
2016-11-04 23:13:07 +00:00
glog . Infof ( "GCI node and GCI mounter both detected, modifying --experimental-mounter-path accordingly" )
2016-10-19 20:50:36 +00:00
// Note this implicitly requires the script to be where we expect in the tarball, so if that location changes the error
// here will tell us to update the remote test runner.
2016-10-29 05:56:23 +00:00
mounterPath := filepath . Join ( workspace , "cluster/gce/gci/mounter/mounter" )
2016-08-19 04:50:33 +00:00
output , err = SSH ( host , "sh" , "-c" , fmt . Sprintf ( "'chmod 544 %s'" , mounterPath ) )
2016-10-19 20:50:36 +00:00
if err != nil {
glog . Errorf ( "Unable to chmod 544 GCI mounter script. Err: %v, Output:\n%s" , err , output )
return "" , false , err
}
// Insert args at beginning of testArgs, so any values from command line take precedence
2016-11-16 21:40:23 +00:00
testArgs = fmt . Sprintf ( "--kubelet-flags=--experimental-mounter-path=%s " , mounterPath ) + testArgs
2016-10-19 20:50:36 +00:00
}
2016-05-27 22:40:49 +00:00
// Run the tests
2016-08-19 04:50:33 +00:00
cmd = getSSHCommand ( " && " ,
2016-10-29 05:56:23 +00:00
fmt . Sprintf ( "cd %s" , workspace ) ,
2016-08-31 00:46:35 +00:00
fmt . Sprintf ( "timeout -k 30s %fs ./ginkgo %s ./e2e_node.test -- --logtostderr --v 4 --node-name=%s --report-dir=%s/results --report-prefix=%s %s" ,
2016-10-29 05:56:23 +00:00
testTimeoutSeconds . Seconds ( ) , ginkgoFlags , host , workspace , junitFilePrefix , testArgs ) ,
2016-02-24 21:12:42 +00:00
)
2016-05-27 22:40:49 +00:00
aggErrs := [ ] error { }
2016-05-27 17:33:23 +00:00
2016-05-17 01:33:51 +00:00
glog . Infof ( "Starting tests on %s" , host )
2016-08-19 04:50:33 +00:00
output , err = SSH ( host , "sh" , "-c" , cmd )
2016-11-08 02:32:38 +00:00
// Do not log the output here, let the caller deal with the test output.
2016-02-22 18:52:20 +00:00
if err != nil {
2016-05-27 22:40:49 +00:00
aggErrs = append ( aggErrs , err )
2016-05-23 20:16:47 +00:00
2016-11-03 15:17:33 +00:00
// Encountered an unexpected error. The remote test harness may not
// have finished retrieved and stored all the logs in this case. Try
// to get some logs for debugging purposes.
// TODO: This is a best-effort, temporary hack that only works for
// journald nodes. We should have a more robust way to collect logs.
var (
2016-10-29 05:56:23 +00:00
logName = "system.log"
2016-11-23 02:40:30 +00:00
logPath = fmt . Sprintf ( "/tmp/%s-%s" , getTimestamp ( ) , logName )
2016-11-03 15:17:33 +00:00
destPath = fmt . Sprintf ( "%s/%s-%s" , * resultsDir , host , logName )
)
glog . Infof ( "Test failed unexpectedly. Attempting to retreiving system logs (only works for nodes with journald)" )
// Try getting the system logs from journald and store it to a file.
// Don't reuse the original test directory on the remote host because
// it could've be been removed if the node was rebooted.
2016-11-08 02:32:38 +00:00
if output , err := SSH ( host , "sh" , "-c" , fmt . Sprintf ( "'journalctl --system --all > %s'" , logPath ) ) ; err == nil {
2016-11-03 15:17:33 +00:00
glog . Infof ( "Got the system logs from journald; copying it back..." )
2016-11-08 02:32:38 +00:00
if output , err := runSSHCommand ( "scp" , fmt . Sprintf ( "%s:%s" , GetHostnameOrIp ( host ) , logPath ) , destPath ) ; err != nil {
glog . Infof ( "Failed to copy the log: err: %v, output: %q" , err , output )
2016-11-03 15:17:33 +00:00
}
} else {
2016-11-08 02:32:38 +00:00
glog . Infof ( "Failed to run journactl (normal if it doesn't exist on the node): %v, output: %q" , err , output )
2016-11-03 15:17:33 +00:00
}
}
2016-05-27 17:33:23 +00:00
glog . Infof ( "Copying test artifacts from %s" , host )
2016-10-29 05:56:23 +00:00
scpErr := getTestArtifacts ( host , workspace )
2016-05-27 17:33:23 +00:00
if scpErr != nil {
2016-06-30 18:25:40 +00:00
aggErrs = append ( aggErrs , scpErr )
2016-02-22 18:52:20 +00:00
}
2016-06-30 18:25:40 +00:00
return output , len ( aggErrs ) == 0 , utilerrors . NewAggregate ( aggErrs )
2016-02-22 18:52:20 +00:00
}
2016-10-29 05:56:23 +00:00
// timestampFormat is the timestamp format used in the node e2e directory name.
const timestampFormat = "20060102T150405"
func getTimestamp ( ) string {
return fmt . Sprintf ( time . Now ( ) . Format ( timestampFormat ) )
}
2016-05-23 20:16:47 +00:00
func getTestArtifacts ( host , testDir string ) error {
2016-10-29 05:56:23 +00:00
logPath := filepath . Join ( * resultsDir , host )
if err := os . MkdirAll ( logPath , 0755 ) ; err != nil {
return fmt . Errorf ( "failed to create log directory %q: %v" , logPath , err )
}
// Copy logs to artifacts/hostname
_ , err := runSSHCommand ( "scp" , "-r" , fmt . Sprintf ( "%s:%s/results/*.log" , GetHostnameOrIp ( host ) , testDir ) , logPath )
2016-05-23 20:16:47 +00:00
if err != nil {
return err
}
// Copy junit to the top of artifacts
2016-10-29 05:56:23 +00:00
_ , err = runSSHCommand ( "scp" , fmt . Sprintf ( "%s:%s/results/junit*" , GetHostnameOrIp ( host ) , testDir ) , * resultsDir )
2016-05-23 20:16:47 +00:00
if err != nil {
return err
}
return nil
}
2016-08-19 04:50:33 +00:00
// getSSHCommand handles proper quoting so that multiple commands are executed in the same shell over ssh
func getSSHCommand ( sep string , args ... string ) string {
2016-02-22 18:52:20 +00:00
return fmt . Sprintf ( "'%s'" , strings . Join ( args , sep ) )
}
2016-08-19 04:50:33 +00:00
// SSH executes ssh command with runSSHCommand as root. The `sudo` makes sure that all commands
// are executed by root, so that there won't be permission mismatch between different commands.
func SSH ( host string , cmd ... string ) ( string , error ) {
return runSSHCommand ( "ssh" , append ( [ ] string { GetHostnameOrIp ( host ) , "--" , "sudo" } , cmd ... ) ... )
}
// SSHNoSudo executes ssh command with runSSHCommand as normal user. Sometimes we need this,
// for example creating a directory that we'll copy files there with scp.
func SSHNoSudo ( host string , cmd ... string ) ( string , error ) {
return runSSHCommand ( "ssh" , append ( [ ] string { GetHostnameOrIp ( host ) , "--" } , cmd ... ) ... )
}
// runSSHCommand executes the ssh or scp command, adding the flag provided --ssh-options
func runSSHCommand ( cmd string , args ... string ) ( string , error ) {
2016-02-22 18:52:20 +00:00
if env , found := sshOptionsMap [ * sshEnv ] ; found {
args = append ( strings . Split ( env , " " ) , args ... )
}
if * sshOptions != "" {
args = append ( strings . Split ( * sshOptions , " " ) , args ... )
}
output , err := exec . Command ( cmd , args ... ) . CombinedOutput ( )
if err != nil {
2016-11-08 02:32:38 +00:00
return string ( output ) , fmt . Errorf ( "command [%s %s] failed with error: %v" , cmd , strings . Join ( args , " " ) , err )
2016-02-22 18:52:20 +00:00
}
2016-11-08 02:32:38 +00:00
return string ( output ) , nil
2016-02-22 18:52:20 +00:00
}