packages feed

cachix-0.2.0: src/Cachix/Client/Commands.hs

{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
module Cachix.Client.Commands
  ( authtoken
  , create
  , generateKeypair
  , push
  , use
  ) where

import           Crypto.Sign.Ed25519
import           Control.Concurrent           (threadDelay)
import qualified Control.Concurrent.QSem       as QSem
import           Control.Concurrent.Async     (mapConcurrently)
import           Control.Monad                (forever, (>=>))
import           Control.Exception.Safe       (MonadThrow, throwM)
import           Control.Retry                (recoverAll, RetryStatus(rsIterNumber))
import qualified Data.ByteString.Base64        as B64
import           Data.Conduit
import           Data.Default                  (def)
import           Data.Conduit.Process
import           Data.Conduit.Lzma              ( compress )
import           Data.IORef
import           Data.List                      ( isSuffixOf )
import           Data.Maybe                     ( fromJust )
import           Data.String.Here
import qualified Data.Text                      as T
import           Network.HTTP.Types             (status404, status401)
import           Protolude
import           Servant.API
import           Servant.Auth                   ()
import           Servant.Auth.Client
import           Servant.API.Generic
import           Servant.Client.Generic
import           Servant.Client.Streaming
import           Servant.Conduit                ()
import           System.Directory               ( doesFileExist )
import           System.FSNotify
import           System.IO                      ( stdin, hIsTerminalDevice )
import           System.Process                 ( readProcess )
import           System.Environment             ( lookupEnv )

import qualified Cachix.Api                    as Api
import           Cachix.Api.Error
import           Cachix.Api.Signing             (fingerprint, passthroughSizeSink, passthroughHashSink)
import qualified Cachix.Types.NarInfoCreate    as Api
import qualified Cachix.Types.BinaryCacheCreate as Api
import qualified Cachix.Types.SigningKeyCreate as SigningKeyCreate
import           Cachix.Client.Config           ( Config(..)
                                                , BinaryCacheConfig(..)
                                                , writeConfig
                                                , mkConfig
                                                )
import qualified Cachix.Client.Config          as Config
import           Cachix.Client.Env              ( Env(..) )
import           Cachix.Client.Exception        ( CachixException(..) )
import           Cachix.Client.OptionsParser    ( CachixOptions(..), UseOptions(..) )
import           Cachix.Client.InstallationMode
import           Cachix.Client.NixVersion       ( getNixVersion )
import qualified Cachix.Client.NixConf         as NixConf
import           Cachix.Client.Servant


cachixClient :: Api.CachixAPI (AsClientT ClientM)
cachixClient = fromServant $ client Api.servantApi

cachixBCClient :: Text -> Api.BinaryCacheAPI (AsClientT ClientM)
cachixBCClient name = fromServant $ Api.cache cachixClient name

cachixBCStreamingClient :: Text -> Api.BinaryCacheStreamingAPI (AsClientT ClientM)
cachixBCStreamingClient name = fromServant $ client (Proxy :: Proxy Api.BinaryCachStreamingServantAPI) name

authtoken :: Env -> Text -> IO ()
authtoken env token = do
  -- TODO: check that token actually authenticates!
  writeConfig (configPath (cachixoptions env)) $ case config env of
    Just config -> config { authToken = Token (toS token) }
    Nothing -> mkConfig token
  putStrLn ([hereLit|
Continue by creating a binary cache at https://cachix.org
  |] :: Text)

create :: Env -> Text -> IO ()
create _ _ =
  throwIO $ DeprecatedCommand "Create command has been deprecated. Please visit https://cachix.org to create a binary cache."

generateKeypair :: Env -> Text -> IO ()
generateKeypair Env { config = Nothing } _ = throwIO $ NoConfig "Start with visiting https://cachix.org and copying the token to $ cachix authtoken <token>"
generateKeypair env@Env { config = Just config } name = do
  (PublicKey pk, SecretKey sk) <- createKeypair

  let signingKey = toS $ B64.encode sk
      signingKeyCreate = SigningKeyCreate.SigningKeyCreate (toS $ B64.encode pk)
      bcc = BinaryCacheConfig name signingKey

  -- we first validate if key can be added to the binary cache
  escalate =<< ((`runClientM` clientenv env) $ Api.createKey (cachixBCClient name) (authToken config) signingKeyCreate)

  -- if key was successfully added, write it to the config
  -- TODO: this breaks if more than one key is added, see #27
  writeConfig (configPath (cachixoptions env)) $
    config { binaryCaches = binaryCaches config <> [bcc] }

  putStrLn ([iTrim|
Secret signing key has been saved in the file above. To populate
your binary cache:

    $ nix-build | cachix push ${name}

Or if you'd like to use the signing key on another machine or CI:

    $ export CACHIX_SIGNING_KEY=${signingKey}
    $ nix-build | cachix push ${name}

To instruct Nix to use the binary cache:

    $ cachix use ${name}

IMPORTANT: Make sure to make a backup for the signing key above, as you have the only copy.
  |] :: Text)

envToToken :: Env -> Token
envToToken env =
  maybe (Token "") authToken (config env)

notAuthenticatedBinaryCache :: CachixException
notAuthenticatedBinaryCache =
  AccessDeniedBinaryCache "You must first authenticate using:  $ cachix authtoken <token>"

accessDeniedBinaryCache :: Text -> CachixException
accessDeniedBinaryCache name =
  AccessDeniedBinaryCache $ "You don't seem to have API access to binary cache " <> name

use :: Env -> Text -> UseOptions -> IO ()
use env name useOptions = do
  -- 1. get cache public key
  res <- (`runClientM` clientenv env) $ Api.get (cachixBCClient name) (envToToken env)
  case res of
    Left err | isErr err status401 && isJust (config env) -> throwM $ accessDeniedBinaryCache name
             | isErr err status401 -> throwM notAuthenticatedBinaryCache
             | isErr err status404 -> throwM $ BinaryCacheNotFound $ "Binary cache" <> name <> " does not exist."
             | otherwise -> throwM err
    Right binaryCache -> do
      nixVersion <- escalateAs UnsupportedNixVersion =<< getNixVersion
      user <- getUser
      nc <- NixConf.read NixConf.Global
      isTrusted <- isTrustedUser $ NixConf.readLines (catMaybes [nc]) NixConf.isTrustedUsers
      isNixOS <- doesFileExist "/etc/NIXOS"
      let nixEnv = NixEnv
            { nixVersion = nixVersion
            , isRoot = user == "root"
            , isTrusted = isTrusted
            , isNixOS = isNixOS
            }
      addBinaryCache (config env) binaryCache useOptions $
        if useNixOS useOptions
        then EchoNixOS nixVersion
        else getInstallationMode nixEnv


-- TODO: lots of room for perfomance improvements
push :: Env -> Text -> [Text] -> Bool -> IO ()
push env name rawPaths False = do
  hasNoStdin <- hIsTerminalDevice stdin
  when (not hasNoStdin && not (null rawPaths)) $ throwIO $ AmbiguousInput "You provided both stdin and store path arguments, pick only one to proceed."
  inputStorePaths <-
    if hasNoStdin
    then return rawPaths
    else T.words <$> getContents

  -- TODO: if empty, take whole nix store and warn: nix store-path --all
  when (null inputStorePaths) $ throwIO $ NoInput "You need to specify store paths either as stdin or as a cli argument"

  -- Query list of paths
  -- TODO: split args if too big
  paths <- T.lines . toS <$> readProcess "nix-store" (fmap toS (["-qR"] <> inputStorePaths)) mempty

  -- TODO: make pool size configurable, on beefier machines this could be doubled
  _ <- mapConcurrentlyBounded 4 (pushStorePath env name) paths
  putText "All done."
push env name _ True = withManager $ \mgr -> do
  _ <- watchDir mgr "/nix/store" filterF action
  putText "Watching /nix/store for new builds ..."
  forever $ threadDelay 1000000
  where
    action :: Action
#if MIN_VERSION_fsnotify(0,3,0)
    action (Removed fp _ _) =
#else
    action (Removed fp _) =
#endif
      pushStorePath env name $ toS $ dropEnd 5 fp
    action _ = return ()

    filterF :: ActionPredicate
#if MIN_VERSION_fsnotify(0,3,0)
    filterF (Removed fp _ _)
#else
    filterF (Removed fp _)
#endif
      | ".lock" `isSuffixOf` fp = True
    filterF _ = False

    dropEnd :: Int -> [a] -> [a]
    dropEnd index xs = take (length xs - index) xs

pushStorePath :: Env -> Text -> Text -> IO ()
pushStorePath env name storePath = retryPath $ \retrystatus -> do
  -- use secret key from config or env
  -- TODO: this shouldn't happen for each store path
  maybeEnvSK <- lookupEnv "CACHIX_SIGNING_KEY"
  let matches Config{..} = filter (\bc -> Config.name bc == name) binaryCaches
      maybeBCSK = case config env of
        Nothing -> Nothing
        Just c -> Config.secretKey <$> head (matches c)
      -- TODO: better error msg
      sk = SecretKey $ toS $ B64.decodeLenient $ toS $ fromJust $ maybeBCSK <|> toS <$> maybeEnvSK <|> panic "You need to: export CACHIX_SIGNING_KEY=XXX"
      (storeHash, _) = splitStorePath $ toS storePath
  -- Check if narinfo already exists
  -- TODO: query also cache.nixos.org? server-side?
  res <- (`runClientM` clientenv env) $ Api.narinfoHead
    (cachixBCClient name)
    (envToToken env)
    (Api.NarInfoC storeHash)
  case res of
    Right NoContent -> pass -- we're done as store path is already in the cache
    Left err | isErr err status404 -> go sk retrystatus
             | isErr err status401 && isJust (config env) -> throwM $ accessDeniedBinaryCache name
             | isErr err status401 -> throwM notAuthenticatedBinaryCache
             | otherwise -> throwM err
    where
      retryText :: RetryStatus -> Text
      retryText retrystatus =
        if rsIterNumber retrystatus == 0
        then ""
        else "(retry #" <> show (rsIterNumber retrystatus) <> ") "
      go sk retrystatus = do
        let (storeHash, storeSuffix) = splitStorePath $ toS storePath
        -- we append newline instead of putStrLn due to https://github.com/haskell/text/issues/242
        putStr $ "pushing " <> retryText retrystatus <> storePath <> "\n"

        narSizeRef <- liftIO $ newIORef 0
        fileSizeRef <- liftIO $ newIORef 0
        narHashRef <- liftIO $ newIORef ("" :: Text)
        fileHashRef <- liftIO $ newIORef ("" :: Text)

        -- stream store path as xz compressed nar file
        let cmd = proc "nix-store" ["--dump", toS storePath]
        (ClosedStream, (stdoutStream, closeStdout), ClosedStream, cph) <- streamingProcess cmd
        let stream'
              = stdoutStream
             .| passthroughSizeSink narSizeRef
             .| passthroughHashSink narHashRef
             .| compress Nothing
             .| passthroughSizeSink fileSizeRef
             .| passthroughHashSink fileHashRef

        -- for now we need to use letsencrypt domain instead of cloudflare due to its upload limits
        let newClientEnv = (clientenv env) {
              baseUrl = (baseUrl (clientenv env)) { baseUrlHost = toS name <> "." <> baseUrlHost (baseUrl (clientenv env))}
            }
        void $ (`withClientM` newClientEnv)
            (Api.createNar (cachixBCStreamingClient name) stream')
            $ escalate >=> \NoContent -> do
                closeStdout
                exitcode <- waitForStreamingProcess cph
                -- TODO: print process stderr?
                when (exitcode /= ExitSuccess) $ throwM $ NarStreamingError exitcode $ show cmd

                narSize <- readIORef narSizeRef
                narHashB16 <- readIORef narHashRef
                fileHash <- readIORef fileHashRef
                fileSize <- readIORef fileSizeRef

                -- TODO: #3: implement using pure haskell
                narHash <- ("sha256:" <>) . T.strip . toS <$> readProcess "nix-hash" ["--type", "sha256", "--to-base32", toS narHashB16] mempty

                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 sk fp
                    nic = Api.NarInfoCreate
                      { cStoreHash = storeHash
                      , cStoreSuffix = storeSuffix
                      , cNarHash = narHash
                      , cNarSize = narSize
                      , cFileSize = fileSize
                      , cFileHash = 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 env) $ Api.createNarinfo
                  (cachixBCClient name)
                  (Api.NarInfoC storeHash)
                  nic


-- Utils

-- Retry up to 5 times for each store path.
-- Catches all exceptions except skipAsyncExceptions
retryPath :: (RetryStatus -> IO a) -> IO a
retryPath = recoverAll def

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

splitStorePath :: Text -> (Text, Text)
splitStorePath storePath =
  (T.take 32 (T.drop 11 storePath), T.drop 44 storePath)