Skip to content

Commit a981600

Browse files
committed
Merge remote-tracking branch 'zalando/master' into multisite
2 parents 41eef52 + 03735ec commit a981600

25 files changed

+519
-87
lines changed

docs/ENVIRONMENT.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ REST API
208208
- **PATRONI\_RESTAPI\_HTTP\_EXTRA\_HEADERS**: (optional) HTTP headers let the REST API server pass additional information with an HTTP response.
209209
- **PATRONI\_RESTAPI\_HTTPS\_EXTRA\_HEADERS**: (optional) HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
210210
- **PATRONI\_RESTAPI\_REQUEST\_QUEUE\_SIZE**: (optional): Sets request queue size for TCP socket used by Patroni REST API. Once the queue is full, further requests get a "Connection denied" error. The default value is 5.
211+
- **PATRONI\_RESTAPI\_SERVER\_TOKENS**: (optional) Configures the value of the ``Server`` HTTP header. ``Original`` (default) will expose the original behaviour and display the BaseHTTP and Python versions, e.g. ``BaseHTTP/0.6 Python/3.12.3``. ``Minimal``: The header will contain only the Patroni version, e.g. ``Patroni/4.0.0``. ``ProductOnly``: The header will contain only the product name, e.g. ``Patroni``.
211212

212213
.. warning::
213214

