github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/worker/uniter/runner/debug/server_test.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 "os" 10 "path/filepath" 11 "regexp" 12 "strings" 13 "time" 14 15 jc "github.com/juju/testing/checkers" 16 gc "gopkg.in/check.v1" 17 18 "github.com/juju/juju/testing" 19 ) 20 21 type DebugHooksServerSuite struct { 22 testing.BaseSuite 23 ctx *HooksContext 24 fakebin string 25 tmpdir string 26 } 27 28 var _ = gc.Suite(&DebugHooksServerSuite{}) 29 30 // echocommand outputs its name and arguments to stdout for verification, 31 // and exits with the value of $EXIT_CODE 32 var echocommand = `#!/bin/bash --norc 33 echo $(basename $0) $@ 34 exit $EXIT_CODE 35 ` 36 37 var fakecommands = []string{"sleep", "tmux"} 38 39 func (s *DebugHooksServerSuite) SetUpTest(c *gc.C) { 40 s.fakebin = c.MkDir() 41 42 // Create a clean $TMPDIR for the debug hooks scripts. 43 s.tmpdir = filepath.Join(c.MkDir(), "debug-hooks") 44 err := os.RemoveAll(s.tmpdir) 45 c.Assert(err, jc.ErrorIsNil) 46 err = os.MkdirAll(s.tmpdir, 0755) 47 c.Assert(err, jc.ErrorIsNil) 48 49 s.PatchEnvPathPrepend(s.fakebin) 50 s.PatchEnvironment("TMPDIR", s.tmpdir) 51 s.PatchEnvironment("TEST_RESULT", "") 52 for _, name := range fakecommands { 53 err := os.WriteFile(filepath.Join(s.fakebin, name), []byte(echocommand), 0777) 54 c.Assert(err, jc.ErrorIsNil) 55 } 56 s.ctx = NewHooksContext("foo/8") 57 s.ctx.FlockDir = c.MkDir() 58 s.PatchEnvironment("JUJU_UNIT_NAME", s.ctx.Unit) 59 } 60 61 func (s *DebugHooksServerSuite) TestFindSession(c *gc.C) { 62 // Test "tmux has-session" failure. The error 63 // message is the output of tmux has-session. 64 _ = os.Setenv("EXIT_CODE", "1") 65 session, err := s.ctx.FindSession() 66 c.Assert(session, gc.IsNil) 67 c.Assert(err, gc.ErrorMatches, regexp.QuoteMeta("tmux has-session -t "+s.ctx.Unit+"\n")) 68 _ = os.Setenv("EXIT_CODE", "") 69 70 // tmux session exists, but missing debug-hooks file: error. 71 session, err = s.ctx.FindSession() 72 c.Assert(session, gc.IsNil) 73 c.Assert(err, gc.NotNil) 74 c.Assert(err, jc.Satisfies, os.IsNotExist) 75 76 // Hooks file is present, empty. 77 err = os.WriteFile(s.ctx.ClientFileLock(), []byte{}, 0777) 78 c.Assert(err, jc.ErrorIsNil) 79 session, err = s.ctx.FindSession() 80 c.Assert(session, gc.NotNil) 81 c.Assert(err, jc.ErrorIsNil) 82 // If session.hooks is empty, it'll match anything. 83 c.Assert(session.MatchHook(""), jc.IsTrue) 84 c.Assert(session.MatchHook("something"), jc.IsTrue) 85 86 // Hooks file is present, non-empty 87 err = os.WriteFile(s.ctx.ClientFileLock(), []byte(`hooks: [foo, bar, baz]`), 0777) 88 c.Assert(err, jc.ErrorIsNil) 89 session, err = s.ctx.FindSession() 90 c.Assert(session, gc.NotNil) 91 c.Assert(err, jc.ErrorIsNil) 92 // session should only match "foo", "bar" or "baz". 93 c.Assert(session.MatchHook(""), jc.IsFalse) 94 c.Assert(session.MatchHook("something"), jc.IsFalse) 95 c.Assert(session.MatchHook("foo"), jc.IsTrue) 96 c.Assert(session.MatchHook("bar"), jc.IsTrue) 97 c.Assert(session.MatchHook("baz"), jc.IsTrue) 98 c.Assert(session.MatchHook("foo bar baz"), jc.IsFalse) 99 c.Assert(session.DebugAt(), gc.Equals, "") 100 } 101 102 func (s *DebugHooksServerSuite) TestRunHookExceptional(c *gc.C) { 103 err := os.WriteFile(s.ctx.ClientFileLock(), []byte{}, 0777) 104 c.Assert(err, jc.ErrorIsNil) 105 session, err := s.ctx.FindSession() 106 c.Assert(session, gc.NotNil) 107 c.Assert(err, jc.ErrorIsNil) 108 109 flockAcquired := make(chan struct{}, 1) 110 waitForFlock := func() { 111 select { 112 case <-flockAcquired: 113 case <-time.After(testing.ShortWait): 114 c.Fatalf("timed out waiting for hook to acquire flock") 115 } 116 } 117 118 // Run the hook in debug mode with no exit flock held. 119 // The exit flock will be acquired immediately, and the 120 // debug-hooks server process killed. 121 s.PatchValue(&waitClientExit, func(*ServerSession) { 122 flockAcquired <- struct{}{} 123 }) 124 err = session.RunHook("myhook", s.tmpdir, os.Environ(), "myhook") 125 c.Assert(err, gc.ErrorMatches, "signal: [kK]illed") 126 waitForFlock() 127 128 // Run the hook in debug mode, simulating the holding 129 // of the exit flock. This simulates the client process 130 // starting but not cleanly exiting (normally the .pid 131 // file is updated, and the server waits on the client 132 // process' death). 133 ch := make(chan bool) // acquire the flock 134 var clientExited bool 135 s.PatchValue(&waitClientExit, func(*ServerSession) { 136 clientExited = <-ch 137 flockAcquired <- struct{}{} 138 }) 139 go func() { ch <- true }() // asynchronously release the flock 140 err = session.RunHook("myhook", s.tmpdir, os.Environ(), "myhook") 141 waitForFlock() 142 c.Assert(clientExited, jc.IsTrue) 143 c.Assert(err, gc.ErrorMatches, "signal: [kK]illed") 144 } 145 146 func (s *DebugHooksServerSuite) TestRunHook(c *gc.C) { 147 const hookName = "myhook" 148 // JUJU_DISPATCH_PATH is written in context.HookVars and not part of 149 // what's being tested here. 150 s.PatchEnvironment("JUJU_DISPATCH_PATH", "hooks/"+hookName) 151 err := os.WriteFile(s.ctx.ClientFileLock(), []byte{}, 0777) 152 c.Assert(err, jc.ErrorIsNil) 153 var output bytes.Buffer 154 session, err := s.ctx.FindSessionWithWriter(&output) 155 c.Assert(session, gc.NotNil) 156 c.Assert(err, jc.ErrorIsNil) 157 158 flockRequestCh := make(chan chan struct{}) 159 s.PatchValue(&waitClientExit, func(*ServerSession) { 160 <-<-flockRequestCh 161 }) 162 defer close(flockRequestCh) 163 164 runHookCh := make(chan error) 165 go func() { 166 runHookCh <- session.RunHook(hookName, s.tmpdir, os.Environ(), hookName) 167 }() 168 169 flockCh := make(chan struct{}) 170 select { 171 case flockRequestCh <- flockCh: 172 case <-time.After(testing.LongWait): 173 c.Fatal("timed out waiting for flock to be requested") 174 } 175 defer close(flockCh) 176 177 // Look for the debug hooks temporary dir, inside $TMPDIR. 178 entries, err := os.ReadDir(s.tmpdir) 179 if err != nil { 180 c.Fatalf("Failed to read $TMPDIR: %s", err) 181 } 182 c.Assert(entries, gc.HasLen, 1) 183 c.Assert(entries[0].IsDir(), jc.IsTrue) 184 c.Assert(strings.HasPrefix(entries[0].Name(), "juju-debug-hooks-"), jc.IsTrue) 185 186 debugDir := filepath.Join(s.tmpdir, entries[0].Name()) 187 hookScript := filepath.Join(debugDir, "hook.sh") 188 _, err = os.Stat(hookScript) 189 c.Assert(err, jc.ErrorIsNil) 190 191 // Check that the debug hooks script exports the environment, 192 // and the values are as expected. When RunHook completes, 193 // it removes the temporary directory in which the scripts 194 // reside; so we must wait for it to be written before we 195 // wait for RunHook to return. 196 timeout := time.After(testing.LongWait) 197 envsh := filepath.Join(debugDir, "env.sh") 198 for { 199 // Wait for env.sh to show up, and have some content. If it exists and 200 // is size 0, we managed to see it at exactly the time it is being 201 // written. 202 if st, err := os.Stat(envsh); err == nil { 203 if st.Size() != 0 { 204 break 205 } 206 } 207 select { 208 case <-time.After(time.Millisecond): 209 case <-timeout: 210 c.Fatal("timed out waiting for env.sh to be written") 211 } 212 } 213 s.verifyEnvshFile(c, envsh, hookName) 214 215 // Write the hook.pid file, causing the debug hooks script to exit. 216 hookpid := filepath.Join(debugDir, "hook.pid") 217 err = os.WriteFile(hookpid, []byte("not a pid"), 0777) 218 c.Assert(err, jc.ErrorIsNil) 219 220 // RunHook should complete without waiting to be 221 // killed, and despite the exit lock being held. 222 select { 223 case err := <-runHookCh: 224 c.Assert(err, jc.ErrorIsNil) 225 case <-time.After(testing.LongWait): 226 c.Fatal("RunHook did not complete") 227 } 228 } 229 230 func (s *DebugHooksServerSuite) TestRunHookDebugAt(c *gc.C) { 231 s.fakeTmux(c) 232 s.fakeJujuLog(c) 233 err := os.WriteFile(s.ctx.ClientFileLock(), []byte("debug-at: all\n"), 0777) 234 c.Assert(err, jc.ErrorIsNil) 235 var output bytes.Buffer 236 session, err := s.ctx.FindSessionWithWriter(&output) 237 c.Assert(session, gc.NotNil) 238 c.Assert(err, jc.ErrorIsNil) 239 c.Check(session.DebugAt(), gc.Equals, "all") 240 241 flockAcquired := make(chan struct{}, 0) 242 waitForFlock := func() { 243 select { 244 case <-flockAcquired: 245 case <-time.After(testing.ShortWait): 246 c.Fatalf("timed out waiting for hook to acquire flock") 247 } 248 } 249 s.PatchValue(&waitClientExit, func(*ServerSession) { 250 flockAcquired <- struct{}{} 251 }) 252 const hookName = "myhook" 253 hookRunner := s.tmpdir + "/" + hookName 254 err = os.WriteFile(hookRunner, []byte(`#!/bin/bash --norc 255 echo ran hook >&2 256 `), 0777) 257 c.Assert(err, jc.ErrorIsNil) 258 259 env := os.Environ() 260 env = append(env, "JUJU_DISPATCH_PATH=hooks/"+hookName) 261 env = append(env, "JUJU_HOOK_NAME="+hookName) 262 err = session.RunHook(hookName, s.tmpdir, env, hookRunner) 263 waitForFlock() // Close the goroutine that was spawned to ensure cleanup 264 265 c.Check(output.String(), gc.Equals, 266 fmt.Sprintf(`--log-level INFO debug running %s for myhook 267 ran hook 268 `, hookRunner)) 269 c.Assert(err, jc.ErrorIsNil) 270 } 271 272 func (s *DebugHooksServerSuite) TestRunHookDebugAtNoHook(c *gc.C) { 273 // see that if the hook doesn't actually exist, we exit gracefully rather than error 274 const hookName = "no-hook" 275 s.fakeTmux(c) 276 s.fakeJujuLog(c) 277 err := os.WriteFile(s.ctx.ClientFileLock(), []byte("debug-at: all\n"), 0777) 278 c.Assert(err, jc.ErrorIsNil) 279 var output bytes.Buffer 280 session, err := s.ctx.FindSessionWithWriter(&output) 281 c.Assert(session, gc.NotNil) 282 c.Assert(err, jc.ErrorIsNil) 283 c.Check(session.DebugAt(), gc.Equals, "all") 284 285 flockAcquired := make(chan struct{}, 0) 286 waitForFlock := func() { 287 select { 288 case <-flockAcquired: 289 case <-time.After(testing.ShortWait): 290 c.Fatalf("timed out waiting for hook to acquire flock") 291 } 292 } 293 s.PatchValue(&waitClientExit, func(*ServerSession) { 294 flockAcquired <- struct{}{} 295 }) 296 env := os.Environ() 297 env = append(env, "JUJU_DISPATCH_PATH=hooks/"+hookName) 298 env = append(env, "JUJU_HOOK_NAME="+hookName) 299 err = session.RunHook(hookName, s.tmpdir, env, "") 300 waitForFlock() // Close the goroutine that was spawned to ensure cleanup 301 302 // RunHook should complete once we finish running the hook.sh 303 c.Check(output.String(), gc.Equals, 304 "--log-level INFO debugging is enabled, but no handler for no-hook, skipping\n") 305 c.Assert(err, jc.ErrorIsNil) 306 } 307 308 func (s *DebugHooksServerSuite) verifyEnvshFile(c *gc.C, envshPath string, hookName string) { 309 data, err := os.ReadFile(envshPath) 310 c.Assert(err, jc.ErrorIsNil) 311 contents := string(data) 312 c.Assert(contents, jc.Contains, fmt.Sprintf("JUJU_UNIT_NAME=%q", s.ctx.Unit)) 313 c.Assert(contents, jc.Contains, fmt.Sprintf(`PS1="%s:hooks/%s %% "`, s.ctx.Unit, hookName)) 314 } 315 316 // fakeTmux installs a script that will respond to has-session and new-window 317 func (s *DebugHooksServerSuite) fakeTmux(c *gc.C) { 318 err := os.WriteFile(filepath.Join(s.fakebin, "tmux"), []byte(`#!/bin/bash --norc 319 case "$1" in 320 has-session) 321 # yes, we have the session 322 exit 0 323 ;; 324 new-window) 325 # echo "running: ${@: -1}" >&2 326 # cat ${@: -1} >&2 327 exec "${@: -1}" 328 ;; 329 esac 330 exit 1`), 0777) 331 c.Assert(err, jc.ErrorIsNil) 332 } 333 334 // fakeJujuLog installs a script that echos its arguments to stderr, 335 // ending up in the subprocess output 336 func (s *DebugHooksServerSuite) fakeJujuLog(c *gc.C) { 337 err := os.WriteFile(filepath.Join(s.fakebin, "juju-log"), []byte(`#!/bin/bash --norc 338 echo "$@" >&2 339 `), 0777) 340 c.Assert(err, jc.ErrorIsNil) 341 } 342 343 // DebugSuite is for tests of methods/functions that don't need complex setup. 344 type DebugSuite struct { 345 testing.BaseSuite 346 } 347 348 var _ = gc.Suite(&DebugSuite{}) 349 350 func checkBuildRunHookCommand(c *gc.C, expected, hookName, hookRunner, charmDir string) { 351 c.Check(expected, gc.Equals, buildRunHookCmd(hookName, hookRunner, charmDir)) 352 } 353 354 func (s *DebugSuite) Test_buildRunHookCmd_legacy(c *gc.C) { 355 checkBuildRunHookCommand(c, "./$JUJU_DISPATCH_PATH", "install", 356 "hooks/install", 357 "/var/lib/juju") 358 checkBuildRunHookCommand(c, "./$JUJU_DISPATCH_PATH", "install", 359 "/var/lib/juju/charm/hooks/install", 360 "/var/lib/juju/charm") 361 } 362 363 func (s *DebugSuite) Test_buildRunHookCmd_dispatch_subdir(c *gc.C) { 364 checkBuildRunHookCommand(c, "./dispatch", "install", 365 "/var/lib/juju/charm/dispatch", 366 "/var/lib/juju/charm/") 367 checkBuildRunHookCommand(c, "./hooks/foo", "install", 368 "/var/lib/juju/charm/hooks/foo", 369 "/var/lib/juju/charm/") 370 } 371 372 func (s *DebugSuite) Test_buildRunHookCmd_dispatch_neigbor(c *gc.C) { 373 checkBuildRunHookCommand(c, "./../../not-charm/dispatch", 374 "install", 375 "/var/lib/juju/not-charm/dispatch", 376 "/var/lib/juju/charm/dispatch") 377 } 378 379 func (s *DebugSuite) Test_buildRunHookCmd_dispatch_relative(c *gc.C) { 380 checkBuildRunHookCommand(c, "./dispatch", 381 "install", 382 "./dispatch", 383 "/var/lib/juju/not-charm/dispatch") 384 }