packages feed

dockercook-0.5.0.0: src/lib/Cook/State/Manager.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE DoAndIfThenElse #-}
module Cook.State.Manager
    ( StateManager, HashManager(..)
    , createStateManager, markUsingImage
    , forgetImage
    , isImageKnown, fastFileHash
    , syncImages, waitForWrites
    , getImageId, setImageId, setImageBuildTime
    , ImageBuildTime(..), getImageBuildTime
    , _STATE_DIR_NAME_, findStateDirectory
    )
where

import Cook.Util
import Cook.State.Model
import Cook.Types
import qualified Cook.DirectDocker as Docker

import Control.Applicative
import Control.Concurrent
import Control.Concurrent.STM
import Control.Exception
import Control.Monad
import Control.Monad.Logger hiding (logInfo, logDebug, logError, logWarn)
import Control.Monad.State
import Control.Monad.Trans.Resource
import Control.Retry
import Data.Aeson
import Data.Maybe
import Data.Time
import Data.Time.Clock.POSIX
import Database.Persist.Sqlite
import System.Directory
import System.FilePath
import System.Posix.Files
import qualified Data.HashMap.Strict as HM
import qualified Data.Text as T
import qualified Data.Vector as V

data StateManager
   = StateManager
   { sm_runSqlGet :: forall a. SqlPersistM a -> IO a
   , sm_runSqlWrite :: SqlPersistM () -> IO ()
   , sm_databaseFile :: FilePath
   , sm_waitForWrites :: IO ()
   }

data HashManager
   = HashManager
   { hm_lookup :: forall m. MonadIO m => FilePath -> m SHA1 -> m SHA1
   , hm_didFileChange :: forall m. MonadIO m => FilePath -> m Bool
   }

fastFileHash :: forall m. MonadIO m => HashManager -> FilePath -> m SHA1 -> m SHA1
fastFileHash hm = hm_lookup hm

_STATE_DIR_NAME_ :: FilePath
_STATE_DIR_NAME_ = ".kitchen2"

findStateDirectory :: IO FilePath
findStateDirectory =
    do currentDir <- getCurrentDirectory
       checkLoop currentDir
    where
      checkLoop parentDir =
          do logDebug $ "Looking for a " ++ _STATE_DIR_NAME_ ++ " directory in " ++ parentDir
             isThere <- doesDirectoryExist (parentDir </> _STATE_DIR_NAME_)
             if isThere
             then return (parentDir </> _STATE_DIR_NAME_)
             else do when (normalise parentDir == "/" || normalise parentDir == "") $
                          fail "Can't find my kitchen! Did you run dockercook init?"
                     checkLoop (normalise $ takeDirectory parentDir)

waitForWrites :: StateManager -> IO ()
waitForWrites st =
    sm_waitForWrites st

createStateManager :: FilePath -> IO (StateManager, HashManager)
createStateManager stateDirectory =
    do logDebug $ "Creating state manager with directory " ++ stateDirectory
       pool <- runNoLoggingT $ createSqlitePool (T.pack sqlLoc) 5
       let runSql action =
               let tryTx = (runResourceT . runNoLoggingT . ((flip runSqlPool) pool)) action
               in (recoverAll (constantDelay microsec <> limitRetries 5) tryTx) `catch` \(e :: SomeException) ->
                    fail $ "Sqlite-Transaction finally failed: " ++ show e
       runSql (runMigration migrateState)
       sqlQueue <- newTBQueueIO 100
       allHashes <- runSql $ selectList [] []
       logDebug $ "Initializing hash manager"
       let hashMap =
               foldl (\hm entity ->
                          let v = entityVal entity
                          in HM.insert (dbHashCacheFullPath v) (dbHashCacheMtime v, SHA1 $ dbHashCacheHash v) hm
                     ) HM.empty allHashes
       hashMapV <- newTVarIO hashMap
       hashWriteChan <- newTBQueueIO 1000
       workV <- newTVarIO False
       let waitWrites =
               atomically $
               do emptyQ <- isEmptyTBQueue sqlQueue
                  unless emptyQ $ retry
           stateMgr =
               StateManager
               { sm_runSqlGet =
                     \action ->
                     do liftIO waitWrites
                        runSql action
               , sm_runSqlWrite =
                   \writeReq ->
                       atomically $ writeTBQueue sqlQueue writeReq
               , sm_databaseFile = stateDirectory </> "database.db"
               , sm_waitForWrites =
                   do waitForHashes workV hashWriteChan
                      waitWrites
               }
       _ <- forkIO (hashManagerPersistWorker stateMgr workV hashWriteChan)
       _ <- forkIO (queueWorker runSql sqlQueue)
       let hashMgr =
               HashManager
               { hm_lookup = hashManagerLookup stateMgr hashMapV hashWriteChan
               , hm_didFileChange = didFileChange stateMgr hashMapV
               }
       return (stateMgr, hashMgr)
    where
      microsec =
          12 * 1000 * 1000
      sqlLoc = stateDirectory </> "database.db"
      queueWorker runSql queue =
          do action <- atomically $ readTBQueue queue
             _ <- runSql action
             queueWorker runSql queue

