{-# LANGUAGE ExplicitForAll #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} module Docker.Client.Utils where import qualified Codec.Archive.Tar as Tar import qualified Codec.Compression.GZip as GZip import Control.Monad (filterM, liftM, unless) import Control.Monad.IO.Class import qualified Data.ByteString.Lazy as BS import Data.Monoid ((<>)) import qualified Data.Text as T import qualified Data.Text.IO as TIO import qualified Data.UUID as UUID import qualified Data.UUID.V4 as UUID import System.Directory (doesDirectoryExist, doesFileExist, getTemporaryDirectory) import System.FilePath (isAbsolute, makeRelative, ()) import System.FilePath.Find (FilterPredicate, RecursionPredicate, always, fileName, find, (==?)) import System.FilePath.GlobPattern ((~~)) import System.IO.Error (tryIOError) -- import System.IO.Temp (withSystemTempDirectory) -- import Docker.Client.Http type File = FilePath data DirTree = DirTree [File] [DirTree] newtype ExclusionPattern = ExclusionPattern T.Text deriving (Eq, Show) newtype InclusionPattern = InclusionPattern T.Text deriving (Eq, Show) data DockerIgnore = DockerIgnore { exclusionPatterns :: [ExclusionPattern] , inclusionPatterns :: [InclusionPattern] } deriving (Eq, Show) newtype BuildContextRootDir = BuildContextRootDir FilePath deriving (Eq, Show) makeBuildContext :: forall m. MonadIO m => BuildContextRootDir -> m (Either DockerError FilePath) makeBuildContext base = liftIO $ tryIOError (makeBuildContext' base) >>= \res -> case res of Left e -> return $ Left $ DockerClientError (T.pack $ show e) Right c -> return $ Right c makeBuildContext' :: forall m. MonadIO m => BuildContextRootDir -> m FilePath makeBuildContext' (BuildContextRootDir base) = do uuid <- liftIO UUID.nextRandom fs <- liftIO $ getBuildContext $ BuildContextRootDir base let relFs = map (makeRelative base) fs tmpDir <- liftIO getTemporaryDirectory let tmpF = tmpDir "docker.context-" <> UUID.toString uuid <> ".tar.gz" liftIO $ BS.writeFile tmpF . GZip.compress . Tar.write =<< Tar.pack base relFs return tmpF parseDockerIgnoreFile :: T.Text -> DockerIgnore parseDockerIgnoreFile c = DockerIgnore{ exclusionPatterns=parseExclusions , inclusionPatterns=parseInclusions} where lines = filter (not . T.isPrefixOf "#") (T.lines c) -- Ignore comments parseExclusions = map ExclusionPattern $ filter (\l -> not (T.isPrefixOf "!" l) && (l /= "")) lines parseInclusions = map (InclusionPattern . T.drop 1) $ filter (\l -> T.isPrefixOf "!" l && (l /= "")) lines getBuildContext :: BuildContextRootDir -> IO [FilePath] getBuildContext (BuildContextRootDir base) = do -- The base dir needs to be a path to a directory and not a path to -- a file exists <- doesDirectoryExist base let abs = isAbsolute base unless (exists && abs) $ fail "Path to context needs to be a directory that: exists, is readable, and is an absolute path." di <- find always (fileName ==? ".dockerignore") base dockerignore <- case di of [] -> return $ DockerIgnore [] [] -- This should not return more than one result though (x:_) -> do c <- TIO.readFile x return $ parseDockerIgnoreFile c -- This will traverse the directory recursively fs <- find (shouldRecurse dockerignore) (shouldInclude dockerignore) base -- fs is a list of directories *and* files in those directories. So -- an example result would look like ["/tmp/project/files", -- "/tmp/project/files/file1.txt"] and we want just the individual -- files otherwise tar duplicates them when making the archive. fs' <- filterM doesFileExist fs -- For some reason base is in there as well and we don't need that return $ filter (not . (==) base) fs' shouldInclude :: DockerIgnore -> FilterPredicate shouldInclude d = check `liftM` fileName where check f = dockerIgnoreDecision (exclusionCheck f (exclusionPatterns d), inclusionCheck f (inclusionPatterns d)) shouldRecurse :: DockerIgnore -> RecursionPredicate shouldRecurse d = check `liftM` fileName where check f = dockerIgnoreDecision (exclusionCheck f (exclusionPatterns d), inclusionCheck f (inclusionPatterns d)) -- TODO: We don't handle precedence rules. For instance a dockerignore file -- like this: -- -- *.md -- !README*.md -- README-secret.md -- -- Should result in no markdown files being included in the context except README files other -- than README-secret.md. FIXME: OUR implementation would in fact include -- README-secret.md as well!!! -- -- Whereas this: -- -- *.md -- README-secret.md -- !README*.md -- -- Should result in all of the README files being included. The middle line has no effect -- because !README*.md matches README-secret.md and comes last. Our -- implementation will result in the same thing. dockerIgnoreDecision :: (Bool, Bool) -> Bool dockerIgnoreDecision p = case p of -- If it's in any of the exclusion patterns but also -- in any of the inclusion patterns then laeave it (True, True) -> True (True, False) -> False (False, True) -> True (False, False) -> True exclusionCheck :: FilePath -> [ExclusionPattern] -> Bool exclusionCheck f ps = any id (map (\(ExclusionPattern p) -> f ~~ T.unpack p) ps) inclusionCheck :: FilePath -> [InclusionPattern] -> Bool inclusionCheck f ps = any id (map (\(InclusionPattern p) -> f ~~ T.unpack p) ps)