vitess.io/vitess@v0.16.2/go/vt/hook/hook.go (about)

     1  /*
     2  Copyright 2019 The Vitess Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package hook
    18  
    19  import (
    20  	"bytes"
    21  	"context"
    22  	"errors"
    23  	"fmt"
    24  	"io"
    25  	"os"
    26  	"os/exec"
    27  	"path"
    28  	"strings"
    29  	"syscall"
    30  	"time"
    31  
    32  	vtenv "vitess.io/vitess/go/vt/env"
    33  	"vitess.io/vitess/go/vt/log"
    34  )
    35  
    36  // Hook is the input structure for this library.
    37  type Hook struct {
    38  	Name       string
    39  	Parameters []string
    40  	ExtraEnv   map[string]string
    41  }
    42  
    43  // HookResult is returned by the Execute method.
    44  type HookResult struct {
    45  	ExitStatus int // HOOK_SUCCESS if it succeeded
    46  	Stdout     string
    47  	Stderr     string
    48  }
    49  
    50  // The hook will return a value between 0 and 255. 0 if it succeeds.
    51  // So we have these additional values here for more information.
    52  const (
    53  	// HOOK_SUCCESS is returned when the hook worked.
    54  	HOOK_SUCCESS = 0
    55  
    56  	// HOOK_DOES_NOT_EXIST is returned when the hook cannot be found.
    57  	HOOK_DOES_NOT_EXIST = -1
    58  
    59  	// HOOK_STAT_FAILED is returned when the hook exists, but stat
    60  	// on it fails.
    61  	HOOK_STAT_FAILED = -2
    62  
    63  	// HOOK_CANNOT_GET_EXIT_STATUS is returned when after
    64  	// execution, we fail to get the exit code for the hook.
    65  	HOOK_CANNOT_GET_EXIT_STATUS = -3
    66  
    67  	// HOOK_INVALID_NAME is returned if a hook has an invalid name.
    68  	HOOK_INVALID_NAME = -4
    69  
    70  	// HOOK_VTROOT_ERROR is returned if VTROOT is not set properly.
    71  	HOOK_VTROOT_ERROR = -5
    72  
    73  	// HOOK_GENERIC_ERROR is returned for unknown errors.
    74  	HOOK_GENERIC_ERROR = -6
    75  
    76  	// HOOK_TIMEOUT_ERROR is returned when a CommandContext has its context
    77  	// become done before the command terminates.
    78  	HOOK_TIMEOUT_ERROR = -7
    79  )
    80  
    81  // WaitFunc is a return type for the Pipe methods.
    82  // It returns the process stderr and an error, if any.
    83  type WaitFunc func() (string, error)
    84  
    85  // NewHook returns a Hook object with the provided name and params.
    86  func NewHook(name string, params []string) *Hook {
    87  	return &Hook{Name: name, Parameters: params}
    88  }
    89  
    90  // NewSimpleHook returns a Hook object with just a name.
    91  func NewSimpleHook(name string) *Hook {
    92  	return &Hook{Name: name}
    93  }
    94  
    95  // NewHookWithEnv returns a Hook object with the provided name, params and ExtraEnv.
    96  func NewHookWithEnv(name string, params []string, env map[string]string) *Hook {
    97  	return &Hook{Name: name, Parameters: params, ExtraEnv: env}
    98  }
    99  
   100  // findHook tries to locate the hook, and returns the exec.Cmd for it.
   101  func (hook *Hook) findHook(ctx context.Context) (*exec.Cmd, int, error) {
   102  	// Check the hook path.
   103  	if strings.Contains(hook.Name, "/") {
   104  		return nil, HOOK_INVALID_NAME, fmt.Errorf("hook cannot contain '/'")
   105  	}
   106  
   107  	// Find our root.
   108  	root, err := vtenv.VtRoot()
   109  	if err != nil {
   110  		return nil, HOOK_VTROOT_ERROR, fmt.Errorf("cannot get VTROOT: %v", err)
   111  	}
   112  
   113  	// See if the hook exists.
   114  	vthook := path.Join(root, "vthook", hook.Name)
   115  	_, err = os.Stat(vthook)
   116  	if err != nil {
   117  		if os.IsNotExist(err) {
   118  			return nil, HOOK_DOES_NOT_EXIST, fmt.Errorf("missing hook %v", vthook)
   119  		}
   120  
   121  		return nil, HOOK_STAT_FAILED, fmt.Errorf("cannot stat hook %v: %v", vthook, err)
   122  	}
   123  
   124  	// Configure the command.
   125  	log.Infof("hook: executing hook: %v %v", vthook, strings.Join(hook.Parameters, " "))
   126  	cmd := exec.CommandContext(ctx, vthook, hook.Parameters...)
   127  	if len(hook.ExtraEnv) > 0 {
   128  		cmd.Env = os.Environ()
   129  		for key, value := range hook.ExtraEnv {
   130  			cmd.Env = append(cmd.Env, key+"="+value)
   131  		}
   132  	}
   133  
   134  	return cmd, HOOK_SUCCESS, nil
   135  }
   136  
   137  // ExecuteContext tries to execute the Hook with the given context and returns a HookResult.
   138  func (hook *Hook) ExecuteContext(ctx context.Context) (result *HookResult) {
   139  	result = &HookResult{}
   140  
   141  	// Find the hook.
   142  	cmd, status, err := hook.findHook(ctx)
   143  	if err != nil {
   144  		result.ExitStatus = status
   145  		result.Stderr = err.Error() + "\n"
   146  		return result
   147  	}
   148  
   149  	// Run it.
   150  	var stdout, stderr bytes.Buffer
   151  	cmd.Stdout = &stdout
   152  	cmd.Stderr = &stderr
   153  
   154  	start := time.Now()
   155  	err = cmd.Run()
   156  	duration := time.Since(start)
   157  
   158  	result.Stdout = stdout.String()
   159  	result.Stderr = stderr.String()
   160  
   161  	defer func() {
   162  		log.Infof("hook: result is %v", result.String())
   163  	}()
   164  
   165  	if err == nil {
   166  		result.ExitStatus = HOOK_SUCCESS
   167  		return result
   168  	}
   169  
   170  	if ctx.Err() != nil && errors.Is(ctx.Err(), context.DeadlineExceeded) {
   171  		// When (exec.Cmd).Run hits a context cancelled, the process is killed via SIGTERM.
   172  		// This means:
   173  		// 	1. cmd.ProcessState.Exited() is false.
   174  		//	2. cmd.ProcessState.ExitCode() is -1.
   175  		// [ref]: https://golang.org/pkg/os/#ProcessState.ExitCode
   176  		//
   177  		// Therefore, we need to catch this error specifically, and set result.ExitStatus to
   178  		// HOOK_TIMEOUT_ERROR, because just using ExitStatus will result in HOOK_DOES_NOT_EXIST,
   179  		// which would be wrong. Since we're already doing some custom handling, we'll also include
   180  		// the amount of time the command was running in the error string, in case that is helpful.
   181  		result.ExitStatus = HOOK_TIMEOUT_ERROR
   182  		result.Stderr += fmt.Sprintf("ERROR: (after %s) %s\n", duration, err)
   183  		return result
   184  	}
   185  
   186  	if cmd.ProcessState != nil && cmd.ProcessState.Sys() != nil {
   187  		result.ExitStatus = cmd.ProcessState.Sys().(syscall.WaitStatus).ExitStatus()
   188  	} else {
   189  		result.ExitStatus = HOOK_CANNOT_GET_EXIT_STATUS
   190  	}
   191  	result.Stderr += "ERROR: " + err.Error() + "\n"
   192  
   193  	return result
   194  }
   195  
   196  // Execute tries to execute the Hook and returns a HookResult.
   197  func (hook *Hook) Execute() (result *HookResult) {
   198  	return hook.ExecuteContext(context.Background())
   199  }
   200  
   201  // ExecuteOptional executes an optional hook, logs if it doesn't
   202  // exist, and returns a printable error.
   203  func (hook *Hook) ExecuteOptional() error {
   204  	hr := hook.Execute()
   205  	switch hr.ExitStatus {
   206  	case HOOK_DOES_NOT_EXIST:
   207  		log.Infof("%v hook doesn't exist", hook.Name)
   208  	case HOOK_VTROOT_ERROR:
   209  		log.Infof("VTROOT not set, so %v hook doesn't exist", hook.Name)
   210  	case HOOK_SUCCESS:
   211  		// nothing to do here
   212  	default:
   213  		return fmt.Errorf("%v hook failed(%v): %v", hook.Name, hr.ExitStatus, hr.Stderr)
   214  	}
   215  	return nil
   216  }
   217  
   218  // ExecuteAsWritePipe will execute the hook as in a Unix pipe,
   219  // directing output to the provided writer. It will return:
   220  // - an io.WriteCloser to write data to.
   221  // - a WaitFunc method to call to wait for the process to exit,
   222  // that returns stderr and the cmd.Wait() error.
   223  // - an error code and an error if anything fails.
   224  func (hook *Hook) ExecuteAsWritePipe(out io.Writer) (io.WriteCloser, WaitFunc, int, error) {
   225  	// Find the hook.
   226  	cmd, status, err := hook.findHook(context.Background())
   227  	if err != nil {
   228  		return nil, nil, status, err
   229  	}
   230  
   231  	// Configure the process's stdin, stdout, and stderr.
   232  	in, err := cmd.StdinPipe()
   233  	if err != nil {
   234  		return nil, nil, HOOK_GENERIC_ERROR, fmt.Errorf("failed to configure stdin: %v", err)
   235  	}
   236  	cmd.Stdout = out
   237  	var stderr bytes.Buffer
   238  	cmd.Stderr = &stderr
   239  
   240  	// Start the process.
   241  	err = cmd.Start()
   242  	if err != nil {
   243  		status = HOOK_CANNOT_GET_EXIT_STATUS
   244  		if cmd.ProcessState != nil && cmd.ProcessState.Sys() != nil {
   245  			status = cmd.ProcessState.Sys().(syscall.WaitStatus).ExitStatus()
   246  		}
   247  		return nil, nil, status, err
   248  	}
   249  
   250  	// And return
   251  	return in, func() (string, error) {
   252  		err := cmd.Wait()
   253  		return stderr.String(), err
   254  	}, HOOK_SUCCESS, nil
   255  }
   256  
   257  // ExecuteAsReadPipe will execute the hook as in a Unix pipe, reading
   258  // from the provided reader. It will return:
   259  // - an io.Reader to read piped data from.
   260  // - a WaitFunc method to call to wait for the process to exit, that
   261  // returns stderr and the Wait() error.
   262  // - an error code and an error if anything fails.
   263  func (hook *Hook) ExecuteAsReadPipe(in io.Reader) (io.Reader, WaitFunc, int, error) {
   264  	// Find the hook.
   265  	cmd, status, err := hook.findHook(context.Background())
   266  	if err != nil {
   267  		return nil, nil, status, err
   268  	}
   269  
   270  	// Configure the process's stdin, stdout, and stderr.
   271  	out, err := cmd.StdoutPipe()
   272  	if err != nil {
   273  		return nil, nil, HOOK_GENERIC_ERROR, fmt.Errorf("failed to configure stdout: %v", err)
   274  	}
   275  	cmd.Stdin = in
   276  	var stderr bytes.Buffer
   277  	cmd.Stderr = &stderr
   278  
   279  	// Start the process.
   280  	err = cmd.Start()
   281  	if err != nil {
   282  		status = HOOK_CANNOT_GET_EXIT_STATUS
   283  		if cmd.ProcessState != nil && cmd.ProcessState.Sys() != nil {
   284  			status = cmd.ProcessState.Sys().(syscall.WaitStatus).ExitStatus()
   285  		}
   286  		return nil, nil, status, err
   287  	}
   288  
   289  	// And return
   290  	return out, func() (string, error) {
   291  		err := cmd.Wait()
   292  		return stderr.String(), err
   293  	}, HOOK_SUCCESS, nil
   294  }
   295  
   296  // String returns a printable version of the HookResult
   297  func (hr *HookResult) String() string {
   298  	result := "result: "
   299  	switch hr.ExitStatus {
   300  	case HOOK_SUCCESS:
   301  		result += "HOOK_SUCCESS"
   302  	case HOOK_DOES_NOT_EXIST:
   303  		result += "HOOK_DOES_NOT_EXIST"
   304  	case HOOK_STAT_FAILED:
   305  		result += "HOOK_STAT_FAILED"
   306  	case HOOK_CANNOT_GET_EXIT_STATUS:
   307  		result += "HOOK_CANNOT_GET_EXIT_STATUS"
   308  	case HOOK_INVALID_NAME:
   309  		result += "HOOK_INVALID_NAME"
   310  	case HOOK_VTROOT_ERROR:
   311  		result += "HOOK_VTROOT_ERROR"
   312  	default:
   313  		result += fmt.Sprintf("exit(%v)", hr.ExitStatus)
   314  	}
   315  	if hr.Stdout != "" {
   316  		result += "\nstdout:\n" + hr.Stdout
   317  	}
   318  	if hr.Stderr != "" {
   319  		result += "\nstderr:\n" + hr.Stderr
   320  	}
   321  	return result
   322  }