cachix-0.3.0: src/Cachix/Client/Push.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE Rank2Types #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeOperators #-}
module Cachix.Client.Push
( -- * Pushing a single path
pushSingleStorePath,
PushCache (..),
PushStrategy (..),
defaultWithXzipCompressor,
defaultWithXzipCompressorWithLevel,
-- * Pushing a closure of store paths
pushClosure,
mapConcurrentlyBounded
)
where
import qualified Cachix.Api as Api
import Cachix.Api.Error
import Cachix.Api.Signing (fingerprint, passthroughHashSink, passthroughHashSinkB16, passthroughSizeSink)
import Cachix.Client.Exception (CachixException (..))
import Cachix.Client.Secrets
import Cachix.Client.Servant
import Cachix.Client.Store (Store)
import qualified Cachix.Client.Store as Store
import qualified Cachix.Types.NarInfoCreate as Api
import Control.Concurrent.Async (mapConcurrently)
import qualified Control.Concurrent.QSem as QSem
import Control.Exception.Safe (MonadMask, throwM)
import Control.Monad ((>=>))
import Control.Monad.Trans.Resource (ResourceT)
import Control.Retry (RetryPolicy, RetryStatus, constantDelay, limitRetries, recoverAll)
import Crypto.Sign.Ed25519
import qualified Data.ByteString.Base64 as B64
import Data.Conduit
import Data.Conduit.Lzma (compress)
import Data.Conduit.Process
import Data.IORef
import qualified Data.Set as Set
import qualified Data.Text as T
import Network.HTTP.Types (status401, status404)
import Protolude
import Servant.API
import Servant.Auth ()
import Servant.Auth.Client
import Servant.Client.Streaming hiding (ClientError)
import Servant.Conduit ()
import qualified System.Nix.Base32
import System.Process (readProcess)
data PushCache
= PushCache
{ pushCacheName :: Text,
pushCacheSigningKey :: SigningKey,
pushCacheToken :: Token
}
data PushStrategy m r
= PushStrategy
{ onAlreadyPresent :: m r, -- ^ Called when a path is already in the cache.
onAttempt :: RetryStatus -> m (),
on401 :: m r,
onError :: ClientError -> m r,
onDone :: m r,
withXzipCompressor :: forall a. (ConduitM ByteString ByteString (ResourceT IO) () -> m a) -> m a
}
defaultWithXzipCompressor :: forall m a. (ConduitM ByteString ByteString (ResourceT IO) () -> m a) -> m a
defaultWithXzipCompressor = ($ compress (Just 2))
defaultWithXzipCompressorWithLevel :: Int -> forall m a. (ConduitM ByteString ByteString (ResourceT IO) () -> m a) -> m a
defaultWithXzipCompressorWithLevel l = ($ compress (Just l))
pushSingleStorePath
:: (MonadMask m, MonadIO m)
=> ClientEnv -- ^ cachix base url, connection manager, see 'Cachix.Client.URI.defaultCachixBaseUrl', 'Servant.Client.mkClientEnv'
-> Store
-> PushCache -- ^ details for pushing to cache
-> PushStrategy m r -- ^ how to report results, (some) errors, and do some things
-> Text -- ^ store path
-> m r -- ^ r is determined by the 'PushStrategy'
pushSingleStorePath clientEnv _store cache cb storePath = retryAll $ \retrystatus -> do
let storeHash = fst $ splitStorePath $ toS storePath
name = pushCacheName cache
-- Check if narinfo already exists
-- TODO: query also cache.nixos.org? server-side?
res <-
liftIO $ (`runClientM` clientEnv)
$ Api.narinfoHead
(cachixBCClient name)
(pushCacheToken cache)
(Api.NarInfoC storeHash)
case res of
Right NoContent -> onAlreadyPresent cb -- we're done as store path is already in the cache
Left err
| isErr err status404 -> uploadStorePath clientEnv _store cache cb storePath retrystatus
| isErr err status401 -> on401 cb
| otherwise -> onError cb err
uploadStorePath
:: (MonadMask m, MonadIO m)
=> ClientEnv -- ^ cachix base url, connection manager, see 'Cachix.Client.URI.defaultCachixBaseUrl', 'Servant.Client.mkClientEnv'
-> Store
-> PushCache -- ^ details for pushing to cache
-> PushStrategy m r -- ^ how to report results, (some) errors, and do some things
-> Text -- ^ store path
-> RetryStatus
-> m r -- ^ r is determined by the 'PushStrategy'
uploadStorePath clientEnv _store cache cb storePath retrystatus = do
let (storeHash, storeSuffix) = splitStorePath $ toS storePath
name = pushCacheName cache
onAttempt cb retrystatus
narSizeRef <- liftIO $ newIORef 0
fileSizeRef <- liftIO $ newIORef 0
narHashRef <- liftIO $ newIORef ("" :: ByteString)
fileHashRef <- liftIO $ newIORef ("" :: ByteString)
-- stream store path as xz compressed nar file
let cmd = proc "nix-store" ["--dump", toS storePath]
-- TODO: print process stderr?
(ClosedStream, (stdoutStream, closeStdout), ClosedStream, cph) <- liftIO $ streamingProcess cmd
withXzipCompressor cb $ \xzCompressor -> do
let stream' =
stdoutStream
.| passthroughSizeSink narSizeRef
.| passthroughHashSink narHashRef
.| xzCompressor
.| passthroughSizeSink fileSizeRef
.| passthroughHashSinkB16 fileHashRef
-- for now we need to use letsencrypt domain instead of cloudflare due to its upload limits
let newClientEnv =
clientEnv
{ baseUrl = (baseUrl clientEnv) {baseUrlHost = toS name <> "." <> baseUrlHost (baseUrl clientEnv)}
}
(_ :: NoContent) <-
liftIO
$ (`withClientM` newClientEnv)
(Api.createNar (cachixBCStreamingClient name) stream')
$ escalate
>=> \NoContent -> do
closeStdout
exitcode <- waitForStreamingProcess cph
when (exitcode /= ExitSuccess) $ throwM $ NarStreamingError exitcode $ show cmd
-- TODO: we're done, so we can leave withClientM. Doing so
-- will allow more concurrent requests when the number
-- of XZ compressors is limited
narSize <- readIORef narSizeRef
narHash <- ("sha256:" <>) . System.Nix.Base32.encode <$> readIORef narHashRef
fileHash <- readIORef fileHashRef
fileSize <- readIORef fileSizeRef
deriverRaw <- T.strip . toS <$> readProcess "nix-store" ["-q", "--deriver", toS storePath] mempty
let deriver =
if deriverRaw == "unknown-deriver"
then deriverRaw
else T.drop 11 deriverRaw
references <- sort . T.lines . T.strip . toS <$> readProcess "nix-store" ["-q", "--references", toS storePath] mempty
let fp = fingerprint storePath narHash narSize references
sig = dsign (signingSecretKey $ pushCacheSigningKey cache) fp
nic = Api.NarInfoCreate
{ cStoreHash = storeHash,
cStoreSuffix = storeSuffix,
cNarHash = narHash,
cNarSize = narSize,
cFileSize = fileSize,
cFileHash = toS fileHash,
cReferences = fmap (T.drop 11) references,
cDeriver = deriver,
cSig = toS $ B64.encode $ unSignature sig
}
escalate $ Api.isNarInfoCreateValid nic
-- Upload narinfo with signature
escalate <=< (`runClientM` clientEnv)
$ Api.createNarinfo
(cachixBCClient name)
(Api.NarInfoC storeHash)
nic
onDone cb
-- Catches all exceptions except skipAsyncExceptions
retryAll :: (MonadIO m, MonadMask m) => (RetryStatus -> m a) -> m a
retryAll = recoverAll defaultRetryPolicy
where
defaultRetryPolicy :: RetryPolicy
defaultRetryPolicy =
constantDelay 50000 <> limitRetries 3
-- | Push an entire closure
--
-- Note: 'onAlreadyPresent' will be called less often in the future.
pushClosure
:: (MonadIO m, MonadMask m)
=> (forall a b. (a -> m b) -> [a] -> m [b])
-- ^ Traverse paths, responsible for bounding parallel processing of paths
--
-- For example: @'mapConcurrentlyBounded' 4@
-> ClientEnv -- ^ See 'pushSingleStorePath'
-> Store
-> PushCache
-> (Text -> PushStrategy m r)
-> [Text] -- ^ Initial store paths
-> m [r] -- ^ Every @r@ per store path of the entire closure of store paths
pushClosure traversal clientEnv store pushCache pushStrategy inputStorePaths = do
-- Get the transitive closure of dependencies
paths <-
liftIO $ do
inputs <- Store.newEmptyPathSet
for_ inputStorePaths $ \path -> do
normalized <- Store.followLinksToStorePath store (encodeUtf8 path)
Store.addToPathSet normalized inputs
closure <- Store.computeFSClosure store Store.defaultClosureParams inputs
Store.traversePathSet (pure . toSL) closure
-- Check what store paths are missing
-- TODO: query also cache.nixos.org? server-side?
missingHashesList <-
retryAll $ \_ ->
escalate
=<< liftIO
( (`runClientM` clientEnv)
$ Api.narinfoBulk
(cachixBCClient (pushCacheName pushCache))
(pushCacheToken pushCache)
(fst . splitStorePath <$> paths)
)
let missingHashes = Set.fromList missingHashesList
missingPaths = filter (\path -> Set.member (fst (splitStorePath path)) missingHashes) paths
-- TODO: make pool size configurable, on beefier machines this could be doubled
traversal (\path -> retryAll $ \retrystatus -> uploadStorePath clientEnv store pushCache (pushStrategy path) path retrystatus) missingPaths
mapConcurrentlyBounded :: Traversable t => Int -> (a -> IO b) -> t a -> IO (t b)
mapConcurrentlyBounded bound action items = do
qs <- QSem.newQSem bound
let wrapped x = bracket_ (QSem.waitQSem qs) (QSem.signalQSem qs) (action x)
mapConcurrently wrapped items
-------------------
-- Private terms --
splitStorePath :: Text -> (Text, Text)
splitStorePath storePath =
(T.take 32 (T.drop 11 storePath), T.drop 44 storePath)