packages feed

cachix-1.3: src/Cachix/Deploy/ActivateCommand.hs

module Cachix.Deploy.ActivateCommand where

import qualified Cachix.API.Deploy.V1 as API.V1
import qualified Cachix.API.Deploy.V2 as API.V2
import Cachix.API.Error (escalate)
import qualified Cachix.API.WebSocketSubprotocol as WSS
import qualified Cachix.Client.Config as Config
import qualified Cachix.Client.Env as Env
import qualified Cachix.Client.Retry as Retry
import Cachix.Client.Servant (deployClientV1, deployClientV2)
import qualified Cachix.Client.URI as URI
import Cachix.Client.Version (versionNumber)
import qualified Cachix.Deploy.OptionsParser as DeployOptions
import qualified Cachix.Deploy.Websocket as WebSocket
import Cachix.Types.Deploy (Deploy)
import qualified Cachix.Types.Deploy as Types
import qualified Cachix.Types.DeployResponse as DeployResponse
import qualified Cachix.Types.Deployment as Deployment
import qualified Control.Concurrent.Async as Async
import qualified Data.Aeson as Aeson
import Data.HashMap.Strict (filterWithKey)
import qualified Data.HashMap.Strict as HM
import qualified Data.Text as Text
import qualified Data.Text.IO as Text
import Data.UUID (UUID)
import qualified Data.UUID as UUID
import qualified Network.WebSockets as WS
import Protolude hiding (toS)
import Protolude.Conv
import Servant.Auth.Client (Token (..))
import Servant.Client.Streaming (ClientEnv, runClientM)
import Servant.Conduit ()
import System.Environment (getEnv)
import qualified Text.Megaparsec as Parse
import qualified Text.Megaparsec.Char as Parse

run :: Env.Env -> DeployOptions.ActivateOptions -> IO ()
run env DeployOptions.ActivateOptions {DeployOptions.payloadPath, DeployOptions.agents, DeployOptions.deployAsync} = do
  -- TODO: improve the error message here
  agentToken <- toS <$> getEnv "CACHIX_ACTIVATE_TOKEN"
  Aeson.eitherDecodeFileStrict' payloadPath >>= \case
    Left err -> do
      hPutStrLn stderr $ "Error parsing the deployment spec: " <> err
      exitFailure
    Right deploySpec -> do
      activate env deployAsync agentToken (filterAgents agents deploySpec)
  where
    filterAgents [] deploySpec = deploySpec
    filterAgents chosenAgents deploySpec =
      deploySpec
        { Types.agents = filterWithKey (\k _ -> k `elem` chosenAgents) (Types.agents deploySpec)
        }

-- TODO: use prettyprinter
activate :: Env.Env -> Bool -> ByteString -> Deploy -> IO ()
activate Env.Env {cachixoptions, clientenv} deployAsync agentToken payload = do
  deployResponse <-
    escalate <=< (`runClientM` clientenv) $
      API.V2.activate deployClientV2 (Token agentToken) payload

  let agents = HM.toList (DeployResponse.agents deployResponse)

  Text.putStr (renderOverview agents)

  -- Skip streaming the logs when run with the --async flag
  when deployAsync exitSuccess

  Text.putStr "\n\n"
  deployments <- Async.mapConcurrently watchDeployments agents

  Text.putStr "\n"
  Text.putStr (renderSummary deployments)

  if all isSuccessfulDeployment deployments
    then exitSuccess
    else exitFailure
  where
    isSuccessfulDeployment = (==) Deployment.Succeeded . Deployment.status . snd

    watchDeployments (agentName, details) = do
      let deploymentID = DeployResponse.id details
          host = Config.host cachixoptions
          hostname = URI.getHostname host
          port = fromMaybe (URI.Port 80) (URI.getPortFor (URI.getScheme host))
          path = "/api/v1/deploy/log/" <> UUID.toText deploymentID <> "?view=true"
          useSSL = URI.requiresSSL (URI.getScheme host)
          headers = [("Authorization", "Bearer " <> agentToken)]
          identifier = unwords ["cachix", versionNumber]
          options =
            WebSocket.Options
              { WebSocket.host = hostname,
                WebSocket.port = port,
                WebSocket.path = path,
                WebSocket.useSSL = useSSL,
                WebSocket.headers = headers,
                WebSocket.identifier = identifier
              }

      deployment <- Async.withAsync (printLogsToTerminal options agentName) $ \logThread -> do
        deployment <- pollDeploymentStatus clientenv (Token agentToken) deploymentID

        -- Wait for all the logs to arrive
        let status = Deployment.status deployment
        when (status == Deployment.Failed || status == Deployment.Succeeded) $
          void (Async.waitCatch logThread)

        pure deployment

      pure (agentName, deployment)

pollDeploymentStatus :: ClientEnv -> Token -> UUID -> IO Deployment.Deployment
pollDeploymentStatus clientEnv token deploymentID = loop
  where
    loop = do
      deployment <-
        Retry.retryAll . const $
          escalate <=< (`runClientM` clientEnv) $
            API.V1.getDeployment deployClientV1 token deploymentID

      case Deployment.status deployment of
        Deployment.Cancelled -> pure deployment
        Deployment.Failed -> pure deployment
        Deployment.Succeeded -> pure deployment
        _ -> do
          threadDelay (2 * 1000 * 1000)
          loop

printLogsToTerminal :: WebSocket.Options -> Text -> IO ()
printLogsToTerminal options agentName =
  WebSocket.runClientWith options WS.defaultConnectionOptions $ \connection ->
    fix $ \loop -> do
      message <- WS.receiveData connection
      case Aeson.eitherDecodeStrict' message of
        Left error -> do
          Text.putStrLn $ "Error parsing the log message: " <> show error
          loop
        Right msg -> do
          putStrLn $ unwords [inBrackets agentName, WSS.line msg]
          unless (isDeploymentDone (WSS.line msg)) loop
  where
    -- Parse each log line looking for the success/failure messages.
    -- TODO: figure out a way to avoid this. How can we tell when the log is done?
    isDeploymentDone :: Text -> Bool
    isDeploymentDone = isRight . Parse.parse logEndMessageParser ""

    logEndMessageParser :: Parse.Parsec Void Text Text
    logEndMessageParser =
      Parse.string "Successfully activated the deployment"
        <|> Parse.string "Failed to activate the deployment"

renderOverview :: [(Text, DeployResponse.Details)] -> Text
renderOverview agents =
  Text.intercalate "\n" $
    "Deploying agents:" :
      [ inBrackets agentName <> " " <> DeployResponse.url details
        | (agentName, details) <- agents
      ]

renderSummary :: [(Text, Deployment.Deployment)] -> Text
renderSummary results =
  Text.intercalate "\n" $
    "Deployment summary:" :
      [ inBrackets agentName <> " " <> renderStatus (Deployment.status deployment)
        | (agentName, deployment) <- results
      ]
  where
    renderStatus = \case
      Deployment.Succeeded -> "Deployed successfully"
      Deployment.Failed -> "Failed to deploy"
      Deployment.Cancelled -> "Deployment cancelled"
      Deployment.InProgress -> "Still deploying"
      Deployment.Pending -> "Deployment not started"

inBrackets :: (Semigroup a, IsString a) => a -> a
inBrackets s = "[" <> s <> "]"