packages feed

salmon-ops-recipes-0.2.0.0: test/Test/HaproxySpec.hs

{-# LANGUAGE OverloadedStrings #-}

{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Haproxy": what it renders and
what it refuses to render.

The property the module exists for is not a line of configuration but the
absence of one: nothing in @haproxy.cfg@ says which member leads, so a
failover is not a configuration change (@specs\/pg-patroni.md@, T3). The
golden text below is that claim spelled out -- every member is behind every
listener, and only the health-check path differs.
-}
module Test.HaproxySpec (tests) where

import qualified Data.Text as Text
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertEqual, testCase)

import qualified Salmon.Builtin.Nodes.Haproxy as Haproxy
import Test.Harness (runUp)

tests :: TestTree
tests =
    testGroup
        "Salmon.Builtin.Nodes.Haproxy"
        [ testCase "the leader port and the replica port, over the same members" $
            assertEqual "" golden (Haproxy.renderConfig cfg)
        , testCase "each role asks Patroni's own endpoint" $
            assertEqual
                ""
                ["/primary", "/replica", "/replica?lag=1048576", "/read-only"]
                (fmap Haproxy.checkPath [Haproxy.Primary, Haproxy.Replica, Haproxy.ReplicaWithin 1048576, Haproxy.ReadOnly])
        , testCase "members are listed in declared order under every listener, whoever leads" $ do
            let servers = filter ("    server " `Text.isPrefixOf`) (Text.lines (Haproxy.renderConfig cfg))
            assertEqual "" ["pg-a", "pg-b", "pg-c", "pg-a", "pg-b", "pg-c"] (fmap ((!! 1) . Text.words) servers)
        , testCase "a demoted leader's sessions are closed, not left writing into a replica" $
            assertBool "" ("on-marked-down shutdown-sessions" `Text.isInfixOf` Haproxy.renderConfig cfg)
        , testCase "the status page is there only when asked for" $ do
            assertBool "" (not ("listen stats" `Text.isInfixOf` Haproxy.renderConfig cfg))
            let withStats = Haproxy.renderConfig cfg{Haproxy.haproxy_stats = Just ("127.0.0.1", 7000)}
            assertBool "" ("listen stats\n    mode http\n    bind 127.0.0.1:7000\n" `Text.isInfixOf` withStats)
        , testCase "the watched file is the one rendered" $
            assertEqual "" "/etc/haproxy/haproxy.cfg" (Haproxy.configPath cfg)
        , testCase "a sound configuration has nothing said against it" $
            assertEqual "" [] (Haproxy.validate cfg)
        , testCase "objections are collected, not first-found" $
            assertEqual
                ""
                [ "listener name \"primary\" is declared more than once"
                , "member name \"pg-a\" is declared more than once"
                , "port 5000 is bound more than once"
                ]
                ( Haproxy.validate
                    cfg
                        { Haproxy.haproxy_listeners =
                            [ Haproxy.Listener "primary" "*" 5000 Haproxy.Primary
                            , Haproxy.Listener "primary" "*" 5000 Haproxy.Replica
                            ]
                        , Haproxy.haproxy_members = [member "pg-a" "10.0.0.1", member "pg-a" "10.0.0.2"]
                        }
                )
        , testCase "an empty configuration is refused" $
            assertEqual
                ""
                ["no listeners", "no members"]
                (Haproxy.validate cfg{Haproxy.haproxy_listeners = [], Haproxy.haproxy_members = []})
        , testCase "a name or host that would split into two words is refused" $ do
            let bad = Haproxy.validate cfg{Haproxy.haproxy_members = [(member "pg a" "10.0.0.1 backup")]}
            assertEqual
                ""
                [ "member name \"pg a\" is not a valid server name"
                , "member \"pg a\" has host \"10.0.0.1 backup\", which is not one word"
                ]
                bad
        , testCase "the status page's port and name are not a listener's to take" $
            assertEqual
                ""
                [ "listener name \"stats\" is taken by the status page"
                , "port 5000 is bound more than once"
                ]
                ( Haproxy.validate
                    cfg
                        { Haproxy.haproxy_stats = Just ("*", 5000)
                        , Haproxy.haproxy_listeners = [Haproxy.Listener "stats" "*" 5000 Haproxy.Primary]
                        }
                )
        , testCase "out-of-range ports and non-positive check settings are refused" $
            assertEqual
                ""
                [ "port 0 is not a port"
                , "port 70000 is not a port"
                , "checks need a positive interval, fall, rise and timeout"
                ]
                ( Haproxy.validate
                    cfg
                        { Haproxy.haproxy_members = [(member "pg-a" "10.0.0.1"){Haproxy.member_pg_port = 0, Haproxy.member_rest_port = 70000}]
                        , Haproxy.haproxy_checks = Haproxy.defaultChecks{Haproxy.checks_fall = 0}
                        }
                )
        , -- a file haproxy refuses is a router that stays down at its next
          -- restart, so the node fails instead of writing it.
          testCase "an invalid configuration fails the node rather than being written" $ do
            ok <- runUp (Haproxy.configFiles cfg{Haproxy.haproxy_config_dir = "/nonexistent-salmon-test", Haproxy.haproxy_members = []})
            assertBool "the pass reported a failure" (not ok)
        ]

member :: Text.Text -> Text.Text -> Haproxy.Member
member name host = Haproxy.Member name host 5432 8008

cfg :: Haproxy.HaproxyConfig
cfg = Haproxy.patroniRouter [member "pg-a" "10.0.0.1", member "pg-b" "10.0.0.2", member "pg-c" "10.0.0.3"]

golden :: Text.Text
golden =
    Text.unlines
        [ "# written by salmon (Salmon.Builtin.Nodes.Haproxy); edits are overwritten"
        , "global"
        , "    maxconn 100"
        , "    log stdout format short daemon"
        , ""
        , "defaults"
        , "    log global"
        , "    mode tcp"
        , "    retries 2"
        , "    timeout connect 4s"
        , "    timeout client 1800s"
        , "    timeout server 1800s"
        , "    timeout check 5s"
        , ""
        , "listen primary"
        , "    bind *:5000"
        , "    option httpchk GET /primary"
        , "    http-check expect status 200"
        , "    default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions"
        , "    server pg-a 10.0.0.1:5432 maxconn 100 check port 8008"
        , "    server pg-b 10.0.0.2:5432 maxconn 100 check port 8008"
        , "    server pg-c 10.0.0.3:5432 maxconn 100 check port 8008"
        , ""
        , "listen replicas"
        , "    bind *:5001"
        , "    option httpchk GET /replica"
        , "    http-check expect status 200"
        , "    default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions"
        , "    balance leastconn"
        , "    server pg-a 10.0.0.1:5432 maxconn 100 check port 8008"
        , "    server pg-b 10.0.0.2:5432 maxconn 100 check port 8008"
        , "    server pg-c 10.0.0.3:5432 maxconn 100 check port 8008"
        ]