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 `