cachix-1.7.9: src/Cachix/Deploy/Agent.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
module Cachix.Deploy.Agent where
import Cachix.API.WebSocketSubprotocol qualified as WSS
import Cachix.Client.Config qualified as Config
import Cachix.Client.URI (URI)
import Cachix.Client.URI qualified as URI
import Cachix.Client.Version (versionNumber)
import Cachix.Deploy.Deployment qualified as Deployment
import Cachix.Deploy.Lock qualified as Lock
import Cachix.Deploy.Log qualified as Log
import Cachix.Deploy.OptionsParser qualified as CLI
import Cachix.Deploy.StdinProcess qualified as StdinProcess
import Cachix.Deploy.Websocket qualified as WebSocket
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.Extra (once)
import Control.Concurrent.MVar qualified as MVar
import Control.Exception.Safe (onException)
import Control.Exception.Safe qualified as Safe
import Control.Retry qualified as Retry
import Data.Aeson qualified as Aeson
import Data.IORef
import Data.String (String)
import Katip qualified as K
import Paths_cachix (getBinDir)
import Protolude hiding (onException, toS, (<.>))
import Protolude.Conv
import System.Directory qualified as Directory
import System.Environment (getEnv, lookupEnv)
import System.FilePath ((<.>), (</>))
import System.Posix.Files qualified as Posix.Files
import System.Posix.Process qualified as Posix
import System.Posix.Signals qualified as Signals
import System.Posix.Types qualified as Posix
import System.Posix.User qualified as Posix.User
import System.Timeout qualified as Timeout
type ServiceWebSocket = WebSocket.WebSocket (WSS.Message WSS.AgentCommand) (WSS.Message WSS.BackendCommand)
data Agent = Agent
{ name :: Text,
token :: Text,
profileName :: Text,
bootstrap :: Bool,
host :: URI,
websocket :: ServiceWebSocket,
agentState :: IORef (Maybe WSS.AgentInformation),
logOptions :: Log.Options,
withLog :: Log.WithLog,
lockFile :: FilePath,
pid :: Posix.CPid,
pidFile :: FilePath
}
mkAgent :: Log.WithLog -> Log.Options -> Maybe FilePath -> Config.CachixOptions -> CLI.AgentOptions -> Text -> IO Agent
mkAgent withLog logOptions mlockDirectory cachixOptions agentOptions agentToken = do
agentState <- newIORef Nothing
pid <- Posix.getProcessID
lockDirectory <- maybe Lock.getLockDirectory return mlockDirectory
let host = Config.host cachixOptions
basename = URI.getHostname host
agentName = CLI.name agentOptions
profileName = fromMaybe "system" (CLI.profile agentOptions)
port =
host
& URI.getScheme
& URI.getPortFor
& fromMaybe (URI.Port 80)
websocketOptions =
WebSocket.Options
{ WebSocket.host = basename,
WebSocket.port = port,
WebSocket.path = "/ws",
WebSocket.useSSL = URI.requiresSSL (URI.getScheme host),
WebSocket.headers = WebSocket.createHeaders agentName agentToken,
WebSocket.identifier = agentIdentifier agentName
}
websocket <- WebSocket.new withLog websocketOptions
let lockFilename = "agent-" <> toS agentName
return $
Agent
{ name = agentName,
token = agentToken,
profileName = profileName,
bootstrap = CLI.bootstrap agentOptions,
agentState = agentState,
pid = pid,
pidFile = lockDirectory </> lockFilename <.> Lock.pidExtension,
lockFile = lockDirectory </> lockFilename <.> Lock.lockExtension,
host = host,
logOptions = logOptions,
withLog = withLog,
websocket = websocket
}
agentIdentifier :: Text -> Text
agentIdentifier agentName = unwords [agentName, toS versionNumber]
run :: Config.CachixOptions -> CLI.AgentOptions -> IO ()
run cachixOptions agentOptions =
Log.withLog logOptions $ \withLog ->
logExceptions withLog $ do
checkUserOwnsHome
-- TODO: show a more helpful error if the token is missing
-- TODO: show a more helpful error when the token isn't valid
-- TODO: wrap the token in a newtype or use servant's Token
agentToken <- toS <$> getEnv "CACHIX_AGENT_TOKEN"
agent <- mkAgent withLog logOptions Nothing cachixOptions agentOptions agentToken
withAgentLock agent $ do
-- Connect to the backend
channel <- WebSocket.receive (websocket agent)
shutdownWebsocket <- connectToService (websocket agent)
-- Shutdown the socket on sigint
installSignalHandlers shutdownWebsocket
WebSocket.readDataMessages channel $ \message ->
handleCommand agent (WSS.command message)
where
verbosity =
if Config.verbose cachixOptions
then Log.Verbose
else Log.Normal
logOptions =
Log.Options
{ verbosity = verbosity,
namespace = "agent",
environment = "production"
}
handleCommand :: Agent -> WSS.BackendCommand -> IO ()
handleCommand agent command =
case command of
WSS.AgentRegistered agentInformation -> registerAgent agent agentInformation
WSS.Deployment deploymentDetails -> launchDeployment agent deploymentDetails
logExceptions :: Log.WithLog -> IO () -> IO ()
logExceptions withLog action =
action `catches` [agentHandler, exceptionHandler]
where
-- Pretty-print any errors thrown by the agent
agentHandler =
Handler $ \(e :: AgentError) -> do
withLog . K.logLocM K.ErrorS . K.ls $ displayException e
exitFailure
-- Handle any unexcepted exceptions
exceptionHandler = Handler $ \(e :: SomeException) -> do
withLog . K.logLocM K.ErrorS . K.ls $
unlines
[ "The agent encountered an exception:",
toS (displayException e)
]
exitFailure
-- | Acquire a lock for this agent. Skip this step if we're bootstrapping the agent.
withAgentLock :: Agent -> IO () -> IO ()
withAgentLock agent action =
if bootstrap agent
then action
else tryToAcquireLock 0
where
agentName = name agent
tryToAcquireLock :: Int -> IO ()
tryToAcquireLock attempts = do
lock <- Lock.withTryLockAndPid (lockFile agent) (pidFile agent) action
when (isNothing lock) $
if attempts >= 5
then throwIO (AgentAlreadyRunning agentName)
else do
threadDelay (3 * 1000 * 1000)
tryToAcquireLock (attempts + 1)
installSignalHandlers :: IO () -> IO ()
installSignalHandlers shutdown = do
mainThreadId <- myThreadId
-- Track Ctrl+C attempts
interruptRef <- newIORef False
let safeShutdown = do
-- Timeout after 30 seconds to ensure we don't hang
void $ Timeout.timeout (30 * 1000 * 1000) shutdown
-- Handle SIGTERM (systemd): always shutdown and exit immediately
_ <-
Signals.installHandler
Signals.sigTERM
( Signals.Catch $ do
safeShutdown
throwTo mainThreadId ExitSuccess
)
Nothing
-- Handle SIGINT (interactive Ctrl+C): first attempt initiates shutdown, second exits
_ <-
Signals.installHandler
Signals.sigINT
( Signals.Catch $ do
isSecondInterrupt <- liftIO $ atomicModifyIORef' interruptRef (True,)
if isSecondInterrupt
then throwTo mainThreadId ExitSuccess
else safeShutdown
)
Nothing
return ()
registerAgent :: Agent -> WSS.AgentInformation -> IO ()
registerAgent Agent {agentState, withLog} agentInformation = do
withLog $ K.logLocM K.InfoS "Agent registered."
atomicWriteIORef agentState (Just agentInformation)
launchDeployment :: Agent -> WSS.DeploymentDetails -> IO ()
launchDeployment agent@Agent {..} deploymentDetails = do
agentRegistered <- readIORef agentState
case agentRegistered of
-- TODO: the agent should either not exist before we register or
-- we should re-register here as a precaution.
Nothing -> pure ()
Just agentInformation -> do
binDir <- toS <$> getBinDir
exitCode <-
StdinProcess.readProcess (binDir <> "/.cachix-deployment") [] $
toS . Aeson.encode $
Deployment.Deployment
{ Deployment.agentName = name,
Deployment.agentToken = token,
Deployment.profileName = profileName,
Deployment.host = host,
Deployment.deploymentDetails = deploymentDetails,
Deployment.agentInformation = agentInformation,
Deployment.logOptions = logOptions
}
when
(bootstrap && exitCode == ExitSuccess)
(verifyBootstrapSuccess agent)
verifyBootstrapSuccess :: Agent -> IO ()
verifyBootstrapSuccess agent@(Agent {name, withLog}) = do
withLog . K.logLocM K.InfoS . K.ls $
unwords ["Waiting for another agent to take over..."]
magentPid <- waitForAgent (Retry.limitRetries 60 <> Retry.constantDelay 1000) agent
case magentPid of
Just pid -> do
withLog . K.logLocM K.InfoS . K.ls $
unwords ["Found an active agent for", name, "with PID " <> show pid <> ".", "Exiting."]
exitSuccess
Nothing -> do
withLog . K.logLocM K.InfoS . K.ls $
unwords ["Cannot find an active agent for", name <> ".", "Waiting for more deployments."]
waitForAgent :: Retry.RetryPolicyM IO -> Agent -> IO (Maybe Posix.CPid)
waitForAgent retryPolicy agent = do
Retry.retrying
retryPolicy
(const $ pure . isNothing)
(const $ findActiveAgent agent)
-- The PID might be stale in rare cases. Only use this for diagnostics.
findActiveAgent :: Agent -> IO (Maybe Posix.CPid)
findActiveAgent Agent {pidFile, lockFile} = do
Safe.handleAny (const $ pure Nothing) $ do
lock <- Lock.withTryLock lockFile (pure ())
-- The lock must be held by another process
guard (isNothing lock)
-- We should have a PID file
mpid <- Lock.readPidFile pidFile
guard (isJust mpid)
return mpid
-- | Asynchronously open and maintain a websocket connection to the backend for
-- sending deployment progress updates.
connectToService :: ServiceWebSocket -> IO (IO ())
connectToService websocket = do
close <- MVar.newEmptyMVar
thread <- Async.async $
WebSocket.runConnection websocket $ do
WebSocket.handleJSONMessages websocket (MVar.readMVar close)
-- Block until the initial connection is established
void $ MVar.readMVar (WebSocket.connection websocket)
once $ do
void $ MVar.tryPutMVar close ()
void $ Timeout.timeout (5 * 1000 * 1000) (Async.wait thread)
-- | Fetch the home directory and verify that the owner matches the current user.
-- Throws either 'NoHomeFound' or 'UserDoesNotOwnHome'.
checkUserOwnsHome :: IO ()
checkUserOwnsHome = do
home <- Directory.getHomeDirectory `onException` throwIO NoHomeFound
stat <- Posix.Files.getFileStatus home
userId <- Posix.User.getEffectiveUserID
when (userId /= Posix.Files.fileOwner stat) $ do
userName <- Posix.User.userName <$> Posix.User.getUserEntryForID userId
sudoUser <- lookupEnv "SUDO_USER"
throwIO $ UserDoesNotOwnHome userName sudoUser home
data AgentError
= -- | An agent with the same name is already running.
AgentAlreadyRunning Text
| -- | No home directory.
NoHomeFound
| -- | Safeguard against creating root-owned files in user directories.
-- This is an issue on macOS, where, by default, sudo does not reset $HOME.
UserDoesNotOwnHome
-- | The current user name
String
-- | The sudo user name, if any
(Maybe String)
-- | The home directory
FilePath
deriving (Show)
instance Exception AgentError where
displayException = \case
AgentAlreadyRunning agentName -> toS $ unwords ["The agent", agentName, "is already running."]
NoHomeFound -> "Could not find the user’s home directory. Make sure to set the $HOME variable."
UserDoesNotOwnHome userName sudoUser home ->
if isJust sudoUser
then toS $ unlines [warningMessage, suggestSudoFlagH]
else toS warningMessage
where
warningMessage = "The current user (" <> toS userName <> ") does not own the home directory (" <> toS home <> ")"
suggestSudoFlagH = "Try running the agent with `sudo -H`."