diff --git a/command/spawn_daemon.go b/command/spawn_daemon.go index 3ca825d41..ea7868be4 100644 --- a/command/spawn_daemon.go +++ b/command/spawn_daemon.go @@ -2,19 +2,19 @@ package command import ( "encoding/json" + "fmt" + "io" "os" + "os/exec" + "strconv" "strings" + "syscall" ) type SpawnDaemonCommand struct { Meta -} - -// Status of executing the user's command. -type SpawnStartStatus struct { - // ErrorMsg will be empty if the user command was started successfully. - // Otherwise it will have an error message. - ErrorMsg string + config *DaemonConfig + exitFile io.WriteCloser } func (c *SpawnDaemonCommand) Help() string { @@ -23,15 +23,15 @@ Usage: nomad spawn-daemon [options] INTERNAL ONLY - Spawns a daemon process optionally inside a cgroup. The required daemon_config is a json - encoding of the DaemonConfig struct containing the isolation configuration and command to run. - SpawnStartStatus is json serialized to Stdout upon running the user command or if any error - prevents its execution. If there is no error, the process waits on the users - command and then json serializes SpawnExitStatus to Stdout after its termination. - -General Options: - - ` + generalOptionsUsage() + Spawns a daemon process by double forking. The required daemon_config is a + json encoding of the DaemonConfig struct containing the isolation + configuration and command to run. SpawnStartStatus is json serialized to + stdout upon running the user command or if any error prevents its execution. + If there is no error, the process waits on the users command. Once the user + command exits, the exit code is written to a file specified in the + daemon_config and this process exits with the same exit status as the user + command. + ` return strings.TrimSpace(helpText) } @@ -40,6 +40,147 @@ func (c *SpawnDaemonCommand) Synopsis() string { return "Spawn a daemon command with configurable isolation." } +// Status of executing the user's command. +type SpawnStartStatus struct { + // The PID of the user's command. + UserPID int + + // ErrorMsg will be empty if the user command was started successfully. + // Otherwise it will have an error message. + ErrorMsg string +} + +// Exit status of the user's command. +type SpawnExitStatus struct { + // The exit code of the user's command. + ExitCode int +} + +// Configuration for the command to start as a daemon. +type DaemonConfig struct { + exec.Cmd + + // The filepath to write the exit status to. + ExitStatusFile string + + // The paths, if not /dev/null, must be either in the tasks root directory + // or in the shared alloc directory. + StdoutFile string + StdinFile string + StderrFile string + + // An optional path specifying the directory to chroot the process in. + Chroot string +} + +// Whether to start the user command or abort. +type TaskStart bool + +// parseConfig reads the DaemonConfig from the passed arguments. If not +// successful, an error is returned. +func (c *SpawnDaemonCommand) parseConfig(args []string) (*DaemonConfig, error) { + flags := c.Meta.FlagSet("spawn-daemon", FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + if err := flags.Parse(args); err != nil { + return nil, fmt.Errorf("failed to parse args: %v", err) + } + + // Check that we got json input. + args = flags.Args() + if len(args) != 1 { + return nil, fmt.Errorf("incorrect number of args; got %v; want 1", len(args)) + } + jsonInput, err := strconv.Unquote(args[0]) + if err != nil { + return nil, fmt.Errorf("Failed to unquote json input: %v", err) + } + + // De-serialize the passed command. + var config DaemonConfig + dec := json.NewDecoder(strings.NewReader(jsonInput)) + if err := dec.Decode(&config); err != nil { + return nil, err + } + + return &config, nil +} + +// configureLogs creates the log files and redirects the process +// stdin/stderr/stdout to them. If unsuccessful, an error is returned. +func (c *SpawnDaemonCommand) configureLogs() error { + stdo, err := os.OpenFile(c.config.StdoutFile, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0666) + if err != nil { + return fmt.Errorf("Error opening file to redirect stdout: %v", err) + } + + stde, err := os.OpenFile(c.config.StderrFile, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0666) + if err != nil { + return fmt.Errorf("Error opening file to redirect stderr: %v", err) + } + + stdi, err := os.OpenFile(c.config.StdinFile, os.O_CREATE|os.O_RDONLY, 0666) + if err != nil { + return fmt.Errorf("Error opening file to redirect stdin: %v", err) + } + + c.config.Cmd.Stdout = stdo + c.config.Cmd.Stderr = stde + c.config.Cmd.Stdin = stdi + return nil +} + +func (c *SpawnDaemonCommand) Run(args []string) int { + var err error + c.config, err = c.parseConfig(args) + if err != nil { + return c.outputStartStatus(err, 1) + } + + // Open the file we will be using to write exit codes to. We do this early + // to ensure that we don't start the user process when we can't capture its + // exit status. + c.exitFile, err = os.OpenFile(c.config.ExitStatusFile, os.O_CREATE|os.O_RDWR, 0666) + if err != nil { + return c.outputStartStatus(fmt.Errorf("Error opening file to store exit status: %v", err), 1) + } + + // Isolate the user process. + if err := c.isolateCmd(); err != nil { + return c.outputStartStatus(err, 1) + } + + // Redirect logs. + if err := c.configureLogs(); err != nil { + return c.outputStartStatus(err, 1) + } + + // Chroot jail the process and set its working directory. + c.configureChroot() + + // Wait to get the start command. + var start TaskStart + dec := json.NewDecoder(os.Stdin) + if err := dec.Decode(&start); err != nil { + return c.outputStartStatus(err, 1) + } + + // Aborted by Nomad process. + if !start { + return 0 + } + + // Spawn the user process. + if err := c.config.Cmd.Start(); err != nil { + return c.outputStartStatus(fmt.Errorf("Error starting user command: %v", err), 1) + } + + // Indicate that the command was started successfully. + c.outputStartStatus(nil, 0) + + // Wait and then output the exit status. + return c.writeExitStatus(c.config.Cmd.Wait()) +} + // outputStartStatus is a helper function that outputs a SpawnStartStatus to // Stdout with the passed error, which may be nil to indicate no error. It // returns the passed status. @@ -51,6 +192,36 @@ func (c *SpawnDaemonCommand) outputStartStatus(err error, status int) int { startStatus.ErrorMsg = err.Error() } + if c.config != nil && c.config.Process == nil { + startStatus.UserPID = c.config.Process.Pid + } + enc.Encode(startStatus) return status } + +// writeExitStatus takes in the error result from calling wait and writes out +// the exit status to a file. It returns the same exit status as the user +// command. +func (c *SpawnDaemonCommand) writeExitStatus(exit error) int { + // Parse the exit code. + exitStatus := &SpawnExitStatus{} + if exit != nil { + // Default to exit code 1 if we can not get the actual exit code. + exitStatus.ExitCode = 1 + + if exiterr, ok := exit.(*exec.ExitError); ok { + if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { + exitStatus.ExitCode = status.ExitStatus() + } + } + } + + if c.exitFile != nil { + enc := json.NewEncoder(c.exitFile) + enc.Encode(exitStatus) + c.exitFile.Close() + } + + return exitStatus.ExitCode +} diff --git a/command/spawn_daemon_darwin.go b/command/spawn_daemon_darwin.go new file mode 100644 index 000000000..f3fe8484a --- /dev/null +++ b/command/spawn_daemon_darwin.go @@ -0,0 +1,4 @@ +package command + +// No chroot on darwin. +func (c *SpawnDaemonCommand) configureChroot() {} diff --git a/command/spawn_daemon_linux.go b/command/spawn_daemon_linux.go index 3e9ceaa3e..512ec645f 100644 --- a/command/spawn_daemon_linux.go +++ b/command/spawn_daemon_linux.go @@ -1,115 +1,16 @@ package command -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "strconv" - "strings" - "syscall" -) +import "syscall" -// Configuration for the command to start as a daemon. -type DaemonConfig struct { - exec.Cmd +// configureChroot enters the user command into a chroot if specified in the +// config and on an OS that supports Chroots. +func (c *SpawnDaemonCommand) configureChroot() { + if len(c.config.Chroot) != 0 { + if c.config.Cmd.SysProcAttr == nil { + c.config.Cmd.SysProcAttr = &syscall.SysProcAttr{} + } - // The paths, if not /dev/null, must be either in the tasks root directory - // or in the shared alloc directory. - StdoutFile string - StdinFile string - StderrFile string - - Chroot string -} - -// Whether to start the user command or abort. -type TaskStart bool - -func (c *SpawnDaemonCommand) Run(args []string) int { - flags := c.Meta.FlagSet("spawn-daemon", FlagSetClient) - flags.Usage = func() { c.Ui.Output(c.Help()) } - - if err := flags.Parse(args); err != nil { - return 1 - } - - // Check that we got json input. - args = flags.Args() - if len(args) != 1 { - c.Ui.Error(c.Help()) - return 1 - } - jsonInput, err := strconv.Unquote(args[0]) - if err != nil { - return c.outputStartStatus(fmt.Errorf("Failed to unquote json input: %v", err), 1) - } - - // De-serialize the passed command. - var cmd DaemonConfig - dec := json.NewDecoder(strings.NewReader(jsonInput)) - if err := dec.Decode(&cmd); err != nil { - return c.outputStartStatus(err, 1) - } - - // Isolate the user process. - if _, err := syscall.Setsid(); err != nil { - return c.outputStartStatus(fmt.Errorf("Failed setting sid: %v", err), 1) - } - - syscall.Umask(0) - - // Redirect logs. - stdo, err := os.OpenFile(cmd.StdoutFile, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0666) - if err != nil { - return c.outputStartStatus(fmt.Errorf("Error opening file to redirect Stdout: %v", err), 1) - } - - stde, err := os.OpenFile(cmd.StderrFile, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0666) - if err != nil { - return c.outputStartStatus(fmt.Errorf("Error opening file to redirect Stderr: %v", err), 1) - } - - stdi, err := os.OpenFile(cmd.StdinFile, os.O_CREATE|os.O_RDONLY, 0666) - if err != nil { - return c.outputStartStatus(fmt.Errorf("Error opening file to redirect Stdin: %v", err), 1) - } - - cmd.Cmd.Stdout = stdo - cmd.Cmd.Stderr = stde - cmd.Cmd.Stdin = stdi - - // Chroot jail the process and set its working directory. - if cmd.Cmd.SysProcAttr == nil { - cmd.Cmd.SysProcAttr = &syscall.SysProcAttr{} - } - - cmd.Cmd.SysProcAttr.Chroot = cmd.Chroot - cmd.Cmd.Dir = "/" - - // Wait to get the start command. - var start TaskStart - dec = json.NewDecoder(os.Stdin) - if err := dec.Decode(&start); err != nil { - return c.outputStartStatus(err, 1) - } - - if !start { - return 0 - } - - // Spawn the user process. - if err := cmd.Cmd.Start(); err != nil { - return c.outputStartStatus(fmt.Errorf("Error starting user command: %v", err), 1) - } - - // Indicate that the command was started successfully. - c.outputStartStatus(nil, 0) - - // Wait and then output the exit status. - if err := cmd.Wait(); err != nil { - return 1 - } - - return 0 + c.config.Cmd.SysProcAttr.Chroot = c.config.Chroot + c.config.Cmd.Dir = "/" + } } diff --git a/command/spawn_daemon_test.go b/command/spawn_daemon_test.go new file mode 100644 index 000000000..5bfd6ad5a --- /dev/null +++ b/command/spawn_daemon_test.go @@ -0,0 +1,48 @@ +package command + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os/exec" + "testing" +) + +type nopCloser struct { + io.ReadWriter +} + +func (n *nopCloser) Close() error { + return nil +} + +func TestSpawnDaemon_WriteExitStatus(t *testing.T) { + // Check if there is python. + path, err := exec.LookPath("python") + if err != nil { + t.Skip("python not detected") + } + + var b bytes.Buffer + daemon := &SpawnDaemonCommand{exitFile: &nopCloser{&b}} + + code := 3 + cmd := exec.Command(path, "./test-resources/exiter.py", fmt.Sprintf("%d", code)) + err = cmd.Run() + actual := daemon.writeExitStatus(err) + if actual != code { + t.Fatalf("writeExitStatus(%v) returned %v; want %v", err, actual, code) + } + + // De-serialize the passed command. + var exitStatus SpawnExitStatus + dec := json.NewDecoder(&b) + if err := dec.Decode(&exitStatus); err != nil { + t.Fatalf("failed to decode exit status: %v", err) + } + + if exitStatus.ExitCode != code { + t.Fatalf("writeExitStatus(%v) wrote exit status %v; want %v", err, exitStatus.ExitCode, code) + } +} diff --git a/command/spawn_daemon_universal.go b/command/spawn_daemon_universal.go deleted file mode 100644 index 5083af5f3..000000000 --- a/command/spawn_daemon_universal.go +++ /dev/null @@ -1,9 +0,0 @@ -// +build !linux - -package command - -import "errors" - -func (c *SpawnDaemonCommand) Run(args []string) int { - return c.outputStartStatus(errors.New("spawn-daemon not supported"), 1) -} diff --git a/command/spawn_daemon_unix.go b/command/spawn_daemon_unix.go new file mode 100644 index 000000000..981e52596 --- /dev/null +++ b/command/spawn_daemon_unix.go @@ -0,0 +1,16 @@ +// +build !windows + +package command + +import "syscall" + +// isolateCmd sets the session id for the process and the umask. +func (c *SpawnDaemonCommand) isolateCmd() error { + if c.config.Cmd.SysProcAttr == nil { + c.config.Cmd.SysProcAttr = &syscall.SysProcAttr{} + } + + c.config.Cmd.SysProcAttr.Setsid = true + syscall.Umask(0) + return nil +} diff --git a/command/spawn_daemon_windows.go b/command/spawn_daemon_windows.go new file mode 100644 index 000000000..bb2d63ed8 --- /dev/null +++ b/command/spawn_daemon_windows.go @@ -0,0 +1,7 @@ +// build !linux !darwin + +package command + +// No isolation on Windows. +func (c *SpawnDaemonCommand) isolateCmd() error { return nil } +func (c *SpawnDaemonCommand) configureChroot() {} diff --git a/command/test-resources/exiter.py b/command/test-resources/exiter.py new file mode 100644 index 000000000..90e66b98c --- /dev/null +++ b/command/test-resources/exiter.py @@ -0,0 +1,3 @@ +import sys + +sys.exit(int(sys.argv[1]))