waitForHashes :: TVar Bool -> TBQueue DbHashCache -> IO ()
waitForHashes workEnqueuedVar hashWriteChan =
    atomically $
    do queueEmpty <- isEmptyTBQueue hashWriteChan
       workEnqueued <- readTVar workEnqueuedVar
       when ((not queueEmpty) || workEnqueued) retry

hashManagerPersistWorker :: StateManager -> TVar Bool -> TBQueue DbHashCache -> IO ()
hashManagerPersistWorker (StateManager{..}) workEnqueuedVar hashWriteChan =
    do batchV <- newTVarIO V.empty
       _ <- forkIO (batchBuilder batchV)
       loop batchV
    where
      batchBuilder :: TVar (V.Vector DbHashCache) -> IO ()
      batchBuilder batchV =
          do writeOp <-
                 atomically $
                   do writeTVar workEnqueuedVar True
                      readTBQueue hashWriteChan
             atomically $ modifyTVar' batchV (\v -> V.snoc v writeOp)
             batchBuilder batchV
      loop :: TVar (V.Vector DbHashCache) -> IO ()
      loop batchV =
          do logDebug $ "Waiting for next hash batch to arrive"
             writeBatch <-
                 atomically $
                   do v <- readTVar batchV
                      when (V.null v) retry
                      writeTVar batchV V.empty
                      return v
             let sqlAction =
                     sm_runSqlWrite $
                     do let xs = V.toList writeBatch
                        mapM_ (\oldHash -> deleteWhere [DbHashCacheFullPath ==. oldHash]) (map dbHashCacheFullPath xs)
                        _ <- insertMany xs
                        logDebug $ "Stored " ++ (show $ V.length writeBatch) ++ " hashes"
                        return ()
             logDebug $ "Storing " ++ (show $ V.length writeBatch) ++ " hashes in database."
             atomically $ writeTVar workEnqueuedVar False
             sqlAction `catch` \(e :: SomeException) ->
                 do logError $ "Hash persist error: " ++ show e
                    atomically $
                        do modifyTVar' batchV (\newV -> V.concat [writeBatch, newV])
                           writeTVar workEnqueuedVar True
             threadDelay 1000000 -- 1 sec
             loop batchV

didFileChange :: MonadIO m
              => StateManager
              -> TVar (HM.HashMap FilePath (UTCTime, SHA1))
              -> FilePath
              -> m Bool
didFileChange (StateManager{..}) hashMapV fullFilePath =
    liftIO $
    do fileExists <- doesFileExist fullFilePath
       if not fileExists
       then return True
       else do currModTime <- getModTime fullFilePath
               mEntry <- liftIO $ atomically $ HM.lookup fullFilePath <$> readTVar hashMapV
               case mEntry of
                 Nothing ->
                     return True
                 Just (oldMtime, _) ->
                     return (oldMtime /= currModTime)

getModTime :: FilePath -> IO UTCTime
getModTime fullFilePath =
    do stat <- liftIO $ getFileStatus fullFilePath
       return $ (\t -> posixSecondsToUTCTime (realToFrac t :: POSIXTime)) $ modificationTime stat

hashManagerLookup :: MonadIO m
                  => StateManager
                  -> TVar (HM.HashMap FilePath (UTCTime, SHA1))
                  -> TBQueue DbHashCache
                  -> FilePath
                  -> m SHA1
                  -> m SHA1
hashManagerLookup (StateManager{..}) hashMapV hashWriteChan fullFilePath computeHash =
    do modTime <- liftIO $ getModTime fullFilePath
       mEntry <- liftIO $ atomically $ HM.lookup fullFilePath <$> readTVar hashMapV
       let recomputeHash =
               do newHash <- computeHash
                  liftIO $ atomically $
                    do modifyTVar' hashMapV (HM.insert fullFilePath (modTime, newHash))
                       writeTBQueue hashWriteChan (DbHashCache fullFilePath modTime (unSha1 newHash))
                  return newHash
       case mEntry of
         Just (mtime, oldHash) ->
             if mtime == modTime
             then return oldHash
             else recomputeHash
         Nothing ->
             recomputeHash

syncImages :: StateManager -> Docker.DockerHostId -> (DockerImage -> IO Bool) -> IO ()
syncImages sm@(StateManager{..}) dh imageStillExists =
    do let hostId = Docker.dockerHostIdAsText dh
       x <- sm_runSqlGet $ selectList [DbDockerImageHost ==. hostId] []
       forM_ x $ \entity ->
           do let dockerImage = entityVal entity
                  name = dbDockerImageName dockerImage
              exists <- imageStillExists (DockerImage name)
              if exists
              then do mRawId <- Docker.dockerImageId (DockerImage name)
                      case mRawId of
                        Nothing ->
                            do logInfo ("The image " ++ T.unpack name
                                        ++ " doesn't have a raw id on the docker host. Deleting it from local state")
                               sm_runSqlWrite $ delete (entityKey entity)
                        Just rawId ->
                            do logInfo ("New raw id for " ++ T.unpack name ++ " is " ++ T.unpack (unDockerImageId rawId))
                               setImageId sm (DockerImage name) rawId
              else do logInfo ("The image " ++ T.unpack name
                               ++ " doesn't exist on remote docker server. Removing it from local state.")
                      sm_runSqlWrite $ delete (entityKey entity)
       sm_waitForWrites

