github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/worker/uniter/runner/debug/server.go (about)

     1  // Copyright 2013 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package debug
     5  
     6  import (
     7  	"bytes"
     8  	"fmt"
     9  	"io"
    10  	"os"
    11  	"os/exec"
    12  	"path/filepath"
    13  	"strings"
    14  
    15  	"github.com/juju/collections/set"
    16  	"github.com/juju/errors"
    17  	"github.com/juju/utils/v3"
    18  	goyaml "gopkg.in/yaml.v2"
    19  )
    20  
    21  // ServerSession represents a "juju debug-hooks" session.
    22  type ServerSession struct {
    23  	*HooksContext
    24  	hooks   set.Strings
    25  	debugAt string
    26  
    27  	output io.Writer
    28  }
    29  
    30  // MatchHook returns true if the specified hook name matches
    31  // the hook specified by the debug-hooks client.
    32  func (s *ServerSession) MatchHook(hookName string) bool {
    33  	return s.hooks.IsEmpty() || s.hooks.Contains(hookName)
    34  }
    35  
    36  // DebugAt returns the location for the charm to stop for debugging, if it is set.
    37  func (s *ServerSession) DebugAt() string {
    38  	return s.debugAt
    39  }
    40  
    41  // waitClientExit executes flock, waiting for the SSH client to exit.
    42  // This is a var so it can be replaced for testing.
    43  var waitClientExit = func(s *ServerSession) {
    44  	path := s.ClientExitFileLock()
    45  	_ = exec.Command("flock", path, "-c", "true").Run()
    46  }
    47  
    48  // RunHook "runs" the hook with the specified name via debug-hooks. The hookRunner
    49  // parameters specifies the name of the binary that users can invoke to handle
    50  // the hook. When using the legacy hook system, hookRunner will be equal to
    51  // the hookName; otherwise, it will point to a script that acts as the dispatcher
    52  // for all hooks/actions.
    53  func (s *ServerSession) RunHook(hookName, charmDir string, env []string, hookRunner string) error {
    54  	debugDir, err := os.MkdirTemp("", "juju-debug-hooks-")
    55  	if err != nil {
    56  		return errors.Trace(err)
    57  	}
    58  	defer func() { _ = os.RemoveAll(debugDir) }()
    59  	help := buildRunHookCmd(hookName, hookRunner, charmDir)
    60  	if err := s.writeDebugFiles(debugDir, help, hookRunner); err != nil {
    61  		return errors.Trace(err)
    62  	}
    63  
    64  	env = utils.Setenv(env, "JUJU_DEBUG="+debugDir)
    65  	if s.debugAt != "" {
    66  		env = utils.Setenv(env, "JUJU_DEBUG_AT="+s.debugAt)
    67  	}
    68  
    69  	cmd := exec.Command("/bin/bash", "-s")
    70  	cmd.Env = env
    71  	cmd.Dir = charmDir
    72  	cmd.Stdin = bytes.NewBufferString(debugHooksServerScript)
    73  	if s.output != nil {
    74  		cmd.Stdout = s.output
    75  		cmd.Stderr = s.output
    76  	}
    77  	if err := cmd.Start(); err != nil {
    78  		return err
    79  	}
    80  	go func(proc *os.Process) {
    81  		// Wait for the SSH client to exit (i.e. release the flock),
    82  		// then kill the server hook process in case the client
    83  		// exited uncleanly.
    84  		waitClientExit(s)
    85  		_ = proc.Kill()
    86  	}(cmd.Process)
    87  	return cmd.Wait()
    88  }
    89  
    90  func buildRunHookCmd(hookName, hookRunner, charmDir string) string {
    91  	if hookName == filepath.Base(hookRunner) {
    92  		return "./$JUJU_DISPATCH_PATH"
    93  	}
    94  	relPath, err := filepath.Rel(charmDir, hookRunner)
    95  	if err == nil {
    96  		return "./" + relPath
    97  	}
    98  	return hookRunner
    99  }
   100  
   101  func (s *ServerSession) writeDebugFiles(debugDir, help, hookRunner string) error {
   102  	// hook.sh does not inherit environment variables,
   103  	// so we must insert the path to the directory
   104  	// containing env.sh for it to source.
   105  	debugHooksHookScript := strings.Replace(strings.Replace(
   106  		debugHooksHookScript,
   107  		"__JUJU_DEBUG__", debugDir, -1,
   108  	), "__JUJU_HOOK_RUNNER__", hookRunner, -1)
   109  
   110  	type file struct {
   111  		filename string
   112  		contents string
   113  		mode     os.FileMode
   114  	}
   115  	files := []file{
   116  		{"welcome.msg", fmt.Sprintf(debugHooksWelcomeMessage, help), 0644},
   117  		{"init.sh", debugHooksInitScript, 0755},
   118  		{"hook.sh", debugHooksHookScript, 0755},
   119  	}
   120  	for _, file := range files {
   121  		if err := os.WriteFile(
   122  			filepath.Join(debugDir, file.filename),
   123  			[]byte(file.contents),
   124  			file.mode,
   125  		); err != nil {
   126  			return errors.Annotatef(err, "writing %q", file.filename)
   127  		}
   128  	}
   129  	return nil
   130  }
   131  
   132  // FindSession attempts to find a debug hooks session for the unit specified
   133  // in the context, and returns a new ServerSession structure for it.
   134  func (c *HooksContext) FindSession() (*ServerSession, error) {
   135  	cmd := exec.Command("tmux", "has-session", "-t", c.tmuxSessionName())
   136  	out, err := cmd.CombinedOutput()
   137  	if err != nil {
   138  		if len(out) != 0 {
   139  			return nil, errors.New(string(out))
   140  		} else {
   141  			return nil, err
   142  		}
   143  	}
   144  	// Parse the debug-hooks file for an optional hook name.
   145  	data, err := os.ReadFile(c.ClientFileLock())
   146  	if err != nil {
   147  		return nil, err
   148  	}
   149  	var args hookArgs
   150  	err = goyaml.Unmarshal(data, &args)
   151  	if err != nil {
   152  		return nil, err
   153  	}
   154  	hooks := set.NewStrings(args.Hooks...)
   155  	session := &ServerSession{HooksContext: c, hooks: hooks, debugAt: args.DebugAt}
   156  	return session, nil
   157  }
   158  
   159  const debugHooksServerScript = `set -e
   160  exec > $JUJU_DEBUG/debug.log >&1
   161  
   162  # Set a useful prompt.
   163  export PS1="$JUJU_UNIT_NAME:$JUJU_DISPATCH_PATH % "
   164  
   165  # Save environment variables and export them for sourcing.
   166  FILTER='^\(LS_COLORS\|LESSOPEN\|LESSCLOSE\|PWD\)='
   167  export | grep -v $FILTER > $JUJU_DEBUG/env.sh
   168  
   169  if [ -z "$JUJU_HOOK_NAME" ] ; then
   170    window_name="$JUJU_DISPATCH_PATH"
   171  else
   172    window_name="$JUJU_HOOK_NAME"
   173  fi
   174  # Since we just use byobu tmux configs without byobu-tmux, we need
   175  # to export this to prevent the TERM being set to empty string.
   176  export BYOBU_TERM=$TERM
   177  tmux new-window -t $JUJU_UNIT_NAME -n $window_name "$JUJU_DEBUG/hook.sh"
   178  
   179  # If we exit for whatever reason, kill the hook shell.
   180  exit_handler() {
   181      if [ -f $JUJU_DEBUG/hook.pid ]; then
   182          kill -9 $(cat $JUJU_DEBUG/hook.pid) 2>/dev/null || true
   183      fi
   184  }
   185  trap exit_handler EXIT
   186  
   187  # Wait for the hook shell to start, and then wait for it to exit.
   188  while [ ! -f $JUJU_DEBUG/hook.pid ]; do
   189      sleep 1
   190  done
   191  HOOK_PID=$(cat $JUJU_DEBUG/hook.pid)
   192  while kill -0 "$HOOK_PID" 2> /dev/null; do
   193      sleep 1
   194  done
   195  typeset -i exitstatus=$(cat $JUJU_DEBUG/hook_exit_status)
   196  exit $exitstatus
   197  `
   198  
   199  const debugHooksWelcomeMessage = `This is a Juju debug-hooks tmux session. Remember:
   200  1. You need to execute hooks/actions manually if you want them to run for trapped events.
   201  2. When you are finished with an event, you can run 'exit' to close the current window and allow Juju to continue processing
   202  new events for this unit without exiting a current debug-session.
   203  3. To run an action or hook and end the debugging session avoiding processing any more events manually, use:
   204  
   205  %s
   206  tmux kill-session -t $JUJU_UNIT_NAME # or, equivalently, CTRL+a d
   207  
   208  4. CTRL+a is tmux prefix.
   209  
   210  More help and info is available in the online documentation:
   211  https://juju.is/docs/olm/debug-charm-hooks
   212  
   213  `
   214  
   215  const debugHooksInitScript = `#!/bin/bash
   216  envsubst < $JUJU_DEBUG/welcome.msg
   217  trap 'echo $? > $JUJU_DEBUG/hook_exit_status' EXIT
   218  `
   219  
   220  // debugHooksHookScript is the shell script that tmux spawns instead of running the normal hook.
   221  // In a debug session, we bring in the environment and record our scripts PID as the
   222  // hook.pid that the rest of the server is waiting for. Without BREAKPOINT, we then exec an
   223  // interactive shell with an init.sh that displays a welcome message and traps its exit code into
   224  // hook_exit_status.
   225  // With JUJU_DEBUG_AT, we just exec the hook directly, and record its exit status before exit.
   226  // It is the responsibility of the code handling JUJU_DEBUG_AT to handle prompting.
   227  const debugHooksHookScript = `#!/bin/bash
   228  . __JUJU_DEBUG__/env.sh
   229  echo $$ > $JUJU_DEBUG/hook.pid
   230  if [ -z "$JUJU_DEBUG_AT" ] ; then
   231  	exec /bin/bash --noprofile --init-file $JUJU_DEBUG/init.sh
   232  elif [ ! -x "__JUJU_HOOK_RUNNER__" ] ; then
   233  	juju-log --log-level INFO "debugging is enabled, but no handler for $JUJU_HOOK_NAME, skipping"
   234  	echo 0 > $JUJU_DEBUG/hook_exit_status
   235  else
   236  	juju-log --log-level INFO "debug running __JUJU_HOOK_RUNNER__ for $JUJU_HOOK_NAME"
   237  	__JUJU_HOOK_RUNNER__
   238  	echo $? > $JUJU_DEBUG/hook_exit_status
   239  fi
   240  `