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  }