isImageKnown :: StateManager -> DockerImage -> Docker.DockerHostId -> IO Bool
isImageKnown (StateManager{..}) (DockerImage imageName) dh =
    do x <- sm_runSqlGet $ getBy (UniqueDbDockerImage imageName $ Docker.dockerHostIdAsText dh)
       return (isJust x)

forgetImage :: StateManager -> DockerImage -> Docker.DockerHostId -> IO ()
forgetImage (StateManager{..}) (DockerImage imageName) dh =
    do sm_runSqlWrite $ deleteBy (UniqueDbDockerImage imageName $ Docker.dockerHostIdAsText dh)
       sm_waitForWrites

getImageId :: StateManager -> DockerImage -> Docker.DockerHostId -> IO (Maybe DockerImageId)
getImageId (StateManager{..}) (DockerImage imageName) dh =
    do x <- sm_runSqlGet $ getBy (UniqueDbDockerImage imageName $ Docker.dockerHostIdAsText dh)
       case x of
         Nothing -> return Nothing
         Just entity ->
             return $ fmap DockerImageId (dbDockerImageRawImageId $ entityVal entity)

setImageId :: StateManager -> DockerImage -> DockerImageId -> IO ()
setImageId (StateManager{..}) (DockerImage imageName) (DockerImageId imageId) =
    sm_runSqlWrite $ updateWhere [ DbDockerImageName ==. imageName ] [ DbDockerImageRawImageId =. (Just imageId) ]

setImageBuildTime :: StateManager -> DockerImage -> Docker.DockerHostId -> NominalDiffTime -> IO ()
setImageBuildTime (StateManager{..}) (DockerImage imageName) dh t =
    sm_runSqlWrite $
    updateWhere
      [ DbDockerImageName ==. imageName
      , DbDockerImageHost ==. (Docker.dockerHostIdAsText dh)
      ] [ DbDockerImageBuildTimeSeconds =. (fromRational $ toRational t) ]

markUsingImage :: StateManager -> DockerImage -> Docker.DockerHostId -> IO ()
markUsingImage (StateManager{..}) (DockerImage imageName) dh =
    do let hostId = Docker.dockerHostIdAsText dh
       mImageEntity <- sm_runSqlGet $ getBy (UniqueDbDockerImage imageName hostId)
       now <- getCurrentTime
       case mImageEntity of
         Nothing ->
             sm_runSqlWrite $
             do _ <-
                    insert $
                    DbDockerImage
                    { dbDockerImageHost = hostId
                    , dbDockerImageName = imageName
                    , dbDockerImageRawImageId = Nothing
                    , dbDockerImageCreationDate = now
                    , dbDockerImageLastUsed = now
                    , dbDockerImageUsageCount = 1
                    , dbDockerImageBuildTimeSeconds = 0
                    }
                return ()
         Just imageEntity ->
             sm_runSqlGet $ update (entityKey imageEntity) [ DbDockerImageUsageCount +=. 1
                                                           , DbDockerImageLastUsed =. now
                                                           ]

data ImageBuildTime
   = ImageBuildTime
   { ibt_host :: !Docker.DockerHostId
   , ibt_imageId :: !(Maybe DockerImageId)
   , ibt_cookId :: !DockerImage
   , ibt_seconds :: !Double
   } deriving (Show, Eq)

instance ToJSON ImageBuildTime where
    toJSON ibt =
        object
        [ "host" .= ibt_host ibt
        , "imageId" .= ibt_imageId ibt
        , "cookId" .= ibt_cookId ibt
        , "seconds" .= ibt_seconds ibt
        ]

getImageBuildTime :: StateManager -> Either DockerImage DockerImageId -> IO [ImageBuildTime]
getImageBuildTime (StateManager{..}) search =
    do res <- sm_runSqlGet $ selectList filters []
       return $ map transTime res
    where
      transTime entity =
          ImageBuildTime
          { ibt_host = Docker.DockerHostId (dbDockerImageHost val)
          , ibt_imageId = fmap DockerImageId (dbDockerImageRawImageId val)
          , ibt_cookId = DockerImage (dbDockerImageName val)
          , ibt_seconds = dbDockerImageBuildTimeSeconds val
          }
          where
            val = entityVal entity
      filters =
          case search of
            Left (DockerImage cookImage) -> [ DbDockerImageName ==. cookImage ]
            Right (DockerImageId imageId) -> [ DbDockerImageRawImageId ==. Just imageId ]