{- 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. -} module Util.IO ( atomicIO , die , readFileUTF8 , writeFileUTF8 , writeFileAtomicUTF8 , readJSON , eitherReadJSON , writeJSON , writeJSON' , removeIfExists , readFileIfExists , writeFileIfChanged , writeFileAtomically , writeTextIfChanged , loudlyWriteTextIfChanged , writeTextIfChangedWith , writeUtf8StringIfChanged , loudlyWriteUtf8StringIfChanged , writeUtf8StringIfChangedWith , Verbosity(..) , writeFileUTF8Text , readFileUTF8Text , getDevserverUser , getDevserverHostInfo , getHostInfo , getHostname , getUserForProcess , getGroupForProcess , getUsername , getUserUnixname , HostInfo(..) , copyDirectoryContents , copyDirectoryContents_ , listDirectoryRecursive , saveStdout , saveStderr , slowIO , isModifiedAfter , safeRemovePathForcibly , withLazy ) where import Control.Concurrent import Control.Concurrent.Async import Control.Exception as Exception import Control.Monad import Control.Monad.Extra import Data.Aeson import Data.Aeson.Encode.Pretty import Data.Foldable import Data.List import Data.List.Split import Data.Maybe import Data.Text (Text) import qualified Data.Text as Text import qualified Data.Text.IO as TextIO import GHC.IO.Handle import System.Directory import System.Exit hiding (die) import System.FilePath import System.IO import System.IO.Error import System.IO.Unsafe import System.Posix.User import System.Process import System.Environment import Text.Printf import Util.String (strip) import qualified Data.ByteString.Lazy as B -- | Performs some IO atomically. This is presumed to be only used -- to avoid output interleaving where verbose information is printed -- concurrently. atomicIO :: IO a -> IO a atomicIO = withMVar atomicIOLock . const {-# NOINLINE atomicIOLock #-} atomicIOLock :: MVar () atomicIOLock = unsafePerformIO $ newMVar () -- | Exit immediately with 'exitCode', showing a message on 'stderr'. die :: Int -> String -> IO a die exitCode msg = hPutStrLn stderr msg >> exitWith (ExitFailure exitCode) -- | Compare a file's content against some benchmark content to see -- whether it's changed. You really want to pass in some kind of lazy -- read function here so that (in-)equality can be determined as soon -- as possible during the file read. Note that if the file doesn't -- exist, then we return True, that is, we state that the file has -- changed. hasFileContentChanged :: (Eq a) => (FilePath -> IO a) -> FilePath -> a -> IO Bool hasFileContentChanged read filePath benchmarkContent = catchIOError (liftM (/= benchmarkContent) $ read filePath) (\_ -> return True) -- | Writes a file only if it differs from the content that would be -- written. writeFileIfChanged :: (Eq a) => (FilePath -> IO a) -> (Handle -> a -> IO ()) -> FilePath -> a -> IO () writeFileIfChanged read hWrite targetPath content = do different <- hasFileContentChanged read targetPath content when different $ writeFileAtomically hWrite targetPath content -- | Write a file "atomically" by writing it fully to a temp -- directory, then copying it to its final location. writeFileAtomically :: (Handle -> a -> IO ()) -> FilePath -> a -> IO () writeFileAtomically hWrite targetPath content = Exception.bracketOnError mkTempFile rmTempFile commit where (targetDir, targetFile) = splitFileName targetPath mkTempFile = openTempFile targetDir (targetFile <.> "tmp") rmTempFile (tmpPath, handle) = hClose handle >> removeFile tmpPath commit (tmpPath, handle) = do hWrite handle content hClose handle renameFile tmpPath targetPath -- | Remove file if it exists. Returns True if the file existed. removeIfExists :: FilePath -> IO Bool removeIfExists filePath = (removeFile filePath >> return True) `catch` \e -> if isDoesNotExistError e then return False else throwIO e readFileIfExists :: FilePath -> IO (Maybe String) readFileIfExists filePath = (Just <$> readFile filePath) `catch` \e -> if isDoesNotExistError e then return Nothing else throwIO e -- | Read a FromJSON datatype from a file containing a JSON string. readJSON :: FromJSON a => FilePath -> IO a readJSON f = do json <- B.readFile f case eitherDecode json of Left err -> error $ printf "Failed parsing JSON file %s: %s\n" f err Right v -> return v eitherReadJSON :: FromJSON a => FilePath -> IO (Either String a) eitherReadJSON f = (eitherDecode <$> B.readFile f) `catch` (\(e :: Exception.IOException) -> return $ Left (show e)) -- | Write a ToJSON datatype to a file as pretty-printed JSON. writeJSON :: ToJSON a => FilePath -> a -> IO () writeJSON path = B.writeFile path . encodePretty -- | Write a ToJSON datatype to a file as pretty-printed JSON. -- Adds a newline at the end of the file writeJSON' :: ToJSON a => Config -> FilePath -> a -> IO () writeJSON' config path obj = B.writeFile path (encodePretty' config obj <> "\n") -- | Read a UTF-8 encoded file. Like 'readFile' but forces the -- encoding to UTF-8. readFileUTF8 :: FilePath -> IO String readFileUTF8 path = do h <- openFile path ReadMode hSetEncoding h utf8 hGetContents h -- | Write a UTF-8 encoded file. Like 'writeFile' but forces the -- encoding to UTF-8. writeFileUTF8 :: Handle -> String -> IO () writeFileUTF8 h str = do hSetEncoding h utf8 hPutStr h str -- | Write a UTF-8 encoded file atomically. Writes to a temporary file -- first, and then atomically moves the temporary file to the destination -- if successful. -- -- Adapted from Distribution.Simple.Utils.writeFileAtomic in Cabal. writeFileAtomicUTF8 :: FilePath -> String -> IO () writeFileAtomicUTF8 targetPath content = do let (targetDir, targetFile) = splitFileName targetPath Exception.bracketOnError (do createDirectoryIfMissing True targetDir openBinaryTempFileWithDefaultPermissions targetDir $ targetFile <.> "tmp") (\(tmpPath, handle) -> hClose handle >> removeFile tmpPath) (\(tmpPath, handle) -> do hSetEncoding handle utf8 hPutStr handle content hClose handle renameFile tmpPath targetPath) -- | Write a UTF-8 encoded file from text, only if it differs from the content -- that would be written. If the output directory will be created if it does -- not exist. writeTextIfChanged :: FilePath -> Text -> IO () writeTextIfChanged path text = do createDirectoryIfMissing True $ takeDirectory path writeFileIfChanged readFileUTF8Text writeFileUTF8Text path text -- Like Util.IO.writeTextIfChanged, but announce upon success. loudlyWriteTextIfChanged :: FilePath -> Text -> IO () loudlyWriteTextIfChanged = writeTextIfChangedWith Loud putStrLn data Verbosity = Loud | Quiet writeTextIfChangedWith :: Verbosity -> (String -> IO ()) -> FilePath -> Text -> IO () writeTextIfChangedWith verbosity f path text = do createDirectoryIfMissing True $ takeDirectory path writeFileIfChanged readFileUTF8Text writeFileUTF8Text_ path text where writeFileUTF8Text_ h txt = do writeFileUTF8Text h txt case verbosity of Loud -> atomicIO $ f ("Written " ++ path) Quiet -> return () -- | Write a UTF-8 encoded file from text. Like 'writeFile' but forces the -- encoding to UTF-8. writeFileUTF8Text :: Handle -> Text -> IO () writeFileUTF8Text h text = do hSetEncoding h utf8 TextIO.hPutStr h text -- | Read a UTF-8 encoded file as text. readFileUTF8Text :: FilePath -> IO Text readFileUTF8Text path = do h <- openFile path ReadMode hSetEncoding h utf8 TextIO.hGetContents h -- Like Util.IO.writeTextIfChanged, but works for String writeUtf8StringIfChanged :: FilePath -> String -> IO () writeUtf8StringIfChanged path content = do createDirectoryIfMissing True $ takeDirectory path writeFileIfChanged readFileUTF8 writeFileUTF8 path content -- Like Util.IO.loudlyWriteTextIfChanged, but works for String. loudlyWriteUtf8StringIfChanged :: FilePath -> String -> IO () loudlyWriteUtf8StringIfChanged = writeUtf8StringIfChangedWith Loud putStrLn -- Like Util.IO.writeTextIfChangedWith, but works for String writeUtf8StringIfChangedWith :: Verbosity -> (String -> IO ()) -> FilePath -> String -> IO () writeUtf8StringIfChangedWith verbosity f path content = do createDirectoryIfMissing True $ takeDirectory path writeFileIfChanged readFileUTF8 writeFileUTF8_ path content where writeFileUTF8_ h txt = do writeFileUTF8 h txt case verbosity of Loud -> atomicIO $ f ("Written " ++ path) Quiet -> return () getUserForProcess :: IO String getUserForProcess = fmap userName $ getUserEntryForID =<< getRealUserID getGroupForProcess :: IO String getGroupForProcess = fmap groupName $ getGroupEntryForID =<< getRealGroupID data HostInfo = HostInfo { hostname :: Text , username :: Text } getHostInfo :: IO HostInfo getHostInfo = do user <- Text.pack <$> getUsername host <- Text.pack <$> getHostname return $ HostInfo host user getDevserverHostInfo :: IO HostInfo getDevserverHostInfo = do user <- Text.pack <$> getDevserverUser host <- Text.pack <$> getHostname return $ HostInfo host user -- | Equivalent of get_devserver_username in www (see -- https://fburl.com/codex/05mqipn2). getDevserverUser :: IO String getDevserverUser = getUserForProcess -- | Get username using 'id' command line tool getUsername :: IO String getUsername = init <$> readProcess "id" ["-un"] "" -- | Get hostname using 'hostname' command line tool getHostname :: IO String getHostname = strip <$> readProcess "hostname" [] [] -- Try to get a meaninful user unix name. This will not realize that the user -- was changed to a non-root account like mysql. getUserUnixname :: IO String getUserUnixname = do user <- getUserForProcess fromMaybe user <$> asum [ return $ helpful $ Just user , helpful <$> getTupperwareUser , helpful <$> getDevserverOwner ] where unhelpfulNames = ["root", "twsvcscm", "svcscm", "apache"] helpful (Just name) | name `elem` unhelpfulNames = Nothing helpful mname = mname getTupperwareUser = lookupEnv "TW_JOB_USER" getDevserverOwner :: IO (Maybe String) getDevserverOwner = do res <- Exception.try $ readFile "/etc/devserver.owners" :: IO (Either Exception.SomeException String) return $ case lines <$> res of Right (name:_) -> Just name _ -> Nothing redirectHandle :: Handle -> FilePath -> IO () -> IO () redirectHandle handle file io = withFile file WriteMode $ \fh -> do bracket (hDuplicate handle) (`hDuplicateTo` handle) $ \_ -> do hDuplicateTo fh handle io -- | Run an IO action saving the stderr to a file saveStderr :: FilePath -> IO () -> IO () saveStderr = redirectHandle stderr -- | Run an IO action saving the stdout to a file saveStdout :: FilePath -> IO () -> IO () saveStdout = redirectHandle stdout -- | Copy all the contents of one directory to another directory -- and return all the resulting paths. -- This will create the target directory if it does not exist. -- | DISCLAIMER: This is not an atomic operation. copyDirectoryContents :: FilePath -> FilePath -> IO [FilePath] copyDirectoryContents dirfrom dirto = do paths <- listDirectoryRecursive dirfrom forM paths $ \path -> do let relfile = makeRelative dirfrom path let frompath = dirfrom relfile let topath = dirto relfile createDirectoryIfMissing True $ takeDirectory topath copyFile frompath topath return topath copyDirectoryContents_ :: FilePath -> FilePath -> IO () copyDirectoryContents_ dirfrom dirto = void $ copyDirectoryContents dirfrom dirto listDirectoryRecursive :: FilePath -> IO [FilePath] listDirectoryRecursive dir = do fs <- listDirectory dir (concat <$>) $ forM fs $ \file -> do let f = dir file isFile <- doesFileExist f if isFile then return [f] else do isDir <- doesDirectoryExist f if isDir then listDirectoryRecursive f else return [] -- | Perform IO in batches with delays between them. Useful to avoid -- filling up fixed size queues. slowIO :: ([a] -> IO ()) -- ^ operation to apply to each batch -> [a] -- ^ all arguments -> Int -- ^ maximum batch size -> Int -- ^ delay, in milliseconds -> IO () slowIO f args batchSize delay = sequence_ $ intersperse (threadDelay (delay*1000)) $ map f $ chunksOf batchSize args isModifiedAfter :: FilePath -> FilePath -> IO Bool isModifiedAfter fp1 fp2 = ifM (doesFileExist fp1 &&^ doesFileExist fp2) (do time <- getModificationTime fp1 time' <- getModificationTime fp2 return $ time > time') (return False) -- | System.Posix.removeLink calls @unlink()@ with an unsafe FFI call, -- which can cause long stalls in some cases. This affects things -- defined in terms of it, including 'System.Directory.removeFile', -- and 'System.Directory.removePathForcibly' to name a couple. -- -- This function is a replacement for 'removePathForcibly' that forks -- an external 'rm' instead. safeRemovePathForcibly :: FilePath -> IO () safeRemovePathForcibly path = callProcess "rm" [ "-rf", path ] -- | Turn a with-style resource scoping function into one that -- executes lazily. The @with@ is performed the first time the -- supplied getter is invoked, and is released when @withLazy@ -- returns. withLazy :: (forall b . (a -> IO b) -> IO b) -- ^ with-style function to allocate a scoped resource of type @a@ -> (IO a -> IO c) -- ^ provides a lazy getter for the resource @a@ -> IO c withLazy wit fn = do barrier <- newEmptyMVar result <- newEmptyMVar done <- newEmptyMVar let get = do _ <- tryPutMVar barrier (); readMVar result go = do takeMVar barrier wit $ \a -> do putMVar result a takeMVar done snd <$> concurrently go (fn get `finally` putMVar done ())