packages feed

inferno-vc-0.1.0: src/Inferno/VersionControl/Operations.hs

{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

-- |
-- Module      : Inferno.VersionControl.Operations
-- Description : Operations on the Inferno version control store
--
-- This module defines operations on the Inferno VC store. The store structure is as follows:
--
-- * `<storePath>` stores the JSON serialised `VCMeta VCObject`s, where the filename is the cryptographic hash (`VCOBjectHash`) of the object's contents
-- * `<storePath>/heads` is a set of current HEAD objects of the store, which can be seen as the roots of the VC tree
-- * `<storePath>/to_head` is a map from every `VCOBjectHash` to its current HEAD, where the file name is the source hash and the contents of the file are the HEAD hash
-- * `<storePath>/deps` is a map from every `VCOBjectHash` to its (transitive) dependencies, i.e. the file `<storePath>/deps/<hash>` describes the closure of `<hash>`
module Inferno.VersionControl.Operations where

import Control.Monad (filterM, foldM, forM, forM_)
import Control.Monad.Error.Lens (throwing)
import Control.Monad.Except (MonadError)
import Control.Monad.IO.Class (MonadIO (..))
import Control.Monad.Reader (MonadReader (..))
import Crypto.Hash (digestFromByteString)
import Data.Aeson (FromJSON, ToJSON, Value, eitherDecode, encode)
import qualified Data.ByteString as B
import qualified Data.ByteString.Base64.URL as Base64
import qualified Data.ByteString.Char8 as Char8
import qualified Data.ByteString.Lazy as BL
import Data.Generics.Product (HasType, getTyped)
import Data.Generics.Sum (AsType (..))
import qualified Data.List as List
import qualified Data.Map as Map
import qualified Data.Set as Set
import Data.Text (pack)
import Data.Time.Clock.POSIX (getPOSIXTime)
import Foreign.C.Types (CTime (..))
import GHC.Generics (Generic)
import Inferno.Types.Syntax (Dependencies (..))
import Inferno.VersionControl.Log (VCServerTrace (..))
import Inferno.VersionControl.Operations.Error (VCStoreError (..))
import Inferno.VersionControl.Types
  ( VCHashUpdate,
    VCMeta (..),
    VCObject (..),
    VCObjectHash (..),
    VCObjectPred (..),
    VCObjectVisibility (..),
    vcHash,
    vcObjectHashToByteString,
  )
import Plow.Logging (IOTracer, traceWith)
import System.Directory (createDirectoryIfMissing, doesFileExist, getDirectoryContents, removeFile, renameFile)
import System.FilePath.Posix (takeFileName, (</>))

newtype VCStorePath = VCStorePath FilePath deriving (Generic)

type VCStoreErrM err m = (AsType VCStoreError err, MonadError err m, MonadIO m)

type VCStoreLogM env m = (HasType (IOTracer VCServerTrace) env, MonadReader env m, MonadIO m)

type VCStoreEnvM env m = (HasType VCStorePath env, MonadReader env m, MonadIO m)

trace :: VCStoreLogM env m => VCServerTrace -> m ()
trace t = do
  tracer <- getTyped <$> ask
  traceWith @IOTracer tracer t

throwError :: (VCStoreLogM env m, VCStoreErrM err m) => VCStoreError -> m a
throwError e = do
  trace $ ThrownVCStoreError e
  throwing _Typed e

initVCStore :: VCStoreEnvM env m => m ()
initVCStore =
  (getTyped <$> ask) >>= \(VCStorePath storePath) -> liftIO $ do
    createDirectoryIfMissing True $ storePath </> "heads"
    createDirectoryIfMissing True $ storePath </> "to_head"
    createDirectoryIfMissing True $ storePath </> "deps"

initVCCachedClient :: VCStoreEnvM env m => m ()
initVCCachedClient =
  (getTyped <$> ask) >>= \(VCStorePath storePath) -> liftIO $ do
    createDirectoryIfMissing True $ storePath </> "deps"

checkPathExists :: (VCStoreLogM env m, VCStoreErrM err m) => FilePath -> m ()
checkPathExists fp =
  liftIO (doesFileExist fp) >>= \case
    False -> throwError $ CouldNotFindPath fp
    True -> pure ()

getDepsFromStore :: (VCStoreLogM env m, VCStoreErrM err m) => FilePath -> VCObjectHash -> m BL.ByteString
getDepsFromStore path h = do
  let fp = path </> show h
  checkPathExists fp
  liftIO $ BL.readFile $ path </> show h

appendBS :: (VCStoreLogM env m) => FilePath -> BL.ByteString -> m ()
appendBS fp bs = do
  trace $ WriteTxt fp
  liftIO $ BL.appendFile fp bs

writeBS :: (VCStoreLogM env m) => FilePath -> BL.ByteString -> m ()
writeBS fp bs = do
  trace $ WriteTxt fp
  liftIO $ BL.writeFile fp bs

writeHashedJSON :: (VCStoreLogM env m, VCHashUpdate obj, ToJSON obj) => FilePath -> obj -> m VCObjectHash
writeHashedJSON path o = do
  let h = vcHash o
  exists <- liftIO $ doesFileExist path
  if exists
    then trace $ AlreadyExistsJSON (path </> show h)
    else do
      trace $ WriteJSON (path </> show h)
      liftIO $ BL.writeFile (path </> show h) $ encode o
  pure h

readVCObjectHashTxt :: (VCStoreLogM env m, VCStoreErrM err m) => FilePath -> m [VCObjectHash]
readVCObjectHashTxt fp = do
  checkPathExists fp
  trace $ ReadTxt fp
  deps <- filter (not . B.null) . Char8.lines <$> (liftIO $ B.readFile fp)
  forM deps $ \dep -> do
    decoded <- either (const $ throwError $ InvalidHash $ Char8.unpack dep) pure $ Base64.decode dep
    maybe (throwError $ InvalidHash $ Char8.unpack dep) (pure . VCObjectHash) $ digestFromByteString decoded

storeVCObject :: (VCStoreLogM env m, VCStoreErrM err m, VCStoreEnvM env m, VCHashUpdate a, VCHashUpdate g, ToJSON a, ToJSON g) => VCMeta a g VCObject -> m VCObjectHash
storeVCObject obj@VCMeta {obj = ast, pred = p} = do
  VCStorePath storePath <- getTyped <$> ask
  -- if the new object has a direct predecessor (i.e. is not a clone or an initial commit)
  --  we need to make sure that the predecessor is currently a HEAD object in the store
  let maybeCurrentHead = case p of
        Init -> Nothing
        CloneOf _ -> Nothing
        CloneOfRemoved _ -> Nothing
        CloneOfNotFound _ -> Nothing
        CompatibleWithPred h -> Just h
        IncompatibleWithPred h _ -> Just h
        MarkedBreakingWithPred h -> Just h

  obj_h <- case maybeCurrentHead of
    Just pred_hash -> do
      let head_fp = storePath </> "heads" </> show pred_hash
      -- check to see if pred_hash exists in '<storePath>/heads`
      exists_head <- liftIO $ doesFileExist head_fp
      if exists_head
        then do
          -- we know that pred_h is currently HEAD, we can therefore store the object and metadata in the store
          obj_h <- writeHashedJSON storePath obj
          -- next we make the newly added object the HEAD
          let new_head_fp = storePath </> "heads" </> show obj_h
          liftIO $ renameFile head_fp new_head_fp
          -- we append the previous head hash to the file (this serves as lookup for all the predecessors)
          appendBS new_head_fp $ BL.fromStrict $ vcObjectHashToByteString pred_hash <> "\n"
          -- now we need to change all the predecessor mappings in '<storePath>/to_head' to point to the new HEAD
          -- we also include the new head pointing to itself
          preds <- readVCObjectHashTxt new_head_fp
          let obj_h_bs = BL.fromStrict $ vcObjectHashToByteString obj_h
          forM_ (obj_h : preds) $ \pred_h ->
            writeBS (storePath </> "to_head" </> show pred_h) obj_h_bs

          pure obj_h
        else throwError $ TryingToAppendToNonHead pred_hash
    Nothing -> do
      obj_h <- writeHashedJSON storePath obj
      -- as there is no previous HEAD for this object, we simply create a new one
      let new_head_fp = storePath </> "heads" </> show obj_h
      appendBS new_head_fp $ case p of
        -- in case this is a clone of another object, we add its hash to the history
        CloneOf clone_h -> BL.fromStrict $ vcObjectHashToByteString clone_h <> "\n"
        _ -> mempty

      -- we again make sure to add a self reference link to the '<storePath>/to_head' map
      let obj_h_bs = BL.fromStrict $ vcObjectHashToByteString obj_h
      writeBS (storePath </> "to_head" </> show obj_h) obj_h_bs
      pure obj_h

  -- finally, we store the dependencies of the commited object by fetching the dependencies from the AST
  let deps = Set.toList $ getDependencies ast
  writeBS (storePath </> "deps" </> show obj_h) mempty
  forM_ deps $ \dep_h -> do
    -- first we append the direct dependency hash 'dep_h'
    appendBS (storePath </> "deps" </> show obj_h) $ BL.fromStrict $ vcObjectHashToByteString dep_h <> "\n"
    -- then we append the transitive dependencies of the given object, pointed to by the hash 'dep_h'
    appendBS (storePath </> "deps" </> show obj_h) =<< getDepsFromStore (storePath </> "deps") dep_h

  pure obj_h

-- | Delete a temporary object from the VC. This is used for autosaved scripts
-- and to run tests against unsaved scripts
deleteAutosavedVCObject :: (VCStoreLogM env m, VCStoreErrM err m, VCStoreEnvM env m) => VCObjectHash -> m ()
deleteAutosavedVCObject obj_hash = do
  VCStorePath storePath <- getTyped <$> ask
  -- check if object meta exists with hash meta_hash, and get meta
  (VCMeta {name = obj_name} :: VCMeta Value Value VCObject) <- fetchVCObject obj_hash
  -- check that it is safe to delete
  if obj_name == pack "<AUTOSAVE>"
    then do
      -- delete object, object meta, head/to_head, and deps
      deleteFile $ (storePath </> show obj_hash)
      deleteFile $ (storePath </> "heads" </> show obj_hash)
      deleteFile $ (storePath </> "to_head" </> show obj_hash)
      deleteFile $ (storePath </> "deps" </> show obj_hash)
    else throwError $ TryingToDeleteNonAutosave obj_name
  where
    deleteFile fp = do
      trace $ DeleteFile fp
      liftIO $ removeFile fp

-- | Deletes all stale autosaved objects from the VC.
deleteStaleAutosavedVCObjects :: (VCStoreLogM env m, VCStoreErrM err m, VCStoreEnvM env m) => m ()
deleteStaleAutosavedVCObjects = do
  -- We know that all autosaves must be heads:
  heads <- getAllHeads
  forM_
    heads
    ( \h -> do
        -- fetch object, check name and timestamp
        (VCMeta {name, timestamp} :: VCMeta Value Value VCObject) <- fetchVCObject h
        now <- liftIO $ CTime . round . toRational <$> getPOSIXTime
        if name == pack "<AUTOSAVE>" && timestamp < now - 60 * 60
          then -- delete the stale ones (> 1hr old)
            deleteAutosavedVCObject h
          else pure ()
    )

-- | Soft delete script and its predecessors
-- All scripts and their references are moved to "removed" directory
deleteVCObjects :: (VCStoreLogM env m, VCStoreErrM err m, VCStoreEnvM env m) => VCObjectHash -> m ()
deleteVCObjects obj_hash = do
  VCStorePath storePath <- getTyped <$> ask
  liftIO $ do
    createDirectoryIfMissing True $ storePath </> "removed"
    createDirectoryIfMissing True $ storePath </> "removed" </> "heads"
    createDirectoryIfMissing True $ storePath </> "removed" </> "to_head"
    createDirectoryIfMissing True $ storePath </> "removed" </> "deps"

  (metas :: [VCMeta Value Value VCObjectHash]) <- fetchVCObjectHistory obj_hash
  forM_ metas $ \VCMeta {obj = hash} -> do
    forM_
      [ show hash,
        "heads" </> show hash,
        "to_head" </> show hash,
        "deps" </> show hash
      ]
      $ \source_fp -> safeRenameFile (storePath </> source_fp) (storePath </> "removed" </> source_fp)
  where
    safeRenameFile source target = do
      liftIO (doesFileExist source) >>= \case
        False -> pure ()
        True -> liftIO $ renameFile source target

fetchVCObject :: (VCStoreLogM env m, VCStoreErrM err m, VCStoreEnvM env m, FromJSON a, FromJSON g) => VCObjectHash -> m (VCMeta a g VCObject)
fetchVCObject = fetchVCObject' Nothing

-- | Fetch object from removed directory
fetchRemovedVCObject :: (VCStoreLogM env m, VCStoreErrM err m, VCStoreEnvM env m, FromJSON a, FromJSON g) => VCObjectHash -> m (VCMeta a g VCObject)
fetchRemovedVCObject = fetchVCObject' (Just "removed")

fetchVCObject' :: (VCStoreLogM env m, VCStoreErrM err m, VCStoreEnvM env m, FromJSON a, FromJSON g) => Maybe FilePath -> VCObjectHash -> m (VCMeta a g VCObject)
fetchVCObject' mprefix h = do
  VCStorePath storePath <- getTyped <$> ask
  let fp = case mprefix of
        Nothing -> storePath </> show h
        Just prefix -> storePath </> prefix </> show h
  checkPathExists fp
  trace $ ReadJSON fp
  either (throwError . CouldNotDecodeObject h) pure =<< (liftIO $ eitherDecode <$> BL.readFile fp)

fetchVCObjects :: (VCStoreLogM env m, VCStoreErrM err m, VCStoreEnvM env m, FromJSON a, FromJSON g) => [VCObjectHash] -> m (Map.Map VCObjectHash (VCMeta a g VCObject))
fetchVCObjects hs = do
  Map.fromList <$> (forM hs $ \h -> (h,) <$> fetchVCObject h)

fetchVCObjectClosureHashes :: (VCStoreLogM env m, VCStoreErrM err m, VCStoreEnvM env m) => VCObjectHash -> m [VCObjectHash]
fetchVCObjectClosureHashes h = do
  VCStorePath storePath <- getTyped <$> ask
  let fp = storePath </> "deps" </> show h
  readVCObjectHashTxt fp

fetchVCObjectWithClosure :: (VCStoreLogM env m, VCStoreErrM err m, VCStoreEnvM env m, FromJSON a, FromJSON g) => VCObjectHash -> m (Map.Map VCObjectHash (VCMeta a g VCObject))
fetchVCObjectWithClosure h = do
  deps <- fetchVCObjectClosureHashes h
  Map.fromList <$> (forM deps $ \dep -> (dep,) <$> fetchVCObject dep)

calculateMissingVCObjects :: VCStoreEnvM env m => [VCObjectHash] -> m [VCObjectHash]
calculateMissingVCObjects = filterM $ \h -> do
  VCStorePath storePath <- getTyped <$> ask
  not <$> (liftIO $ doesFileExist $ storePath </> show h)

fetchCurrentHead :: (VCStoreLogM env m, VCStoreErrM err m, VCStoreEnvM env m) => VCObjectHash -> m VCObjectHash
fetchCurrentHead h = do
  VCStorePath storePath <- getTyped <$> ask
  let fp = storePath </> "to_head" </> show h
  exists <- liftIO $ doesFileExist fp
  if exists
    then
      readVCObjectHashTxt fp >>= \case
        [h'] -> pure h'
        _ -> throwError $ CouldNotFindHead h
    else throwError $ CouldNotFindHead h

fetchVCObjectHistory :: (VCStoreLogM env m, VCStoreErrM err m, VCStoreEnvM env m, FromJSON a, FromJSON g) => VCObjectHash -> m [VCMeta a g VCObjectHash]
fetchVCObjectHistory h = do
  head_h <- fetchCurrentHead h
  VCStorePath storePath <- getTyped <$> ask
  let head_fp = storePath </> "heads" </> show head_h
  preds <- readVCObjectHashTxt head_fp
  -- When we fold the preds, we check if they exist in two places
  -- 1. in 'vc_store' for available scripts
  -- 2. then in 'vc_store/removed' for scripts that have been deleted
  -- If a script has been deleted, we track its hash.
  let f acc hsh = do
        existsInRoot <- liftIO $ doesFileExist $ storePath </> show hsh
        existsInRemoved <- liftIO $ doesFileExist $ storePath </> "removed" </> show hsh

        if existsInRoot
          then do
            obj <- fmap (const hsh) <$> fetchVCObject hsh
            pure ((obj : fst acc), snd acc)
          else do
            if existsInRemoved
              then do
                obj <- fmap (const hsh) <$> fetchRemovedVCObject hsh
                pure ((obj : fst acc), (hsh : snd acc))
              else -- This script no longer exists even in 'removed' directory. The directory might get cleaned up by accident or something.
              -- There are two choices we can make,
              -- 1. Return a `VCMeta VCObjectHash` with dummy data
              -- 2. Ignore this meta.
              -- Approach no. 2 is taken here by just returning the accumulator.
                pure acc
  (metas, removeds) <- foldM f ([], []) (head_h : preds)
  -- We like to know if the source of the clone still exists. We can do this by checking against the deleted hashes
  -- that we tracked above.
  pure $ case removeds of
    [] -> metas
    _ ->
      fmap
        ( \meta -> case Inferno.VersionControl.Types.pred meta of
            CloneOf hsh'
              | List.elem hsh' removeds ->
                -- The source of the clone script has been deleted, so we alter its 'pred' field as 'CloneOfRemoved' but
                -- with the same hash. This way the upstream system (e.g. onping/frontend) can differentiate between
                -- source that is still available and no longer available.
                -- This does not change the way the script is persisted in the db, it is still stored as 'CloneOf'.
                -- See 'CloneOfRemoved' for details.
                meta {Inferno.VersionControl.Types.pred = CloneOfRemoved hsh'}
            _ -> meta
        )
        metas

getAllHeads :: (VCStoreLogM env m, VCStoreEnvM env m) => m [VCObjectHash]
getAllHeads = do
  VCStorePath storePath <- getTyped <$> ask
  headsRaw <- liftIO $ getDirectoryContents $ storePath </> "heads"
  pure $
    foldr
      ( \hd xs ->
          case (either (const $ Nothing) Just $ Base64.decode $ Char8.pack hd) >>= digestFromByteString of
            Just hsh -> (VCObjectHash hsh) : xs
            Nothing -> xs
      )
      []
      (map takeFileName headsRaw)

fetchFunctionsForGroups :: (VCStoreLogM env m, VCStoreErrM err m, VCStoreEnvM env m, Ord g, FromJSON a, FromJSON g) => Set.Set g -> m [VCMeta a g VCObjectHash]
fetchFunctionsForGroups grps = do
  heads <- getAllHeads
  foldM
    ( \objs hsh -> do
        meta@VCMeta {obj, visibility, group} <- fetchVCObject hsh
        pure $ case obj of
          VCFunction _ _ ->
            if visibility == VCObjectPublic || group `Set.member` grps
              then (fmap (const hsh) meta) : objs
              else objs
          _ -> objs
    )
    []
    heads