{- Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. This source code is licensed under the BSD-style license found in the LICENSE file in the root directory of this source tree. -} -- | An implementation of ConfigProvider that uses files on the local -- filesystem, watching for changes using INotify. {-# LANGUAGE ApplicativeDo #-} module Glean.Impl.ConfigProvider ( LocalConfigOptions(..), LocalSubscription, ConfigAPI(..), ) where import Control.Concurrent import Control.Exception import qualified Data.ByteString as ByteString import qualified Data.ByteString.Char8 as BC import Data.ByteString (ByteString) import Data.HashMap.Strict (HashMap) import qualified Data.HashMap.Strict as HashMap import Data.Text (Text) import qualified Data.Text as Text import Options.Applicative import System.Directory import System.FilePath import System.IO.Error import System.INotify import Util.Control.Exception import Glean.Util.ConfigProvider data ConfigAPI = ConfigAPI { opts :: LocalConfigOptions , inotify :: INotify , subscriptions :: MVar (HashMap ConfigPath (WatchDescriptor, [ByteString -> IO ()])) } newtype LocalConfigOptions = LocalConfigOptions { configDir :: Maybe FilePath -- ^ directory where the configs are found. If Nothing, we'll -- use $HOME/.config/glean. } type instance ConfigOptions ConfigAPI = LocalConfigOptions data LocalSubscription a = LocalSubscription newtype ConfigProviderException = ConfigProviderException Text deriving (Show) instance Exception ConfigProviderException instance ConfigProvider ConfigAPI where configOptions = do configDir <- optional $ strOption ( long "config-dir" <> metavar "DIR" <> help ("directory where the config files can be found " <> "(default: $HOME/.config/glean)") ) return LocalConfigOptions{..} defaultConfigOptions = LocalConfigOptions { configDir = Nothing } withConfigProvider opts f = withINotify $ \inotify -> do subs <- newMVar HashMap.empty f (ConfigAPI opts inotify subs) type Subscription ConfigAPI = LocalSubscription subscribe cfg@ConfigAPI{..} path updated deserializer = do a <- get cfg path deserializer updated a dir <- getDir opts modifyMVar_ subscriptions $ \hm -> do let changed contents = deserialize path deserializer contents >>= updated case HashMap.lookup path hm of Just (watch, others) -> return $ HashMap.insert path (watch, changed:others) hm Nothing -> do let file = BC.pack $ dir Text.unpack path watch <- addWatch inotify [Modify,MoveIn,Create] file $ \_events -> do callbacks <- withMVar subscriptions $ \hm -> do case HashMap.lookup path hm of Nothing -> return [] Just (_, callbacks) -> return callbacks contents <- ByteString.readFile (dir Text.unpack path) mapM_ ($ contents) callbacks `catchAll` \_ -> return () return $ HashMap.insert path (watch, [changed]) hm return LocalSubscription cancel _ _ = return () -- unimplemented for now get ConfigAPI{..} path deserializer = do dir <- getDir opts contents <- ByteString.readFile (dir Text.unpack path) `catch` \e -> if isDoesNotExistError e then throwIO $ ConfigProviderException $ "no config for " <> path <> " at " <> Text.pack (dir Text.unpack path) else throwIO e deserialize path deserializer contents isConfigFailure _ e | Just ConfigProviderException{} <- fromException e = True | otherwise = False deserialize :: ConfigPath -> Deserializer a -> ByteString -> IO a deserialize path deserializer contents = case deserializer contents of Left err -> throwIO $ ConfigProviderException $ "deserialization failed for: " <> path <> ": " <> Text.pack err Right a -> return a -- | default to $HOME/.config/glean if --config-dir is not set getDir :: LocalConfigOptions -> IO FilePath getDir opts = case configDir opts of Nothing -> getXdgDirectory XdgConfig "glean" Just dir -> return dir