add: --ready flag for postgrest healthcheck

The `--ready` flag is a wrapper around the admin server
`/ready` request. This is done through using an http client
library in postgrest.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
This commit is contained in:
Taimoor Zaeem
2025-09-28 18:01:10 -05:00
committed by Steve Chavez
parent ab2cd766c2
commit a9a1763328
10 changed files with 273 additions and 6 deletions
+1
View File
@@ -14,6 +14,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Added ### Added
- Bounded JWT cache using the SIEVE algorithm by @mkleczek in #4084 - Bounded JWT cache using the SIEVE algorithm by @mkleczek in #4084
- Add `--ready` flag for postgrest healthcheck by @taimoorzaeem in #4239
### Changed ### Changed
+1
View File
@@ -142,6 +142,7 @@ To avoid having to install the database at all, you can run both it and the serv
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
PGRST_SERVER_HOST: localhost # necessary for `postgrest --ready` flag to work
PGRST_DB_URI: postgres://app_user:password@db:5432/app_db PGRST_DB_URI: postgres://app_user:password@db:5432/app_db
PGRST_OPENAPI_SERVER_PROXY_URI: http://127.0.0.1:3000 PGRST_OPENAPI_SERVER_PROXY_URI: http://127.0.0.1:3000
depends_on: depends_on:
+17 -1
View File
@@ -7,7 +7,7 @@ PostgREST provides a CLI with the options listed below:
.. code:: text .. code:: text
Usage: postgrest [-v|--version] [-e|--example] [--dump-config | --dump-schema] Usage: postgrest [-v|--version] [-e|--example] [--dump-config | --dump-schema | --ready]
[FILENAME] [FILENAME]
PostgREST / create a REST API to an existing Postgres PostgREST / create a REST API to an existing Postgres
@@ -20,6 +20,8 @@ PostgREST provides a CLI with the options listed below:
--dump-config Dump loaded configuration and exit --dump-config Dump loaded configuration and exit
--dump-schema Dump loaded schema as JSON and exit (for debugging, --dump-schema Dump loaded schema as JSON and exit (for debugging,
output structure is unstable) output structure is unstable)
--ready Checks the health of PostgREST by doing a request on
the admin server /ready endpoint
FILENAME Path to configuration file FILENAME Path to configuration file
FILENAME FILENAME
@@ -71,3 +73,17 @@ Dump Schema
$ postgrest --dump-schema $ postgrest --dump-schema
Dumps the schema cache in JSON format. Dumps the schema cache in JSON format.
Ready Flag
----------
Makes a request to the ``/ready`` endpoint of the :ref:`admin_server`. It exits with a return code of ``0`` on success and ``1`` on failure.
.. code-block:: bash
$ postgrest --ready
OK: http://localhost:3001/ready
.. note::
The ``--ready`` flag cannot be used when :ref:`server-host` is configured with special hostnames. We suggest to change it to ``localhost``.
+2
View File
@@ -52,6 +52,7 @@ library
PostgREST.Auth.Types PostgREST.Auth.Types
PostgREST.Cache.Sieve PostgREST.Cache.Sieve
PostgREST.CLI PostgREST.CLI
PostgREST.Client
PostgREST.Config PostgREST.Config
PostgREST.Config.Database PostgREST.Config.Database
PostgREST.Config.JSPath PostgREST.Config.JSPath
@@ -118,6 +119,7 @@ library
, hasql-pool >= 1.0.1 && < 1.1 , hasql-pool >= 1.0.1 && < 1.1
, hasql-transaction >= 1.0.1 && < 1.2 , hasql-transaction >= 1.0.1 && < 1.2
, heredoc >= 0.2 && < 0.3 , heredoc >= 0.2 && < 0.3
, http-client >= 0.7.19 && < 0.8
, http-types >= 0.12.2 && < 0.13 , http-types >= 0.12.2 && < 0.13
, insert-ordered-containers >= 0.2.2 && < 0.3 , insert-ordered-containers >= 0.2.2 && < 0.3
, iproute >= 1.7.0 && < 1.8 , iproute >= 1.7.0 && < 1.8
+30 -5
View File
@@ -1,3 +1,4 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
@@ -24,6 +25,7 @@ import PostgREST.Version (prettyVersion)
import qualified PostgREST.App as App import qualified PostgREST.App as App
import qualified PostgREST.AppState as AppState import qualified PostgREST.AppState as AppState
import qualified PostgREST.Client as Client
import qualified PostgREST.Config as Config import qualified PostgREST.Config as Config
import Protolude import Protolude
@@ -31,16 +33,26 @@ import Protolude
main :: CLI -> IO () main :: CLI -> IO ()
main CLI{cliCommand, cliPath} = do main CLI{cliCommand, cliPath} = do
conf@AppConfig{..} <- conf <-
either panic identity <$> Config.readAppConfig mempty cliPath Nothing mempty mempty either panic identity <$> Config.readAppConfig mempty cliPath Nothing mempty mempty
case cliCommand of
Client adminCmd -> runClientCommand conf adminCmd
Run runCmd -> runAppCommand conf runCmd
-- | Run command using http-client to communicate with an already running postgrest
runClientCommand :: AppConfig -> ClientCommand -> IO ()
runClientCommand conf CmdReady = Client.ready conf
-- | Run postgrest with command
runAppCommand :: AppConfig -> RunCommand -> IO ()
runAppCommand conf@AppConfig{..} runCmd = do
-- Per https://github.com/PostgREST/postgrest/issues/268, we want to -- Per https://github.com/PostgREST/postgrest/issues/268, we want to
-- explicitly close the connections to PostgreSQL on shutdown. -- explicitly close the connections to PostgreSQL on shutdown.
-- 'AppState.destroy' takes care of that. -- 'AppState.destroy' takes care of that.
bracket bracket
(AppState.init conf) (AppState.init conf)
AppState.destroy AppState.destroy
(\appState -> case cliCommand of (\appState -> case runCmd of
CmdDumpConfig -> do CmdDumpConfig -> do
when configDbConfig $ AppState.readInDbConfig True appState when configDbConfig $ AppState.readInDbConfig True appState
putStr . Config.toText =<< AppState.getConfig appState putStr . Config.toText =<< AppState.getConfig appState
@@ -71,6 +83,13 @@ data CLI = CLI
} }
data Command data Command
= Client ClientCommand
| Run RunCommand
data ClientCommand
= CmdReady
data RunCommand
= CmdRun = CmdRun
| CmdDumpConfig | CmdDumpConfig
| CmdDumpSchema | CmdDumpSchema
@@ -105,7 +124,7 @@ readCLIShowHelp =
cliParser :: O.Parser CLI cliParser :: O.Parser CLI
cliParser = cliParser =
CLI CLI
<$> (dumpConfigFlag <|> dumpSchemaFlag) <$> (dumpConfigFlag <|> dumpSchemaFlag <|> readyFlag)
<*> O.optional configFileOption <*> O.optional configFileOption
configFileOption = configFileOption =
@@ -114,15 +133,21 @@ readCLIShowHelp =
<> O.help "Path to configuration file" <> O.help "Path to configuration file"
dumpConfigFlag = dumpConfigFlag =
O.flag CmdRun CmdDumpConfig $ O.flag (Run CmdRun) (Run CmdDumpConfig) $
O.long "dump-config" O.long "dump-config"
<> O.help "Dump loaded configuration and exit" <> O.help "Dump loaded configuration and exit"
dumpSchemaFlag = dumpSchemaFlag =
O.flag CmdRun CmdDumpSchema $ O.flag (Run CmdRun) (Run CmdDumpSchema) $
O.long "dump-schema" O.long "dump-schema"
<> O.help "Dump loaded schema as JSON and exit (for debugging, output structure is unstable)" <> O.help "Dump loaded schema as JSON and exit (for debugging, output structure is unstable)"
readyFlag =
O.flag (Run CmdRun) (Client CmdReady) $
O.long "ready"
<> O.help "Checks the health of PostgREST by doing a request on the admin server /ready endpoint"
exampleConfigFile :: [Char] exampleConfigFile :: [Char]
exampleConfigFile = exampleConfigFile =
[str|## Admin server used for checks. It's disabled by default unless a port is specified. [str|## Admin server used for checks. It's disabled by default unless a port is specified.
+100
View File
@@ -0,0 +1,100 @@
{-|
Module : PostgREST.Client
Description : PostgREST HTTP client
-}
{-# LANGUAGE NamedFieldPuns #-}
module PostgREST.Client
( ready
) where
import qualified Data.Text as T
import qualified Network.HTTP.Client as HC
import qualified Network.HTTP.Types.Status as HTTP
import Network.HTTP.Client (HttpException (..))
import System.IO (hFlush)
import PostgREST.Config (AppConfig (..))
import PostgREST.Network (isSpecialHostName)
import Protolude
data PgrstClientError
= NoAdminServer
| NoSpecialHostNamesAllowed Text
| PostgRESTNotReady Text
| HTTPConnectionRefused Text
| HTTPExceptionInvalidURL Text
-- | This is invoked by the CLI "--ready" flag.
-- The http-client sends and a request to /ready endpoint
-- and exits with success or failure.
ready :: AppConfig -> IO ()
ready AppConfig{configAdminServerHost, configAdminServerPort} = do
client <- HC.newManager HC.defaultManagerSettings
readyURL <- getURL
req <- HC.parseRequest (T.unpack readyURL) `catch` handleHttpException
resp <- HC.httpLbs req client `catch` handleHttpException
let status = HC.responseStatus resp
if status >= HTTP.status200 && status < HTTP.status300
then printAndExitWithSuccess $ "OK: " <> readyURL
else printAndExitWithFailure $ clientErrorMsg (PostgRESTNotReady readyURL)
where
getURL :: IO Text
getURL =
-- Here, we have three cases:
-- 1. If the admin port config is not defined, we exit
-- with "no admin server error"
-- 2. Otherwise, if admin server is running, then we check if
-- postgrest server-host is configured with special hostname like "*4",
-- if it is, we fail with "no special hostname allowed with "--ready".
-- The reason for this is that we can't know the actual address.
-- 3. Finally, if we know the "actual" hostname and the port, then we
-- construct the URL and return it.
case configAdminServerPort of
Nothing -> printAndExitWithFailure $ clientErrorMsg NoAdminServer
Just port ->
if isSpecialHostName configAdminServerHost
then printAndExitWithFailure $ clientErrorMsg (NoSpecialHostNamesAllowed configAdminServerHost)
else return $ makeReadyUrl port
-- NOTE: http-client automatically resolves hostnames
makeReadyUrl :: Int -> Text
makeReadyUrl p = "http://" <> wrapIfIpv6 configAdminServerHost <> ":" <> (T.pack . show) p <> "/ready"
where
-- IPv6 needs to wrapped in [], it has ':' as separator
wrapIfIpv6 :: Text -> Text
wrapIfIpv6 s
| T.any (== ':') s = "[" <> s <> "]"
| otherwise = s
-- | Handle HTTP exception for "http-client" requests
handleHttpException :: HttpException -> IO a
handleHttpException (HttpExceptionRequest req _) = do
let url = show (HC.getUri req)
printAndExitWithFailure $ clientErrorMsg (HTTPConnectionRefused $ T.pack url)
handleHttpException (InvalidUrlException url _) = do
printAndExitWithFailure $ clientErrorMsg (HTTPExceptionInvalidURL $ T.pack url)
-- | Print the message on stdout and exit with success
printAndExitWithSuccess :: Text -> IO a
printAndExitWithSuccess msg = putStrLn (T.unpack msg) >> hFlush stdout >> exitSuccess
-- | Print the message on stderr and exit with failure
printAndExitWithFailure :: Text -> IO a
printAndExitWithFailure msg = hPutStrLn stderr (T.unpack msg) >> hFlush stderr >> exitWith (ExitFailure 1)
-- | Pgrst client error to error message
clientErrorMsg :: PgrstClientError -> Text
clientErrorMsg err = "ERROR: " <>
case err of
NoAdminServer -> "Admin server is not running. Please check admin-server-port config."
NoSpecialHostNamesAllowed host ->
"The `--ready` flag cannot be used when server-host is configured as \"" <> host <> "\". "
<> "Please update your server-host config to \"localhost\"."
PostgRESTNotReady url -> url
HTTPConnectionRefused url -> "connection refused to " <> url
HTTPExceptionInvalidURL url -> "invalid url - " <> url
+10
View File
@@ -1,6 +1,7 @@
module PostgREST.Network module PostgREST.Network
( resolveSocketToAddress ( resolveSocketToAddress
, escapeHostName , escapeHostName
, isSpecialHostName
) where ) where
import Data.String (IsString (..)) import Data.String (IsString (..))
@@ -49,3 +50,12 @@ escapeHostName "!4" = "0.0.0.0"
escapeHostName "*6" = "0.0.0.0" escapeHostName "*6" = "0.0.0.0"
escapeHostName "!6" = "0.0.0.0" escapeHostName "!6" = "0.0.0.0"
escapeHostName h = h escapeHostName h = h
-- | Check if a hostname is special
isSpecialHostName :: Text -> Bool
isSpecialHostName "*" = True
isSpecialHostName "*4" = True
isSpecialHostName "!4" = True
isSpecialHostName "*6" = True
isSpecialHostName "!6" = True
isSpecialHostName _ = False
+6
View File
@@ -95,3 +95,9 @@ def hpctixfile():
# astronomically low. # astronomically low.
test = uuid.uuid4().hex[:12] test = uuid.uuid4().hex[:12]
return tixfile.with_suffix(f".{test}.tix") return tixfile.with_suffix(f".{test}.tix")
def get_admin_host_and_port_from_config(config):
admin_host = config.get("PGRST_ADMIN_SERVER_HOST", config["PGRST_SERVER_HOST"])
admin_port = config["PGRST_ADMIN_SERVER_PORT"]
return (admin_host, admin_port)
+2
View File
@@ -57,6 +57,7 @@ class PostgrestProcess:
admin: object admin: object
process: object process: object
session: object session: object
config: object
def read_stdout(self, nlines=1): def read_stdout(self, nlines=1):
"Wait for line(s) on standard output." "Wait for line(s) on standard output."
@@ -143,6 +144,7 @@ def run(
process=process, process=process,
session=PostgrestSession(baseurl), session=PostgrestSession(baseurl),
admin=PostgrestSession(adminurl), admin=PostgrestSession(adminurl),
config=env,
) )
finally: finally:
remaining_output = process.stdout.read() remaining_output = process.stdout.read()
+104
View File
@@ -1,12 +1,14 @@
"Unit tests for Input/Ouput of PostgREST seen as a black box." "Unit tests for Input/Ouput of PostgREST seen as a black box."
from operator import attrgetter from operator import attrgetter
import signal
import subprocess import subprocess
import pytest import pytest
from syrupy.extensions.json import SingleFileSnapshotExtension from syrupy.extensions.json import SingleFileSnapshotExtension
import yaml import yaml
from config import * from config import *
from postgrest import *
class ExtraNewLinesDumper(yaml.SafeDumper): class ExtraNewLinesDumper(yaml.SafeDumper):
@@ -286,3 +288,105 @@ def test_jwt_secret_min_length(defaultenv):
error = cli(["--dump-config"], env=env, expect_error=True) error = cli(["--dump-config"], env=env, expect_error=True)
assert "The JWT secret must be at least 32 characters long." in error assert "The JWT secret must be at least 32 characters long." in error
@pytest.mark.parametrize("host", ["127.0.0.1", "::1"], ids=["IPv4", "IPv6"])
def test_cli_ready_flag_success(host, defaultenv):
"test PostgREST ready flag succeeds when ready"
port = freeport()
with run(env=defaultenv, host=host, port=port) as postgrest:
output = cli(["--ready"], env=postgrest.config)
(admin_host, admin_port) = get_admin_host_and_port_from_config(postgrest.config)
if is_ipv6(host):
assert f"OK: http://[{admin_host}]:{admin_port}/ready" in output
else:
assert f"OK: http://{admin_host}:{admin_port}/ready" in output
def test_cli_ready_flag_fail_when_schema_cache_not_loaded(defaultenv, metapostgrest):
"test PosgREST ready flag fail when schema cache not loaded"
role = "timeout_authenticator"
env = {
**defaultenv,
"PGUSER": role,
"PGRST_DB_ANON_ROLE": role,
"PGRST_INTERNAL_SCHEMA_CACHE_SLEEP": "500",
}
port = freeport()
with run(env=env, port=port) as postgrest:
# The schema cache query takes at least 500ms, due to PGRST_INTERNAL_SCHEMA_CACHE_SLEEP above.
# Make it impossible to load the schema cache, by setting statement timeout to 400ms.
set_statement_timeout(metapostgrest, role, 400)
# force a reconnection so the new role setting is picked up
postgrest.process.send_signal(signal.SIGUSR1)
postgrest.wait_until_scache_starts_loading()
output = cli(["--ready"], env=postgrest.config, expect_error=True)
(admin_host, admin_port) = get_admin_host_and_port_from_config(postgrest.config)
assert f"ERROR: http://{admin_host}:{admin_port}/ready" in output
def test_cli_ready_flag_fail_with_http_exception(defaultenv):
"test PostgREST ready flag fail when http exception occurs"
port = freeport()
# when healthcheck process sends the request to a wrong endpoint
with run(env=defaultenv, port=port) as postgrest:
# we set it to some freeport where admin is not running
postgrest.config["PGRST_ADMIN_SERVER_PORT"] = str(freeport())
output = cli(["--ready"], env=postgrest.config, expect_error=True)
(admin_host, admin_port) = get_admin_host_and_port_from_config(postgrest.config)
assert (
f"ERROR: connection refused to http://{admin_host}:{admin_port}/ready"
in output
)
# When client sends the request to invalid URL
with run(env=defaultenv, port=port) as postgrest:
postgrest.config["PGRST_ADMIN_SERVER_PORT"] = str(-1)
output = cli(["--ready"], env=postgrest.config, expect_error=True)
(admin_host, admin_port) = get_admin_host_and_port_from_config(postgrest.config)
assert f"ERROR: invalid url - http://{admin_host}:{admin_port}/ready" in output
def test_cli_ready_flag_fail_with_special_hostname(defaultenv):
"test PostgREST ready flag fail when http exception occurs"
port = freeport()
host = "*4"
with run(env=defaultenv, host=host, port=port) as postgrest:
output = cli(["--ready"], env=postgrest.config, expect_error=True)
assert (
f'ERROR: The `--ready` flag cannot be used when server-host is configured as "{host}". Please update your server-host config to "localhost".'
in output
)
def test_cli_ready_flag_fail_when_no_admin_server(defaultenv):
"test PostgREST ready flag fail when admin server not running"
with run(env=defaultenv) as postgrest:
# We set admin-server-port to <empty> to disable admin server
postgrest.config["PGRST_ADMIN_SERVER_PORT"] = ""
output = cli(["--ready"], env=postgrest.config, expect_error=True)
assert (
"ERROR: Admin server is not running. Please check admin-server-port config."
in output
)