├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app └── Main.hs ├── cron-daemon.cabal ├── stack.yaml └── stack.yaml.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .stack-work 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM frolvlad/alpine-gcc 2 | 3 | RUN apk add --no-cache ghc curl 4 | 5 | RUN curl -L https://github.com/nh2/stack/releases/download/v1.6.5/stack-prerelease-1.9.0.1-x86_64-unofficial-fully-static-musl > /usr/bin/stack 6 | 7 | RUN chmod +x /usr/bin/stack 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Chris Done (c) 2018 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Chris Done nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cron-daemon (Unix-like only) 2 | 3 | Useful for: 4 | 5 | * An easy way to make background services on Linux or OS X using cron, 6 | and make sure it stays running. 7 | * An easy way to run a process and automatically restart it by 8 | re-running the same command (via `--terminate`). 9 | 10 | ## Example with cron 11 | 12 | Using cron, run your program `foo` like this: 13 | 14 | ``` shell 15 | * * * * * /path/to/cron-daemon \ 16 | /path/to/foo \ 17 | --pid /tmp/foo.pid \ 18 | --log /tmp/foo.log \ 19 | --stdout /tmp/foo.stdout.log \ 20 | --stderr /tmp/foo.stderr.log \ 21 | -e PORT=2018 \ # optional, to pass environment variables 22 | --pwd /opt/foo \ 23 | -- some arguments # optional 24 | ``` 25 | 26 | All arguments are optional apart from the program itself. But these 27 | arguments are good for making sure that within cron your program makes 28 | sense. 29 | 30 | The program will be started after one minute. Every minute, cron will 31 | run `cron-daemon` which will check whether the process is running. If 32 | it is, it does nothing. If not, it runs it and writes its process ID 33 | to `foo.pid`. 34 | 35 | Because it's cron, it will ensure the program is running regularly, 36 | and also persist through system restarts or logout. 37 | 38 | Output in `--log foo.log` looks like: 39 | 40 | ``` 41 | INFO: Process ID 43780 not running. 42 | INFO: Launching /usr/local/bin/finance 43 | INFO: Arguments: ["/Users/chris/Finance/statements/"] 44 | INFO: Environment: [("PORT","2018")] 45 | INFO: Successfully launched PID: 44861 46 | ``` 47 | 48 | # Using with stack 49 | 50 | Run with `stack build --file-watch --exec` to re-run your service 51 | whenever a file is changed, for a service `webshow` that I run as 52 | `webshow -d /webshow`: 53 | 54 | stack build --fast --file-watch --exec 'cron-daemon --pid .stack-work/pid --terminate -- webshow -d /webshow' 55 | 56 | We pop the arguments after `--`, and `--pid` in a place that's 57 | probably ignored by git. 58 | 59 | # Help text 60 | 61 | Run `--help`: 62 | 63 | cron-daemon - Run a program as a daemon with cron 64 | 65 | Usage: cron-daemon PROGRAM [--pid FILEPATH] [--log FILEPATH] [--stderr FILEPATH] 66 | [--stdout FILEPATH] [-e|--env NAME=value] [--pwd DIR] 67 | [ARGUMENT] [--debug-log-env] [--terminate] 68 | Run a program as a daemon with cron 69 | 70 | Available options: 71 | PROGRAM Run this program 72 | --pid FILEPATH Write the process ID to this file 73 | --log FILEPATH Log file 74 | --stderr FILEPATH Process stderr file 75 | --stdout FILEPATH Process stdout file 76 | -e,--env NAME=value Environment variable 77 | --pwd DIR Working directory 78 | ARGUMENT Argument for the child process 79 | --debug-log-env Log environment variables in log file (default: 80 | false) 81 | --terminate Terminate the process if it's already running (can be 82 | used for restart/update of binary) 83 | -h,--help Show this help text 84 | 85 | # Building statically 86 | 87 | 88 | Build the docker image 89 | 90 | docker image build . -t cron-daemon 91 | 92 | Run the Haskell build 93 | 94 | docker run --rm -v "$(pwd):$(pwd)" -w "$(pwd)" cron-daemon stack build --allow-different-user --system-ghc --ghc-options="-O0 -static -optl-static" 95 | 96 | Copy the binary listed at the end, e.g. 97 | 98 | $ ldd .stack-work/dist/x86_64-linux/Cabal-2.2.0.1/build/cron-daemon/cron-daemon 99 | not a dynamic executable 100 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE ScopedTypeVariables #-} 2 | -- | Program runner. 3 | 4 | module Main where 5 | 6 | import Control.Exception 7 | import Control.Monad 8 | import qualified Data.Map.Strict as M 9 | import Data.Yaml (decodeFileThrow) 10 | import Options.Applicative 11 | import System.Directory 12 | import System.IO 13 | import System.Posix.Signals 14 | import System.Posix.Types 15 | import System.Process 16 | import Text.Read 17 | 18 | data Config = Config 19 | { configProgram :: FilePath 20 | , configPid :: FilePath 21 | , configLog :: FilePath 22 | , configStderr :: FilePath 23 | , configStdout :: FilePath 24 | , configEnv :: [(String, String)] 25 | , configEnvFile :: Maybe FilePath 26 | , configPwd :: FilePath 27 | , configArgs :: [String] 28 | , configLogEnv :: Bool 29 | , configTerm :: Bool 30 | } deriving (Show) 31 | 32 | sample :: Parser Config 33 | sample = 34 | Config <$> 35 | strArgument (metavar "PROGRAM" <> help "Run this program") <*> 36 | strOption 37 | (long "pid" <> metavar "FILEPATH" <> 38 | help "Write the process ID to this file" <> value "cron-daemon-pid") <*> 39 | strOption (long "log" <> metavar "FILEPATH" <> help "Log file" <> value "/dev/stdout") <*> 40 | strOption (long "stderr" <> metavar "FILEPATH" <> help "Process stderr file" <> value "/dev/stderr") <*> 41 | strOption (long "stdout" <> metavar "FILEPATH" <> help "Process stdout file" <> value "/dev/stdout") <*> 42 | many 43 | (option 44 | (maybeReader (parseEnv)) 45 | (long "env" <> short 'e' <> metavar "NAME=value" <> 46 | help "Environment variable")) <*> 47 | optional (strOption (long "env-file" <> metavar "FILEPATH" <> help "YAML file containing an object of environment variables")) <*> 48 | strOption (long "pwd" <> metavar "DIR" <> help "Working directory" <> value ".") <*> 49 | many 50 | (strArgument (metavar "ARGUMENT" <> help "Argument for the child process")) <*> 51 | flag 52 | False 53 | True 54 | (help "Log environment variables in log file (default: false)" <> 55 | long "debug-log-env") <*> 56 | flag 57 | False 58 | True 59 | (help "Terminate the process if it's already running (can be used for restart/update of binary)" <> 60 | long "terminate") 61 | 62 | parseEnv :: String -> Maybe (String, String) 63 | parseEnv = 64 | \s -> 65 | case break (== '=') s of 66 | (name, val) 67 | | not (null val) && not (null name) -> Just (name, drop 1 val) 68 | | otherwise -> Nothing 69 | 70 | main :: IO () 71 | main = do 72 | config <- execParser opts 73 | start config 74 | where 75 | opts = 76 | info 77 | (sample <**> helper) 78 | (fullDesc <> progDesc "Run a program as a daemon with cron" <> 79 | header "cron-daemon - Run a program as a daemon with cron") 80 | 81 | start :: Config -> IO () 82 | start config = do 83 | pidFileExists <- doesFileExist (configPid config) 84 | if pidFileExists 85 | then do 86 | pidbytes <- readFile (configPid config) 87 | case readMaybe pidbytes of 88 | Just u32 -> do 89 | catch 90 | (do signalProcess 0 (CPid u32) 91 | when 92 | (configTerm config) 93 | (do logInfo 94 | "Terminating the process as requested and re-launching." 95 | signalProcess sigTERM (CPid u32) 96 | launch)) 97 | (\(_ :: SomeException) -> do 98 | logInfo ("Process ID " ++ show u32 ++ " not running.") 99 | launch) 100 | Nothing -> logError "Failed to read process ID as a 32-bit integer!" 101 | else do 102 | logInfo ("PID file does not exist: " ++ configPid config) 103 | launch 104 | where 105 | logInfo line = appendFile (configLog config) ("INFO: " ++ line ++ "\n") 106 | logError line = appendFile (configLog config) ("ERROR: " ++ line ++ "\n") 107 | launch = do 108 | envFromFile <- 109 | case configEnvFile config of 110 | Nothing -> pure mempty 111 | Just fp -> fmap M.toList (decodeFileThrow fp) 112 | logInfo ("Launching " ++ configProgram config) 113 | logInfo ("Arguments: " ++ show (configArgs config)) 114 | when 115 | (configLogEnv config) 116 | (logInfo ("Environment: " ++ show (configEnv config))) 117 | errfile <- openFile (configStderr config) AppendMode 118 | outfile <- openFile (configStdout config) AppendMode 119 | (_, _, _, ph) <- 120 | catch 121 | (createProcess 122 | (proc (configProgram config) (configArgs config)) 123 | { env = Just (configEnv config <> envFromFile) 124 | , std_in = NoStream 125 | , std_out = UseHandle outfile 126 | , std_err = UseHandle errfile 127 | , cwd = Just (configPwd config) 128 | }) 129 | (\(e :: SomeException) -> 130 | logError "Failed to launch process." >> throw e) 131 | mpid <- getPid ph 132 | case mpid of 133 | Just (CPid pid) -> do 134 | writeFile (configPid config) (show pid) 135 | logInfo ("Successfully launched PID: " ++ show pid) 136 | Nothing -> logError "Failed to get process ID!" 137 | -------------------------------------------------------------------------------- /cron-daemon.cabal: -------------------------------------------------------------------------------- 1 | name: cron-daemon 2 | version: 0.0.0 3 | synopsis: Make something run as a daemon to be run via cron 4 | description: Make something run as a daemon to be run via cron 5 | license: BSD3 6 | author: Chris Done 7 | maintainer: chrisdone@gmail.com 8 | copyright: 2018 Chris Done 9 | build-type: Simple 10 | extra-source-files: README.md 11 | cabal-version: >=1.10 12 | 13 | executable cron-daemon 14 | hs-source-dirs: app 15 | main-is: Main.hs 16 | ghc-options: -Wall -threaded -rtsopts -with-rtsopts=-N 17 | build-depends: base 18 | , unix 19 | , process 20 | , optparse-applicative 21 | , directory 22 | , yaml 23 | , network 24 | , containers 25 | default-language: Haskell2010 26 | -------------------------------------------------------------------------------- /stack.yaml: -------------------------------------------------------------------------------- 1 | resolver: nightly-2020-09-18 2 | -------------------------------------------------------------------------------- /stack.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: [] 7 | snapshots: 8 | - completed: 9 | size: 531502 10 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/nightly/2020/9/18.yaml 11 | sha256: edd0f8d6cfbb25109c95f4d6a3126f0ff5b9a03e38b1926e6c5245cf7ed43f49 12 | original: nightly-2020-09-18 13 | --------------------------------------------------------------------------------