docs/README.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ Patroni/PostgreSQL nodes are decoupled from DCS nodes (except when Patroni imple
3434
there is no requirement on the minimal number of nodes. Running a cluster consisting of one primary and one standby is
3535
perfectly fine. You can add more standby nodes later.
3636

37+
**2-node clusters** (primary + standby) are common and provide automatic failover with high availability. Note that during failover, you'll temporarily have no redundancy until the failed node rejoins.
38+
39+
**DCS requirements**: Your DCS (etcd, ZooKeeper, Consul) has to run with **3 or 5 nodes** for proper consensus and fault tolerance. A single DCS cluster can store information for hundreds or thousands of Patroni clusters using different namespace/scope combinations.
40+
3741
Running and Configuring
3842
-----------------------
3943

docs/yaml_configuration.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,10 @@ REST API
349349
- **http\_extra\_headers**: (optional): HTTP headers let the REST API server pass additional information with an HTTP response.
350350
- **https\_extra\_headers**: (optional): HTTPS headers let the REST API server pass additional information with an HTTP response when TLS is enabled. This will also pass additional information set in ``http_extra_headers``.
351351
- **request_queue_size**: (optional): Sets request queue size for TCP socket used by Patroni REST API. Once the queue is full, further requests get a "Connection denied" error. The default value is 5.
352+
- **server_tokens**: (optional): Configures the value of the ``Server`` HTTP header.
353+
- ``Minimal``: The header will contain only the Patroni version, e.g. ``Patroni/4.0.0``.
354+
- ``ProductOnly``: The header will contain only the product name, e.g. ``Patroni``.
355+
- ``Original`` (default): The header will expose the original behaviour and display the BaseHTTP and Python versions, e.g. ``BaseHTTP/0.6 Python/3.12.3``.
352356

353357
Here is an example of both **http_extra_headers** and **https_extra_headers**:
354358

extras/startup-scripts/patroni.service

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ EnvironmentFile=-/etc/patroni_env.conf
2323

2424
# Pre-commands to start watchdog device
2525
# Uncomment if watchdog is part of your patroni setup
26-
#ExecStartPre=-/usr/bin/sudo /sbin/modprobe softdog
27-
#ExecStartPre=-/usr/bin/sudo /bin/chown postgres /dev/watchdog
26+
#ExecStartPre=-+/sbin/modprobe softdog
27+
#ExecStartPre=-+/bin/chown postgres /dev/watchdog
2828

2929
# Start the patroni process
3030
ExecStart=/bin/patroni /etc/patroni.yml

features/environment.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@ def read_label(self, label):
123123
except IOError:
124124
return None
125125

126+
def read_config(self):
127+
config = dict()
128+
with open(self._config) as r:
129+
config = yaml.safe_load(r)
130+
return config
131+
126132
@staticmethod
127133
def recursive_update(dst, src):
128134
for k, v in src.items():
@@ -132,11 +138,10 @@ def recursive_update(dst, src):
132138
dst[k] = v
133139

134140
def update_config(self, custom_config):
135-
with open(self._config) as r:
136-
config = yaml.safe_load(r)
137-
self.recursive_update(config, custom_config)
138-
with open(self._config, 'w') as w:
139-
yaml.safe_dump(config, w, default_flow_style=False)
141+
config = self.read_config()
142+
self.recursive_update(config, custom_config)
143+
with open(self._config, 'w') as w:
144+
yaml.safe_dump(config, w, default_flow_style=False)
140145
self._scope = config.get('scope', 'batman')
141146

142147
def add_tag_to_config(self, tag, value):
@@ -857,7 +862,7 @@ def start(self, name, max_wait_limit=40, custom_config=None):
857862
self._processes[name].start(max_wait_limit)
858863

859864
def __getattr__(self, func):
860-
if func not in ['stop', 'query', 'write_label', 'read_label', 'check_role_has_changed_to',
865+
if func not in ['stop', 'query', 'write_label', 'read_label', 'read_config', 'check_role_has_changed_to',
861866
'add_tag_to_config', 'get_watchdog', 'patroni_hang', 'backup', 'read_patroni_log']:
862867
raise AttributeError("PatroniPoolController instance has no attribute '{0}'".format(func))
863868

features/standby_cluster.feature

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,17 @@ Feature: standby cluster
6464
And I receive a response role standby_leader
6565
And replication works from postgres-0 to postgres-1 after 15 seconds
6666
And there is a postgres-1_cb.log with "on_role_change replica batman1\non_role_change standby_leader batman1" in postgres-1 data directory
67+
68+
Scenario: demote cluster
69+
When I switch standby cluster batman1 to archive recovery
70+
Then Response on GET http://127.0.0.1:8009/patroni contains replication_state=in archive recovery after 30 seconds
71+
When I demote cluster batman
72+
And "members/postgres-0" key in DCS has role=standby_leader after 20 seconds
73+
And "members/postgres-0" key in DCS has state=running after 10 seconds
74+
75+
Scenario: promote cluster
76+
When I issue a PATCH request to http://127.0.0.1:8009/config with {"standby_cluster": null}
77+
Then I receive a response code 200
78+
And postgres-1 role is the primary after 10 seconds
79+
When I add the table foo2 to postgres-1
80+
Then table foo2 is present on postgres-0 after 20 seconds

features/steps/standby_cluster.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import json
12
import os
23
import time
34

45
from behave import step
6+
from patroni_api import check_response, do_request
57

68

79
def callbacks(context, name):
@@ -66,3 +68,29 @@ def check_replication_status(context, pg_name1, pg_name2, timeout):
6668
time.sleep(1)
6769
else:
6870
assert False, "{0} is not replicating from {1} after {2} seconds".format(pg_name1, pg_name2, timeout)
71+
72+
73+
@step('I switch standby cluster {scope:name} to archive recovery')
74+
def standby_cluster_archive(context, scope, demote=False):
75+
for name, proc in context.pctl._processes.items():
76+
if proc._scope != scope or not proc._is_running:
77+
continue
78+
79+
config = context.pctl.read_config(name)
80+
url = f'http://{config["restapi"]["connect_address"]}/config'
81+
data = {
82+
"standby_cluster": {
83+
"restore_command": config['bootstrap']['dcs']['postgresql']['parameters']['restore_command']
84+
}
85+
}
86+
if not demote:
87+
data['standby_cluster']['primary_slot_name'] = data['standby_cluster']['host'] =\
88+
data['standby_cluster']['port'] = None
89+
do_request(context, 'PATCH', url, json.dumps(data))
90+
check_response(context, 'code', 200)
91+
break
92+
93+
94+
@step('I demote cluster {scope:name}')
95+
def demote_cluster(context, scope):
96+
standby_cluster_archive(context, scope, True)

patroni/__main__.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,12 @@ def passtochild(signo: int, stack_frame: Optional[FrameType]) -> None:
417417
if pid:
418418
os.kill(pid, signo)
419419

420+
import multiprocessing
421+
patroni = multiprocessing.Process(target=patroni_main, args=(args.configfile,))
422+
patroni.start()
423+
pid = patroni.pid
424+
425+
# Set up signal handlers after fork to prevent child from inheriting them
420426
if os.name != 'nt':
421427
signal.signal(signal.SIGCHLD, sigchld_handler)
422428
signal.signal(signal.SIGHUP, passtochild)
@@ -426,11 +432,6 @@ def passtochild(signo: int, stack_frame: Optional[FrameType]) -> None:
426432
signal.signal(signal.SIGINT, passtochild)
427433
signal.signal(signal.SIGABRT, passtochild)
428434
signal.signal(signal.SIGTERM, passtochild)
429-
430-
import multiprocessing
431-
patroni = multiprocessing.Process(target=patroni_main, args=(args.configfile,))
432-
patroni.start()
433-
pid = patroni.pid
434435
patroni.join()
435436

436437

patroni/api.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,16 @@ def __init__(self, request: Any,
119119
self.__start_time: float = 0.0
120120
self.path_query: Dict[str, List[str]] = {}
121121

122+
def version_string(self) -> str:
123+
"""Override the default version string to return the server header as specified in the configuration.
124+
125+
If the server header is not set, then it returns the default version string of the HTTP server.
126+
127+
:return: ``Server`` version string, which is either the server header or the default version string
128+
from the :class:`BaseHTTPRequestHandler`.
129+
"""
130+
return self.server.server_header or super().version_string()
131+
122132
def _write_status_code_only(self, status_code: int) -> None:
123133
"""Write a response that is composed only of the HTTP status.
124134
@@ -567,7 +577,7 @@ def do_GET_config(self) -> None:
567577
``502`` instead.
568578
"""
569579
cluster = self.server.patroni.dcs.cluster or self.server.patroni.dcs.get_cluster()
570-
if cluster.config:
580+
if cluster.config and cluster.config.modify_version:
571581
self._write_json_response(200, cluster.config.data)
572582
else:
573583
self.send_error(502)
@@ -1517,6 +1527,33 @@ def __init__(self, patroni: Patroni, config: Dict[str, Any]) -> None:
15171527
self.reload_config(config)
15181528
self.daemon = True
15191529

1530+
def construct_server_tokens(self, token_config: str) -> str:
1531+
"""Construct the value for the ``Server`` HTTP header based on *server_tokens*.
1532+
1533+
:param server_tokens: the value of ``restapi.server_tokens`` configuration option.
1534+
1535+
:returns: a string to be used as the value of ``Server`` HTTP header.
1536+
"""
1537+
token = token_config.lower()
1538+
logger.debug('restapi.server_tokens is set to "%s".', token_config)
1539+
1540+
# If 'original' is set, we do not modify the Server header.
1541+
# This is useful for compatibility with existing setups that expect the original header.
1542+
if token == 'original':
1543+
return ""
1544+
1545+
# If 'productonly', or 'minimal' is set, we construct the header accordingly.
1546+
if token == 'productonly': # Show only the product name, without versions.
1547+
return 'Patroni'
1548+
elif token == 'minimal': # Show only the product name and version, without PostgreSQL version.
1549+
return f'Patroni/{self.patroni.version}'
1550+
else:
1551+
# Token is not valid (one of 'original', 'productonly', 'minimal') so report a warning and
1552+
# return an empty string.
1553+
logger.warning('restapi.server_tokens is set to "%s". Patroni will not modify the Server header. '
1554+
'Valid values are: "Minimal", "ProductOnly".', token_config)
1555+
return ""
1556+
15201557
def query(self, sql: str, *params: Any) -> List[Tuple[Any, ...]]:
15211558
"""Execute *sql* query with *params* and optionally return results.
15221559
@@ -1895,6 +1932,9 @@ def reload_config(self, config: Dict[str, Any]) -> None:
18951932
assert isinstance(self.__listen, str)
18961933
self.connection_string = uri(self.__protocol, config.get('connect_address') or self.__listen, 'patroni')
18971934

1935+
# Define the Server header response using the server_tokens option.
1936+
self.server_header = self.construct_server_tokens(config.get('server_tokens', 'original'))
1937+
18981938
def handle_error(self, request: Union[socket.socket, Tuple[bytes, socket.socket]],
18991939
client_address: Tuple[str, int]) -> None:
19001940
"""Handle any exception that is thrown while handling a request to the REST API.

patroni/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,7 @@ def _set_section_values(section: str, params: List[str]) -> None:
536536
_set_section_values('restapi', ['listen', 'connect_address', 'certfile', 'keyfile', 'keyfile_password',
537537
'cafile', 'ciphers', 'verify_client', 'http_extra_headers',
538538
'https_extra_headers', 'allowlist', 'allowlist_include_members',
539-
'request_queue_size'])
539+
'request_queue_size', 'server_tokens'])
540540
_set_section_values('ctl', ['insecure', 'cacert', 'certfile', 'keyfile', 'keyfile_password'])
541541
_set_section_values('postgresql', ['listen', 'connect_address', 'proxy_address',
542542
'config_dir', 'data_dir', 'pgpass', 'bin_dir'])

0 commit comments

Comments
 (0)