module B9.RepositoryIO
( repoSearch,
pushToRepo,
pullFromRepo,
withRemoteRepos,
pullGlob,
FilePathGlob (..),
getSharedImages,
getSharedImagesCacheDir,
getSelectedRepos,
pullRemoteRepos,
pullLatestImage,
cleanOldSharedImageRevisionsFromCache,
cleanLocalRepoCache,
pushToSelectedRepo,
pushSharedImageLatestVersion,
getLatestImageByName,
cleanRemoteRepo,
remoteRepoCheckSshPrivKey,
)
where
import B9.B9Config
import B9.B9Error
import B9.B9Exec
import B9.B9Logging
import B9.DiskImages
import B9.Repository
import Control.Eff
import Control.Eff.Reader.Lazy
import Control.Exception
import Control.Lens ((.~), (^.), view)
import Control.Monad (forM_, unless, when)
import Control.Monad.IO.Class
import Data.Foldable
import Data.List
import Data.Map (Map)
import qualified Data.Map as Map
import Data.Maybe
import Data.Set (Set)
import qualified Data.Set as Set
import GHC.Stack
import System.Directory
import System.FilePath
import System.IO.B9Extras
import System.IO.B9Extras (SystemPath, consult, ensureDir, resolve)
import Text.Printf (printf)
import Text.Show.Pretty (ppShow)
withRemoteRepos ::
(Member B9ConfigReader e, Lifted IO e) =>
Eff (RepoCacheReader ': e) a ->
Eff e a
withRemoteRepos f = do
cfg <- getB9Config
repoCache <-
lift
(initRepoCache (fromMaybe defaultRepositoryCache (_repositoryCache cfg)))
remoteRepos' <- mapM (initRemoteRepo repoCache) (_remoteRepos cfg)
let setRemoteRepos = remoteRepos .~ remoteRepos'
localB9Config setRemoteRepos (runReader repoCache f)
initRepoCache :: MonadIO m => SystemPath -> m RepoCache
initRepoCache repoDirSystemPath = do
repoDir <- resolve repoDirSystemPath
ensureDir (repoDir ++ "/")
return (RepoCache repoDir)
remoteRepoCheckSshPrivKey :: MonadIO m => RemoteRepo -> m RemoteRepo
remoteRepoCheckSshPrivKey (RemoteRepo rId rp (SshPrivKey keyFile) h u) = do
exists <- liftIO (doesFileExist keyFile)
keyFile' <- liftIO (canonicalizePath keyFile)
unless
exists
( error
(printf "SSH Key file '%s' for repository '%s' is missing." keyFile' rId)
)
return (RemoteRepo rId rp (SshPrivKey keyFile') h u)
initRemoteRepo :: MonadIO m => RepoCache -> RemoteRepo -> m RemoteRepo
initRemoteRepo cache repo = do
repo' <- remoteRepoCheckSshPrivKey repo
let (RemoteRepo repoId _ _ _ _) = repo'
ensureDir (remoteRepoCacheDir cache repoId ++ "/")
return repo'
cleanRemoteRepo :: MonadIO m => RepoCache -> RemoteRepo -> m ()
cleanRemoteRepo cache repo = do
let repoId = remoteRepoRepoId repo
repoDir = remoteRepoCacheDir cache repoId ++ "/"
ensureDir repoDir
liftIO $ removeDirectoryRecursive repoDir
ensureDir repoDir
repoSearch ::
forall e.
(CommandIO e, Member RepoCacheReader e) =>
FilePath ->
FilePathGlob ->
Eff e [(Repository, [FilePath])]
repoSearch subDir glob = (:) <$> localMatches <*> remoteRepoMatches
where
remoteRepoMatches =
getRemoteRepos >>= mapM remoteRepoSearch
localMatches :: Eff e (Repository, [FilePath])
localMatches = do
cache <- getRepoCache
let dir = localRepoDir cache </> subDir
files <- findGlob dir
return (Cache, files)
remoteRepoSearch :: RemoteRepo -> Eff e (Repository, [FilePath])
remoteRepoSearch repo = do
cache <- getRepoCache
let dir = remoteRepoCacheDir cache repoId </> subDir
(RemoteRepo repoId _ _ _ _) = repo
files <- findGlob dir
return (Remote repoId, files)
findGlob :: FilePath -> Eff e [FilePath]
findGlob dir = do
traceL (printf "reading contents of directory '%s'" dir)
ensureDir (dir ++ "/")
files <- liftIO (getDirectoryContents dir)
return ((dir </>) <$> filter (matchGlob glob) files)
pushToRepo :: (Member ExcB9 e, CommandIO e) => RemoteRepo -> FilePath -> FilePath -> Eff e ()
pushToRepo repo@(RemoteRepo repoId _ _ _ _) src dest = do
dbgL (printf "PUSHING '%s' TO REPO '%s'" (takeFileName src) repoId)
cmd (repoEnsureDirCmd repo dest)
cmd (pushCmd repo src dest)
pullFromRepo :: (Member ExcB9 e, CommandIO e) => RemoteRepo -> FilePath -> FilePath -> Eff e ()
pullFromRepo repo@(RemoteRepo repoId rootDir _key (SshRemoteHost (host, _port)) (SshRemoteUser user)) src dest =
do
dbgL (printf "PULLING '%s' FROM REPO '%s'" (takeFileName src) repoId)
cmd
( printf
"rsync -rtv -e 'ssh %s' '%s@%s:%s' '%s'"
(sshOpts repo)
user
host
(rootDir </> src)
dest
)
pullGlob ::
(Member ExcB9 e, CommandIO e, Member RepoCacheReader e) =>
FilePath ->
FilePathGlob ->
RemoteRepo ->
Eff e ()
pullGlob subDir glob repo@(RemoteRepo repoId rootDir _key (SshRemoteHost (host, _port)) (SshRemoteUser user)) =
do
cache <- getRepoCache
infoL (printf "SYNCING REPO METADATA '%s'" repoId)
let c =
printf
"rsync -rtv --include '%s' --exclude '*.*' -e 'ssh %s' '%s@%s:%s/' '%s/'"
(globToPattern glob)
(sshOpts repo)
user
host
(rootDir </> subDir)
destDir
destDir = repoCacheDir </> subDir
repoCacheDir = remoteRepoCacheDir cache repoId
ensureDir destDir
cmd c
newtype FilePathGlob
= FileExtension String
globToPattern :: FilePathGlob -> String
globToPattern (FileExtension ext) = "*." ++ ext
matchGlob :: FilePathGlob -> FilePath -> Bool
matchGlob (FileExtension ext) = isSuffixOf ("." ++ ext)
pushCmd :: RemoteRepo -> FilePath -> FilePath -> String
pushCmd repo@(RemoteRepo _repoId rootDir _key (SshRemoteHost (host, _port)) (SshRemoteUser user)) src dest =
printf
"rsync -rtv --inplace --ignore-existing -e 'ssh %s' '%s' '%s'"
(sshOpts repo)
src
sshDest
where
sshDest = printf "%s@%s:%s/%s" user host rootDir dest :: String
repoEnsureDirCmd :: RemoteRepo -> FilePath -> String
repoEnsureDirCmd repo@(RemoteRepo _repoId rootDir _key (SshRemoteHost (host, _port)) (SshRemoteUser user)) dest =
printf
"ssh %s %s@%s mkdir -p '%s'"
(sshOpts repo)
user
host
(rootDir </> takeDirectory dest)
sshOpts :: RemoteRepo -> String
sshOpts (RemoteRepo _repoId _rootDir (SshPrivKey key) (SshRemoteHost (_host, port)) _user) =
unwords
[ "-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
printf "Port=%i" port,
"-o",
"IdentityFile=" ++ key
]
getSharedImages ::
(HasCallStack, CommandIO e, Lifted IO e, Member RepoCacheReader e) =>
Eff e (Map Repository (Set SharedImage))
getSharedImages = do
reposAndFiles <-
repoSearch
sharedImagesRootDirectory
(FileExtension sharedImageFileExtension)
foldrM
( \(repo, files) acc -> do
imgs <- catMaybes <$> mapM consult' files
let imgSet = Set.fromList imgs
return (Map.insert repo imgSet acc)
)
Map.empty
reposAndFiles
where
consult' f = do
r <- liftIO (try (consult f))
case r of
Left (e :: SomeException) -> do
dbgL
( printf
"Failed to load shared image meta-data from '%s': '%s'"
(takeFileName f)
(show e)
)
dbgL (printf "Removing bad meta-data file '%s'" f)
liftIO (removeFile f)
return Nothing
Right c -> return (Just c)
pullRemoteRepos ::
(HasCallStack, Member ExcB9 e, Lifted IO e, CommandIO e, '[SelectedRemoteRepoReader, RepoCacheReader] <:: e) =>
Eff e ()
pullRemoteRepos = do
repos <- getSelectedRepos
mapM_ dl repos
where
dl =
pullGlob
sharedImagesRootDirectory
(FileExtension sharedImageFileExtension)
pullLatestImage ::
(HasCallStack, Lifted IO e, CommandIO e, '[ExcB9, RepoCacheReader, SelectedRemoteRepoReader] <:: e) =>
SharedImageName ->
Eff e (Maybe SharedImageBuildId)
pullLatestImage name@(SharedImageName dbgName) = do
repos <- getSelectedRepos
let repoPredicate Cache = False
repoPredicate (Remote repoId) = repoId `elem` repoIds
repoIds = map remoteRepoRepoId repos
hasName sharedImage = name == sharedImageName sharedImage
candidates <-
filterRepoImagesMap repoPredicate hasName <$> getSharedImages
case maxSharedImageOfAllRepos candidates of
Nothing ->
do
errorL
( printf
"No shared image named '%s' on these remote repositories: '%s'"
dbgName
(ppShow repoIds)
)
return Nothing
Just (image, Cache) -> do
errorExitL (printf "Unreachable code reached in `pullLastestImage`: '%s' %s" dbgName (ppShow image))
Just (image, Remote repoId) -> do
dbgL (printf "PULLING SHARED IMAGE: '%s'" (ppShow image))
cacheDir <- getSharedImagesCacheDir
let (Image imgFile' _imgType _fs) = sharedImageImage image
cachedImgFile = cacheDir </> imgFile'
cachedInfoFile = cacheDir </> sharedImageFileName image
repoImgFile = sharedImagesRootDirectory </> imgFile'
repoInfoFile = sharedImagesRootDirectory </> sharedImageFileName image
repo = fromJust (lookupRemoteRepo repos repoId)
pullFromRepo repo repoImgFile cachedImgFile
pullFromRepo repo repoInfoFile cachedInfoFile
infoL (printf "PULLED '%s' FROM '%s'" dbgName repoId)
cleanOldSharedImageRevisionsFromCache name
return (Just (sharedImageBuildId image))
getLatestImageByName ::
(HasCallStack, Lifted IO e, CommandIO e, Member RepoCacheReader e) =>
SharedImageName ->
Eff e (Maybe Image)
getLatestImageByName name = do
sharedImageRevisions <- lookupCachedImages name <$> getSharedImages
cacheDir <- getSharedImagesCacheDir
let image =
changeImageDirectory cacheDir . sharedImageImage
<$> Set.lookupMax sharedImageRevisions
case image of
Just i -> dbgL (printf "USING SHARED SOURCE IMAGE '%s'" (show i))
Nothing -> errorL (printf "SOURCE IMAGE '%s' NOT FOUND" (show name))
return image
cleanOldSharedImageRevisionsFromCache ::
('[RepoCacheReader, ExcB9] <:: e, Lifted IO e, CommandIO e) =>
SharedImageName ->
Eff e ()
cleanOldSharedImageRevisionsFromCache sn = do
b9Cfg <- getConfig
forM_ (b9Cfg ^. maxLocalSharedImageRevisions) $ \maxRevisions -> do
when
(maxRevisions < 1)
( throwB9Error_
( printf
"Invalid maximum local shared images revision configuration value: %d. Please change the [global] '%s' key in the B9 configuration file to 'Just x' with 'x > 0', or to 'Nothing'."
maxRevisions
maxLocalSharedImageRevisionsK
)
)
allRevisions <- lookupCachedImages sn <$> getSharedImages
let toDelete = dropAllButNLatestSharedImages maxRevisions allRevisions
removeCachedSharedImages toDelete
cleanLocalRepoCache ::
('[RepoCacheReader, ExcB9] <:: e, Lifted IO e, CommandIO e) =>
Eff e ()
cleanLocalRepoCache = do
allCached <- allCachedSharedImages <$> getSharedImages
maxRevConfig <- view maxLocalSharedImageRevisions <$> getConfig
let maxRev = maybe 0 (max 0) maxRevConfig
byName = groupBySharedImageName allCached
toKeep =
fold
( Map.map
(keepNLatestSharedImages maxRev)
byName
)
toDelete =
fold
( Map.map
(dropAllButNLatestSharedImages maxRev)
byName
)
infoL "ALL CACHED IMAGES:"
forM_ allCached (infoL . show)
infoL ("CACHED " ++ maybe "" (("(" ++) . (++ ") ") . show) maxRevConfig ++ "IMAGES TO KEEP:")
forM_ toKeep (infoL . show)
infoL "CACHED IMAGES TO DELETE:"
forM_ toDelete (infoL . show)
removeCachedSharedImages toDelete
return ()
pushSharedImageLatestVersion ::
(Lifted IO e, CommandIO e, '[SelectedRemoteRepoReader, RepoCacheReader, ExcB9] <:: e) =>
SharedImageName ->
Eff e ()
pushSharedImageLatestVersion name@(SharedImageName imgName) =
lookupCachedImages name <$> getSharedImages
>>= maybe
(errorExitL (printf "Nothing found for %s." (show imgName)))
( \sharedImage -> do
dbgL (printf "PUSHING '%s'" (ppShow sharedImage))
pushToSelectedRepo sharedImage
infoL (printf "PUSHED '%s'" imgName)
)
. Set.lookupMax
pushToSelectedRepo ::
(Member ExcB9 e, Lifted IO e, CommandIO e, '[RepoCacheReader, SelectedRemoteRepoReader] <:: e) =>
SharedImage ->
Eff e ()
pushToSelectedRepo i = do
c <- getSharedImagesCacheDir
MkSelectedRemoteRepo r <- getSelectedRemoteRepo
when (isJust r) $ do
let (Image imgFile' _imgType _imgFS) = sharedImageImage i
cachedImgFile = c </> imgFile'
cachedInfoFile = c </> sharedImageFileName i
repoImgFile = sharedImagesRootDirectory </> imgFile'
repoInfoFile = sharedImagesRootDirectory </> sharedImageFileName i
pushToRepo (fromJust r) cachedImgFile repoImgFile
pushToRepo (fromJust r) cachedInfoFile repoInfoFile
getSelectedRepos :: '[B9ConfigReader, SelectedRemoteRepoReader] <:: e => Eff e [RemoteRepo]
getSelectedRepos = do
allRepos <- getRemoteRepos
MkSelectedRemoteRepo selectedRepo <- getSelectedRemoteRepo
let repos = maybe allRepos return selectedRepo
return repos
getSharedImagesCacheDir :: '[RepoCacheReader] <:: e => Eff e FilePath
getSharedImagesCacheDir = do
cacheDir <- localRepoDir <$> getRepoCache
return (cacheDir </> sharedImagesRootDirectory)
removeCachedSharedImages :: (CommandIO e, Member (Reader RepoCache) e) => Set SharedImage -> Eff e ()
removeCachedSharedImages toDelete =
do
imgDir <- getSharedImagesCacheDir
let filesToDelete = Set.map (imgDir </>) (infoFiles <> imgFiles)
infoFiles = Set.map sharedImageFileName toDelete
imgFiles = Set.map (imageFileName . sharedImageImage) toDelete
if Set.null filesToDelete
then infoL "NO IMAGES TO DELETE"
else do
infoL "DELETING FILES:"
forM_ filesToDelete (infoL . show)
liftIO (mapM_ removeIfExists filesToDelete)