packages feed

ihp-ide-1.4.0: IHP/IDE/Postgres.hs

module IHP.IDE.Postgres (withPostgres, withBuiltinOrDevenvPostgres) where

import IHP.IDE.Types
import IHP.Prelude
import qualified System.Process as Process
import qualified System.Directory as Directory
import qualified Data.ByteString.Char8 as ByteString
import qualified Data.ByteString.Builder as ByteString
import Control.Concurrent (threadDelay)
import Control.Concurrent.MVar
import GHC.IO.Handle
import qualified Control.Exception.Safe as Exception

import qualified IHP.Log as Log
import qualified IHP.EnvVar as EnvVar
import Paths_ihp_ide (getDataFileName)

withPostgres :: (?context :: Context) => (MVar () -> IORef ByteString.Builder -> IORef ByteString.Builder -> IO a) -> IO a
withPostgres callback = do
    currentDir <- Directory.getCurrentDirectory
    ensureNoOtherPostgresIsRunning
    shouldInit <- needsDatabaseInit
    when shouldInit initDatabase

    Process.withCreateProcess (postgresProcessParams currentDir) \(Just inputHandle) (Just outputHandle) (Just errorHandle) processHandle -> do
        let main = do
                standardOutput <- newIORef mempty
                errorOutput <- newIORef mempty
                databaseIsReady <- newEmptyMVar

                redirectHandleToVariable standardOutput outputHandle handleOutdatedDatabase
                redirectHandleToVariable errorOutput errorHandle (handleOutdatedDatabase >> handleDatabaseReady databaseIsReady)

                callback databaseIsReady standardOutput errorOutput

        Exception.finally main (softStopPostgres processHandle)

softStopPostgres :: Process.ProcessHandle -> IO ()
softStopPostgres processHandle = do
    let interruptAndWait = Process.interruptProcessGroupOf processHandle >> Process.waitForProcess processHandle
    let waitAndKill = threadDelay 1000000 >> pure ()
    race_
        interruptAndWait
        waitAndKill

postgresProcessParams :: (?context :: Context) => FilePath -> Process.CreateProcess
postgresProcessParams workingDirectory =
    let
        args = ["-D", "build/db/state", "-k", workingDirectory <> "/build/db", "-c", "listen_addresses="]
    in (procDirenvAware "postgres" args)
        { Process.std_in = Process.CreatePipe
        , Process.std_out = Process.CreatePipe
        , Process.std_err = Process.CreatePipe
        , Process.create_group = True
        }

handleDatabaseReady :: MVar () -> ByteString -> IO ()
handleDatabaseReady onReady line = when ("database system is ready to accept connections" `ByteString.isInfixOf` line) (putMVar onReady ())

handleOutdatedDatabase :: (?context :: Context) => ByteString -> IO ()
handleOutdatedDatabase line =
        -- Always log fatal errors to the output:
        -- 2021-09-04 12:18:08.888 CEST [55794] FATAL:  database files are incompatible with server
        --
        -- If we're in debug mode, log all output
        if "FATAL" `ByteString.isInfixOf` line
            then if "database files are incompatible with server" `ByteString.isInfixOf` line
                then Log.error ("The current database state has been created with a different postgres server. Likely you just upgraded the IHP version. Delete your local dev database with 'rm -rf build/db'. You can use 'make dumpdb' to save your database state to Fixtures.sql, otherwise all changes in your local db will be lost. After that run 'devenv up' again." :: Text)
                else Log.error line
            else when ?context.isDebugMode (Log.debug line)

redirectHandleToVariable :: IORef ByteString.Builder -> Handle -> (ByteString -> IO ()) -> IO (Async ())
redirectHandleToVariable !ref !handle !onLine = do
    async $ forever $ do
        line <- ByteString.hGetLine handle
        onLine line
        modifyIORef ref (\log -> log <> "\n" <> ByteString.byteString line)

ensureNoOtherPostgresIsRunning :: IO ()
ensureNoOtherPostgresIsRunning = do
    pidFileExists <- Directory.doesPathExist "build/db/state/postmaster.pid"
    let stopFailedHandler (exception :: SomeException) = do
            -- pg_ctl: could not send stop signal (PID: 123456765432): No such process
            if ("No such process" `isInfixOf` (tshow exception))
                then Directory.removeFile "build/db/state/postmaster.pid"
                else putStrLn "Found postgres lockfile at 'build/db/state/postmaster.pid'. Could not bring the other postgres instance to halt. Please stop the running postgres manually and then restart this dev server"
    when pidFileExists do
        (Process.callProcess "pg_ctl" ["stop", "-D", "build/db/state"]) `catch` stopFailedHandler

needsDatabaseInit :: IO Bool
needsDatabaseInit = not <$> Directory.doesDirectoryExist "build/db/state"

initDatabase :: IO ()
initDatabase = do
    currentDir <- Directory.getCurrentDirectory
    Directory.createDirectoryIfMissing True "build/db"

    Process.callProcess "initdb" [
                "build/db/state"
                , "--no-locale" -- Avoid issues with impure host system locale in dev mode
                , "--encoding"
                , "UTF8"
            ]

    let params = (Process.proc "postgres" ["-D", "build/db/state", "-k", currentDir <> "/build/db", "-c", "listen_addresses="])
                { Process.std_in = Process.CreatePipe
                , Process.std_out = Process.CreatePipe
                , Process.std_err = Process.CreatePipe
                }

    Process.withCreateProcess params \(Just inputHandle) (Just outputHandle) (Just errorHandle) processHandle -> do
        waitUntilReady errorHandle do
            Process.callProcess "createdb" ["app", "-h", currentDir <> "/build/db"]

            let importSql file = Process.callCommand ("psql -h '" <> currentDir <> "/build/db' -d app < " <> file)
            ihpSchemaSql <- getDataFileName "IHPSchema.sql"
            importSql ihpSchemaSql
            importSql "Application/Schema.sql"
            importSql "Application/Fixtures.sql"

            Process.terminateProcess processHandle
            _ <- Process.waitForProcess processHandle
            pure ()

waitUntilReady handle callback = do
    line <- ByteString.hGetLine handle
    if "database system is ready to accept connections" `ByteString.isInfixOf` line
        then callback
        else waitUntilReady handle callback

waitPostgres :: (?context :: Context) => IO ()
waitPostgres = do
    let isDebugMode = ?context.isDebugMode
    threadDelay 1000000
    (_, stdout, _) <- Process.readProcessWithExitCode "pg_ctl" ["status"] ""
    if "server is running" `isInfixOf` (cs stdout)
    then pure ()
    else do
        when isDebugMode (Log.debug ("Waiting for postgres to start" :: Text))
        waitPostgres


withBuiltinOrDevenvPostgres :: (?context :: Context) => (MVar () -> IORef ByteString.Builder -> IORef ByteString.Builder -> IO a) -> IO a
withBuiltinOrDevenvPostgres callback = do
    useDevenv <- EnvVar.envOrDefault "IHP_DEVENV" False
    if useDevenv
    then do
        waitPostgres

        -- For devenv postgres we don't have access to the postgres logs
        standardOutput <- newIORef mempty
        errorOutput <- newIORef mempty
        databaseIsReady <- newMVar ()

        callback databaseIsReady standardOutput errorOutput
    else do
        withPostgres callback