hwm-0.4.0: src/HWM/Runtime/Network.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoImplicitPrelude #-}
module HWM.Runtime.Network (uploadToGitHub, getGHUploadUrl, uploadToHackage, getHackageToken) where
import Control.Exception (try)
import Control.Monad.Except (MonadError (..))
import Control.Retry (exponentialBackoff, limitRetries)
import Data.Aeson (FromJSON)
import qualified Data.Text as T
import HWM.Core.Pkg (Pkg (..))
import HWM.Core.Result (Issue (..), Severity (..))
import HWM.Domain.Config (Config (..))
import HWM.Domain.ConfigT (ConfigT)
import Network.HTTP.Client (HttpException (..), HttpExceptionContent (..), Response (responseStatus))
import Network.HTTP.Client.MultipartFormData (partFileSource)
import Network.HTTP.Req
import Network.HTTP.Types (Status (..))
import Relude
import System.FilePath (takeFileName)
import System.Timeout (timeout)
import Text.URI (mkURI)
import UnliftIO.Retry (capDelay)
getGitHubToken :: (MonadIO m, MonadError Issue m) => m Text
getGitHubToken = do
maybeToken <- liftIO $ lookupEnv "GITHUB_TOKEN"
maybe
(throwError "GITHUB_TOKEN environment variable not set. Please set it to a valid GitHub Personal Access Token with repo permissions.")
(pure . T.pack)
maybeToken
ghAuth :: Text -> Option 'Https
ghAuth token =
header "Authorization" ("Bearer " <> encodeUtf8 token)
<> header "User-Agent" "hwm-tool"
hackageAuth :: Text -> Option 'Https
hackageAuth token =
header "Authorization" ("X-ApiKey " <> encodeUtf8 token)
<> header "User-Agent" "hwm-tool"
uploadToGitHub :: (MonadIO m, MonadError Issue m) => Text -> FilePath -> m ()
uploadToGitHub uploadUrl filePath = do
token <- getGitHubToken
liftIO $ runReq defaultHttpConfig $ do
(url, opts) <- withURI uploadUrl
void
$ req
POST
url
(ReqBodyFile filePath)
ignoreResponse
( opts
<> queryParam "name" (Just $ T.pack $ takeFileName filePath)
<> ghAuth token
<> header "Content-Type" "application/octet-stream"
)
data GitHubRelease = GitHubRelease
{ name :: Text,
upload_url :: Text
}
deriving (Show, Generic, FromJSON)
getGHUploadUrl :: (MonadIO m, MonadError Issue m) => Config -> Text -> m Text
getGHUploadUrl Config {..} tag = do
gh <- maybe (throwError "GitHub repository not configured. Please set github field in hwm.yaml file") pure cfgGithub
token <- getGitHubToken
liftIO $ runReq defaultHttpConfig $ do
(url, opts) <- withURI ("https://api.github.com/repos/" <> gh <> "/releases/tags/" <> tag)
r <- req GET url NoReqBody jsonResponse (opts <> ghAuth token <> header "Accept" "application/vnd.github+json")
let rawUrl = upload_url (responseBody r)
pure $ T.takeWhile (/= '{') rawUrl
withURI :: (MonadIO m) => Text -> m (Url Https, Option scheme)
withURI u = do
uri <- liftIO $ mkURI u
case useHttpsURI uri of
Just (url, opts) -> pure (url, opts)
Nothing -> error ("URLs must be HTTPS: " <> show u)
mkSecond :: Int -> Int
mkSecond n = n * 1000000
hwmConfig :: HttpConfig
hwmConfig =
let policy =
capDelay (mkSecond 5) (exponentialBackoff (mkSecond 1))
<> limitRetries 3
in defaultHttpConfig {httpConfigRetryPolicy = policy}
safeReq :: (MonadIO m) => Pkg -> Text -> (Status -> Issue) -> IO b -> m (Either Issue b)
safeReq pkg errMsg f action = do
res <- liftIO $ timeout (mkSecond 90) $ try action
case res of
Nothing -> pure $ Left $ Issue (pkgMemberId pkg) SeverityError (errMsg <> " (Timeout)") Nothing
Just (Left (VanillaHttpException (HttpExceptionRequest _ (StatusCodeException r _)))) -> pure $ Left $ f (responseStatus r)
Just (Left e) -> pure $ Left $ networkError pkg e
Just (Right a) -> pure $ Right a
getHackageToken :: (MonadIO m, MonadError Issue m) => m Text
getHackageToken = do
maybeToken <- liftIO $ lookupEnv "HACKAGE_AUTH_TOKEN"
maybe
(throwError "HACKAGE_AUTH_TOKEN environment variable not set. Please set it to a valid Hackage API Token.")
(pure . T.pack)
maybeToken
uploadToHackage :: Text -> Pkg -> FilePath -> ConfigT [Issue]
uploadToHackage token pkg tarballPath = do
let auth = hackageAuth token
let url = https "hackage.haskell.org" /: "packages"
body <- reqBodyMultipart [partFileSource "package" tarballPath]
result <-
safeReq pkg "Hackage Upload Failed" (handleHttpError pkg)
$ runReq hwmConfig
$ req POST url body ignoreResponse auth
pure $ either pure (const []) result
handleHttpError :: Pkg -> Status -> Issue
handleHttpError pkg status = case statusCode status of
409 -> warn pkg "Version already exists on Hackage. Skipping."
401 -> err pkg "Invalid Hackage API Token."
413 -> err pkg "Package tarball is too large for Hackage."
_ -> err pkg $ "Hackage returned: " <> show (statusCode status)
networkError :: (Show a) => Pkg -> a -> Issue
networkError pkg _ = Issue (pkgMemberId pkg) SeverityError "Hackage Connection Error" Nothing
err :: Pkg -> Text -> Issue
err pkg msg = Issue (pkgMemberId pkg) SeverityError msg Nothing
warn :: Pkg -> Text -> Issue
warn pkg msg = Issue (pkgMemberId pkg) SeverityWarning msg Nothing