cachix-1.7: src/Cachix/Client/Commands/Push.hs
{-# LANGUAGE QuasiQuotes #-}
module Cachix.Client.Commands.Push
( pushStrategy,
withPushParams,
withPushParams',
handleCacheResponse,
getPushSecret,
getPushSecretRequired,
)
where
import qualified Cachix.API as API
import qualified Cachix.Client.Config as Config
import Cachix.Client.Env (Env (..))
import Cachix.Client.Exception (CachixException (..))
import Cachix.Client.HumanSize (humanSize)
import Cachix.Client.OptionsParser
( PushOptions (..),
)
import Cachix.Client.Push
import Cachix.Client.Retry (retryHttp)
import Cachix.Client.Secrets
import Cachix.Client.Servant
import Cachix.Types.BinaryCache (BinaryCacheName)
import qualified Cachix.Types.BinaryCache as BinaryCache
import Control.Exception.Safe (throwM)
import Control.Retry (RetryStatus (rsIterNumber))
import qualified Data.ByteString as BS
import qualified Data.Conduit as Conduit
import Data.String.Here
import qualified Data.Text as T
import Hercules.CNix (StorePath)
import Hercules.CNix.Store (Store, storePathToPath, withStore)
import Network.HTTP.Types (status401, status404)
import Protolude hiding (toS)
import Protolude.Conv
import Servant.Auth ()
import Servant.Auth.Client
import Servant.Client.Streaming
import Servant.Conduit ()
import System.Console.AsciiProgress
import System.Console.Pretty
import System.Environment (lookupEnv)
import System.IO (hIsTerminalDevice)
pushStrategy :: Store -> Maybe Token -> PushOptions -> Text -> BinaryCache.CompressionMethod -> StorePath -> PushStrategy IO ()
pushStrategy store authToken opts name compressionMethod storePath =
PushStrategy
{ onAlreadyPresent = pass,
on401 = handleCacheResponse name authToken,
onError = throwM,
onAttempt = \_ _ -> pass,
onUncompressedNARStream = showUploadProgress,
onDone = pass,
Cachix.Client.Push.compressionMethod = compressionMethod,
Cachix.Client.Push.compressionLevel = Cachix.Client.OptionsParser.compressionLevel opts,
Cachix.Client.Push.omitDeriver = Cachix.Client.OptionsParser.omitDeriver opts
}
where
retryText :: RetryStatus -> Text
retryText retryStatus =
if rsIterNumber retryStatus == 0
then ""
else color Yellow $ "retry #" <> show (rsIterNumber retryStatus) <> " "
showUploadProgress retryStatus size = do
let hSize = toS $ humanSize $ fromIntegral size
path <- liftIO $ decodeUtf8With lenientDecode <$> storePathToPath store storePath
isTerminal <- liftIO $ hIsTerminalDevice stdout
onTick <-
if isTerminal
then do
let bar = color Blue "[:bar] " <> toS (retryText retryStatus) <> toS path <> " (:percent of " <> hSize <> ")"
barLength = T.length $ T.replace ":percent" " 0%" (T.replace "[:bar]" "" (toS bar))
progressBar <-
liftIO $
newProgressBar
def
{ pgTotal = fromIntegral size,
-- https://github.com/yamadapc/haskell-ascii-progress/issues/24
pgWidth = 20 + barLength,
pgOnCompletion = Just $ color Green "✓ " <> toS path <> " (" <> hSize <> ")",
pgFormat = bar
}
return $ liftIO . tickN progressBar . BS.length
else do
-- we append newline instead of putStrLn due to https://github.com/haskell/text/issues/242
appendErrText $ retryText retryStatus <> "Pushing " <> path <> " (" <> toS hSize <> ")\n"
return $ const pass
Conduit.awaitForever $ \chunk -> do
Conduit.yield chunk
onTick chunk
withPushParams :: Env -> PushOptions -> BinaryCacheName -> (PushParams IO () -> IO ()) -> IO ()
withPushParams env pushOpts name m = do
pushSecret <- getPushSecretRequired (config env) name
withPushParams' env pushOpts name pushSecret m
withPushParams' :: Env -> PushOptions -> BinaryCacheName -> PushSecret -> (PushParams IO () -> IO ()) -> IO ()
withPushParams' env pushOpts name pushSecret m = do
let authToken = getAuthTokenFromPushSecret pushSecret
compressionMethodBackend <- case pushSecret of
PushSigningKey {} -> pure Nothing
PushToken token -> do
res <- retryHttp $ (`runClientM` clientenv env) $ API.getCache cachixClient token name
case res of
Left err -> handleCacheResponse name authToken err
Right binaryCache -> pure (Just $ BinaryCache.preferredCompressionMethod binaryCache)
let compressionMethod =
fromMaybe BinaryCache.ZSTD (head $ catMaybes [Cachix.Client.OptionsParser.compressionMethod pushOpts, compressionMethodBackend])
withStore $ \store ->
m
PushParams
{ pushParamsName = name,
pushParamsSecret = pushSecret,
pushParamsClientEnv = clientenv env,
pushOnClosureAttempt = \full missing -> do
unless (null missing) $ do
let numMissing = length missing
numCached = length full - numMissing
putErrText $ "Pushing " <> show numMissing <> " paths (" <> show numCached <> " are already present) using " <> T.toLower (show compressionMethod) <> " to cache " <> name <> " ⏳\n"
return missing,
pushParamsStrategy = pushStrategy store authToken pushOpts name compressionMethod,
pushParamsStore = store
}
handleCacheResponse :: Text -> Maybe Token -> ClientError -> IO a
handleCacheResponse name optionalAuthToken err
| isErr err status401 && isJust optionalAuthToken = throwM $ accessDeniedBinaryCache name (failureResponseBody err)
| isErr err status401 = throwM $ notAuthenticatedBinaryCache name
| isErr err status404 = throwM $ BinaryCacheNotFound $ "Binary cache " <> name <> " does not exist."
| otherwise = throwM err
failureResponseBody :: ClientError -> Maybe ByteString
failureResponseBody (FailureResponse _ response) = Just $ toS $ responseBody response
failureResponseBody _ = Nothing
notAuthenticatedBinaryCache :: Text -> CachixException
notAuthenticatedBinaryCache name =
AccessDeniedBinaryCache $
"Binary cache " <> name <> " doesn't exist or it's private and you need a token: " <> Config.noAuthTokenError
accessDeniedBinaryCache :: Text -> Maybe ByteString -> CachixException
accessDeniedBinaryCache name maybeBody =
AccessDeniedBinaryCache $ "Binary cache " <> name <> " doesn't exist or you don't have access." <> context maybeBody
where
context Nothing = ""
context (Just body) = " Error: " <> toS body
-- | Fetch the push credentials from the environment or config.
getPushSecret ::
Config.Config ->
-- | Cache name
Text ->
IO (Either Text PushSecret)
getPushSecret config name = do
maybeAuthToken <- Config.getAuthTokenMaybe config
maybeSigningKeyEnv <- toS <<$>> lookupEnv "CACHIX_SIGNING_KEY"
let maybeSigningKeyConfig = Config.secretKey <$> head (getBinaryCache config)
case maybeSigningKeyEnv <|> maybeSigningKeyConfig of
Just signingKey ->
return $ PushSigningKey (fromMaybe (Token "") maybeAuthToken) <$> parseSigningKeyLenient signingKey
Nothing -> case maybeAuthToken of
Just authToken -> return $ Right $ PushToken authToken
Nothing -> return $ Left msg
where
-- we reverse list of caches to prioritize keys added as last
getBinaryCache c =
reverse $
filter (\bc -> Config.name bc == name) (Config.binaryCaches c)
msg :: Text
msg =
[iTrim|
Neither auth token nor signing key are present.
They are looked up via $CACHIX_AUTH_TOKEN and $CACHIX_SIGNING_KEY,
and if missing also looked up from ~/.config/cachix/cachix.dhall
Read https://mycache.cachix.org for instructions how to push to your binary cache.
|]
-- | Like 'getPushSecret', but throws a fatal error if the secret is not found.
getPushSecretRequired ::
Config.Config ->
-- | Cache name
Text ->
-- | Secret key or exception
IO PushSecret
getPushSecretRequired config name = do
epushSecret <- getPushSecret config name
case epushSecret of
-- TODO: technically, we're missing any credentials, not just the signing key
Left err -> throwIO $ NoSigningKey err
Right pushSecret -> return pushSecret
-- | Put text to stderr without a new line.
--
-- This is safe to use when printing from multiple threads, unlike hPutStrLn which may fail to insert the newline at the right place.
appendErrText :: (MonadIO m) => Text -> m ()
appendErrText = hPutStr stderr