hercules-ci-cli-0.2.2: src/Hercules/CLI/Lock.hs
{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Hercules.CLI.Lock (commandParser) where
import Control.Monad.IO.Unlift (UnliftIO (UnliftIO), askUnliftIO)
import Control.Retry (RetryPolicyM, RetryStatus, capDelay, fullJitterBackoff, retrying, rsIterNumber)
import Data.Aeson.Encode.Pretty (encodePretty)
import Data.Has (Has)
import Data.IORef (IORef)
import qualified Data.UUID
import qualified Data.UUID.V4 as UUID4
import Hercules.API (Id, NoContent)
import qualified Hercules.API.Accounts.SimpleAccount as SimpleAccount
import Hercules.API.Id (Id (Id), idText)
import qualified Hercules.API.Projects.Project as Project
import qualified Hercules.API.Projects.SimpleJob as SimpleJob
import Hercules.API.State (ProjectStateResourceGroup (acquireLock), StateAPI (deleteLockLease, updateLockLease))
import qualified Hercules.API.State.StateLockAcquireRequest as StateLockAcquireRequest
import Hercules.API.State.StateLockAcquireResponse (StateLockAcquireResponse (Acquired, Blocked))
import qualified Hercules.API.State.StateLockAcquireResponse as StateLockAcquireResponse
import qualified Hercules.API.State.StateLockLease as StateLockLease
import qualified Hercules.API.State.StateLockUpdateRequest as StateLockUpdateRequest
import Hercules.CLI.Client (HerculesClientEnv, HerculesClientToken, clientErrorSummary, determineDefaultApiBaseUrl, runHerculesClientEither, shouldRetryClientError, shouldRetryResponse, stateClient)
import Hercules.CLI.Common (runAuthenticated)
import Hercules.CLI.Options (mkCommand)
import Hercules.CLI.Project (projectOption)
import Hercules.CLI.State (getProjectAndClient)
import Hercules.Error (escalate)
import Hercules.Frontend (mkLinks)
import qualified Hercules.Frontend
import qualified Network.URI
import Options.Applicative (help, long, metavar, strArgument, strOption, subparser)
import qualified Options.Applicative as Optparse
import Protolude
import RIO (RIO)
import Servant.Auth.Client (Token)
import Servant.Client.Core (ClientError)
import Servant.Client.Internal.HttpClient.Streaming (ClientM)
import qualified System.Environment
import qualified System.Process
import qualified UnliftIO
import UnliftIO.IORef (newIORef, readIORef, writeIORef)
commandParser, acquireCommandParser, releaseCommandParser, updateCommandParser, runCommandParser :: Optparse.Parser (IO ())
commandParser =
subparser
( mkCommand
"acquire"
(Optparse.progDesc "Acquire a lock")
acquireCommandParser
<> mkCommand
"update"
(Optparse.progDesc "Refresh a lock timeout and/or description")
updateCommandParser
<> mkCommand
"release"
(Optparse.progDesc "Release a lock")
releaseCommandParser
<> mkCommand
"run"
(Optparse.progDesc "Run a command holding a lock")
runCommandParser
)
acquireCommandParser = do
projectMaybe <- optional projectOption
name <- nameOption
json <- jsonOption
description <- fromMaybe "hci lock acquire" <$> optional descriptionOption
exclusive <- exclusiveOption
wait_ <- waitOption
pure do
parent <- getLeaseIdFromEnv
idempotencyKey <- Id <$> UUID4.nextRandom
let request =
StateLockAcquireRequest.StateLockAcquireRequest
{ description = description,
exclusive = exclusive,
parent = parent,
idempotencyKey = Just idempotencyKey
}
runAuthenticated do
projectStateClient <- getProjectAndClient projectMaybe
let acquireReq = acquireLock projectStateClient name request
onAcquire s = do
putErrText "hci: lock acquired"
if json
then putLByteString (encodePretty s)
else putText (idText $ StateLockAcquireResponse.leaseId s)
if wait_
then pollAcquire acquireReq >>= onAcquire
else do
ref <- newIORef Nothing
tryAcquire ref acquireReq >>= \case
Acquired s -> onAcquire s
Blocked s -> do
when json do
putLByteString (encodePretty s)
liftIO exitFailure
releaseCommandParser = do
leaseId <- leaseIdOption
pure do
runAuthenticated do
(_ :: NoContent) <- retryOnFail "lock release" (deleteLockLease stateClient leaseId)
putErrText "hci: lock released"
updateCommandParser = do
leaseId <- leaseIdOption
descriptionUpdate <- optional descriptionOption
json <- jsonOption
pure do
runAuthenticated do
let request = StateLockUpdateRequest.StateLockUpdateRequest {description = descriptionUpdate}
response <- retryOnFail "lock update" (updateLockLease stateClient leaseId request)
when json do
putLByteString (encodePretty response)
runCommandParser = do
projectMaybe <- optional projectOption
name <- nameOption
descriptionMaybe <- optional descriptionOption
exclusive <- exclusiveOption
exe <- strArgument (metavar "COMMAND")
args <- many (strArgument (metavar "ARGS"))
pure do
parent <- getLeaseIdFromEnv
idempotencyKey <- Id <$> UUID4.nextRandom
let request =
StateLockAcquireRequest.StateLockAcquireRequest
{ description = description,
exclusive = exclusive,
parent = parent,
idempotencyKey = Just idempotencyKey
}
description = fromMaybe ("hci lock run " <> toS exe) descriptionMaybe
runAuthenticated do
projectStateClient <- getProjectAndClient projectMaybe
lease0 <- pollAcquire (acquireLock projectStateClient name request)
putErrText "hci: lock acquired"
let leaseId = StateLockAcquireResponse.leaseId lease0
exitCode <-
( do
env <- liftIO System.Environment.getEnvironment
let procSpec = (System.Process.proc exe args) {System.Process.env = Just env'}
env' = (leaseIdEnvVar, toS (idText leaseId)) : filter (\(k, _) -> k /= leaseIdEnvVar) env
updateRequest =
StateLockUpdateRequest.StateLockUpdateRequest
{ -- Not changing anything; just pinging
description = Nothing
}
updateInterval = 3 * 60 * 1000 * 1000
pinger = do
liftIO $ threadDelay updateInterval
forever do
( do
(_ :: StateLockAcquireResponse.StateLockAcquiredResponse) <-
retryOnFail "lock pinger" do
updateLockLease stateClient leaseId updateRequest
liftIO $ threadDelay updateInterval
)
UnliftIO unlift <- askUnliftIO
liftIO do
withAsync
(unlift pinger)
( \_ -> do
(_, _, _, processHandle) <- System.Process.createProcess procSpec
System.Process.waitForProcess processHandle
)
)
`UnliftIO.finally` do
(_ :: NoContent) <- retryOnFail "lock release" (deleteLockLease stateClient leaseId)
putErrText "hci: lock released"
liftIO $ exitWith exitCode
simpleRetryPredicate :: Applicative m => (r -> Bool) -> RetryStatus -> r -> m Bool
simpleRetryPredicate f _rs r = pure (f r)
retryOnFail ::
(NFData b, Has HerculesClientToken r, Has HerculesClientEnv r) =>
Text ->
(Token -> ClientM b) ->
RIO r b
retryOnFail shortDesc req = escalate =<< retryOnFailEither shortDesc req
retryOnFailEither ::
(NFData a, Has HerculesClientToken r, Has HerculesClientEnv r) =>
Text ->
(Token -> ClientM a) ->
RIO r (Either ClientError a)
retryOnFailEither shortDesc req =
retrying
failureRetryPolicy
(simpleRetryPredicate shouldRetryResponse)
( \rs -> do
when (rsIterNumber rs /= 0) do
liftIO $ putErrText $ "hci: " <> shortDesc <> " retrying."
r <- runHerculesClientEither req
for_ (leftToMaybe r) \e -> do
liftIO $ putErrText $ "hci: " <> shortDesc <> " encountered " <> clientErrorSummary e <> "."
when (shouldRetryClientError e) do
liftIO $ putErrText $ "hci: " <> shortDesc <> " will retry."
pure r
)
-- NB: fullJitterBackoff is broken, https://github.com/Soostone/retry/issues/46
failureRetryPolicy :: MonadIO m => RetryPolicyM m
failureRetryPolicy = capDelay (120 * 1000 * 1000) (fullJitterBackoff 100000)
-- NB: fullJitterBackoff is broken, https://github.com/Soostone/retry/issues/46
waitRetryPolicy :: MonadIO m => RetryPolicyM m
waitRetryPolicy = capDelay (10 * 1000 * 1000) (fullJitterBackoff 500000)
tryAcquire ::
(Has HerculesClientToken r, Has HerculesClientEnv r) =>
IORef (Maybe StateLockAcquireResponse.StateLockBlockedResponse) ->
(Token -> ClientM StateLockAcquireResponse) ->
RIO r StateLockAcquireResponse
tryAcquire ref acquireLockRequest = do
r <- retryOnFail "lock acquire" acquireLockRequest
case r of
Blocked s -> logBlockedMaybe ref s
Acquired {} -> pass
pure r
pollAcquire ::
(Has HerculesClientToken r, Has HerculesClientEnv r) =>
(Token -> ClientM StateLockAcquireResponse) ->
RIO r StateLockAcquireResponse.StateLockAcquiredResponse
pollAcquire acquireLockRequest = do
ref <- newIORef Nothing
finalResp <-
retrying
waitRetryPolicy
( \_rs s -> case s of
Blocked {} -> do
putErrText "hci: waiting for lock..."
pure True
Acquired {} -> pure False
)
(const $ tryAcquire ref acquireLockRequest)
case finalResp of
Blocked {} -> panic "Retrying timed out" -- won't happen; policy is indefinite
Acquired s -> pure s
logBlockedMaybe ::
MonadIO m =>
IORef (Maybe StateLockAcquireResponse.StateLockBlockedResponse) ->
StateLockAcquireResponse.StateLockBlockedResponse ->
m ()
logBlockedMaybe ref resp = do
old <- readIORef ref
when (old /= Just resp) do
writeIORef ref (Just resp)
logBlocked resp
logBlocked :: MonadIO m => StateLockAcquireResponse.StateLockBlockedResponse -> m ()
logBlocked s = do
putErrText "hci: lock blocked"
for_ (StateLockAcquireResponse.blockedByLeases s) \lease -> do
putErrText "blocked by lease:"
putErrText $ " description: " <> StateLockLease.description lease
for_ (StateLockLease.user lease) \user ->
putErrText $ " user: " <> SimpleAccount.displayName user <> " (" <> SimpleAccount.name user <> ")"
for_ (StateLockLease.job lease) \job -> do
baseUri <- liftIO getLinksBase
let links = mkLinks baseUri
project = SimpleJob.project job
jobUrl =
Hercules.Frontend.job
links
(Project.siteSlug project)
(Project.ownerSlug project)
(Project.slug project)
(fromIntegral (SimpleJob.index job))
putErrText $ " job: " <> jobUrl
getLinksBase :: IO Network.URI.URI
getLinksBase = do
url <- determineDefaultApiBaseUrl
case Network.URI.parseAbsoluteURI (toS url) of
Just x -> pure x
Nothing -> panic "Could not parse API base url"
-- TODO: bytestring
leaseIdEnvVar :: [Char]
leaseIdEnvVar = "HERCULES_CI_LOCK_LEASE_ID"
getLeaseIdFromEnv :: IO (Maybe (Id "StateLockLease"))
getLeaseIdFromEnv = do
strMaybe <- System.Environment.lookupEnv leaseIdEnvVar
for strMaybe \str -> case Data.UUID.fromString str of
Just x -> pure (Id x)
Nothing -> do
putErrLn (leaseIdEnvVar <> " is not a valid UUID")
exitFailure
nameOption :: Optparse.Parser Text
nameOption = strOption $ long "name" <> metavar "NAME" <> help "Name of the lock"
jsonOption :: Optparse.Parser Bool
jsonOption = Optparse.flag False True (long "json" <> help "Write JSON results on stdout")
descriptionOption :: Optparse.Parser Text
descriptionOption = strOption $ long "description" <> metavar "TEXT" <> help "Describe the lock activity, for better messages"
-- NB: exclusive by default; inversion is contained
exclusiveOption :: Optparse.Parser Bool
exclusiveOption = Optparse.flag True False (long "non-exclusive" <> help "Acquire a non-exclusive lock aka read lock")
-- NB: wait by default; inversion is contained
waitOption :: Optparse.Parser Bool
waitOption = Optparse.flag True False (long "no-wait" <> help "Fail immediately when the lock is already taken")
leaseIdOption :: Optparse.Parser (Id "StateLockLease")
leaseIdOption = fmap Id $ Optparse.option Optparse.auto $ long "lease-id" <> metavar "UUID" <> help "Lease UUID"