legion-extra-0.1.0.3: src/Network/Legion/Config.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{- |
This module contains a common pattern for parsing the legion runtime
settings and application-specific configuration from the command
line. The intent of this module is to implement a very specific and
simple pattern that some people find useful and to get them there
with minimum effort; not to be a flexible solution to every problem
in the domain of configuration, or even to endorse the use of this
pattern if you think you might need something different.
This 'parseArgs' function implements a command line parser that accepts
the usage:
> <your-program-name>
> -c <file> --config=<file> REQUIRED - Specifies the config file.
> -j <host>:<port> --joinTarget=<host>:<port> OPTIONAL - The address of a node in the cluster we want to join.
The config file is a YAML file, which has the following format:
> adminAddr: localhost:8080
> peerAddr: localhost:8022
> joinAddr: localhost:8023
In addition to these bindings, an arbitrary set of additional bindings
may be supplied, and which will be decoded into some 'FromJSON' instance
type of your choosing. (Note that the @yaml@ package is implemented by
transforming the YAML input to JSON and then delegating to @aeson@,
which is why your custom config type must be 'FromJSON', even though
the config file is YAML format.)
After parsing the command line and config file, 'parseArgs' will then
return a tuple containing the legionary runtime settings, the startup
mode that should be used, and custom application configuration.
-}
module Network.Legion.Config (
parseArgs
) where
import Data.Aeson (FromJSON, Value(Object), (.:), parseJSON)
import Data.Default.Class (Default(def))
import Data.List (intercalate)
import Data.List.Split (splitOn)
import Data.String (fromString)
import Data.Yaml (decodeEither)
import Network.Legion (StartupMode(JoinCluster, NewCluster),
RuntimeSettings(RuntimeSettings))
import Network.Socket (SockAddr, addrAddress, getAddrInfo)
import Safe (readMay)
import System.Console.GetOpt (OptDescr(Option), usageInfo, getOpt,
ArgOrder(Permute), ArgDescr(ReqArg))
import System.Environment (getArgs, getProgName)
import qualified Data.ByteString as BS
{- | Parse the command line arguments. -}
parseArgs :: (FromJSON userConfig)
=> IO (RuntimeSettings, StartupMode, userConfig)
parseArgs = do
prog <- getProgName
getOpt Permute options <$> getArgs >>= \case
(opts, [], []) ->
case foldr (.) id opts def of
Opt Nothing _ ->
{- No config file. -}
fail $ "Missing config file: \n\n" ++ usageInfo prog options
Opt (Just configFile) maybeJoinTarget -> do
let
configFail m = fail $
"Couldn't decode YAML config from file "
++ configFile ++ ": " ++ show m
c <- BS.readFile configFile
case (decodeEither c, decodeEither c) of
(Left errMsg, _) -> configFail errMsg
(_, Left errMsg) -> configFail errMsg
(Right runtimeConfig, Right userConfig) -> do
mode <- case maybeJoinTarget of
Nothing -> pure NewCluster
Just jtDesc -> JoinCluster <$> resolveAddr (parseAddr jtDesc)
let (adminHost, adminPort) = parseAddr (adminAddr runtimeConfig)
settings <- RuntimeSettings
<$> (resolveAddr . parseAddr . peerAddr) runtimeConfig
<*> (resolveAddr . parseAddr . joinAddr) runtimeConfig
<*> pure (fromString adminHost)
<*> pure adminPort
return (settings, mode, userConfig)
(_, [], errors) ->
fail (intercalate "\n" (errors ++ [usageInfo prog options]))
(_, unknown, _) ->
fail
$ "Unknown options: " ++ intercalate ", " unknown
++ "\n" ++ usageInfo prog options
where
options :: [OptDescr (Opt -> Opt)]
options = [
Option
['c']
["config"]
(ReqArg
(\name opt -> opt {oConfigFile = Just name})
"<file>"
)
"REQUIRED - Specifies the config file.",
Option
['j']
["joinTarget"]
(ReqArg
(\addr opt -> opt {oJoinTarget = Just addr})
"<host>:<port>"
)
"OPTIONAL - The address of a node in the cluster we want to join."
]
data Config = Config {
peerAddr :: AddressDescription,
joinAddr :: AddressDescription,
adminAddr :: AddressDescription
}
instance FromJSON Config where
parseJSON (Object config) = Config
<$> config .: "peerAddr"
<*> config .: "joinAddr"
<*> config .: "adminAddr"
parseJSON value =
fail $ "Couldn't parse legion runtime config from " ++ show value
{- |
An address description is really just an synonym for a formatted string.
Examples: @"0.0.0.0:8080"@, @"www.google.com:80"@,
-}
type AddressDescription = String
{- | Resolve an address description into an actual socket addr. -}
resolveAddr :: (String, Int) -> IO SockAddr
resolveAddr (host, port) =
addrAddress . head <$> getAddrInfo Nothing (Just host) (Just (show port))
{- | Parse a <host>:<port> string into its component parts. -}
{- TODO do this better, using a real parser technology, and supporting IPv6. -}
parseAddr :: AddressDescription -> (String, Int)
parseAddr desc =
case splitOn ":" desc of
[host, portStr] ->
case readMay portStr of
Nothing -> error ("Invalid address description: " ++ show desc)
Just port -> (host, port)
_ -> error ("Invalid address description: " ++ show desc)
data Opt = Opt {
oConfigFile :: Maybe FilePath,
oJoinTarget :: Maybe AddressDescription
}
instance Default Opt where
def = Opt Nothing Nothing