From a8e930e078c541a533db8a889ac06d379fff2599 Mon Sep 17 00:00:00 2001 From: Maurice Makaay Date: Tue, 26 Nov 2019 15:11:33 +0100 Subject: [PATCH] first commit --- Makefile | 61 +++ .../__pycache__/node_config.cpython-36.pyc | Bin 0 -> 795 bytes pgbouncemgr/__pycache__/state.cpython-36.pyc | Bin 0 -> 14407 bytes pgbouncemgr/manager.py | 41 ++ pgbouncemgr/node_config.py | 16 + pgbouncemgr/state.py | 355 ++++++++++++++ pgbouncemgr/state_store.py | 98 ++++ setup.py | 67 +++ tests/__init__.py | 7 + tests/__pycache__/__init__.cpython-36.pyc | Bin 0 -> 309 bytes .../test_node_config.cpython-36.pyc | Bin 0 -> 927 bytes tests/__pycache__/test_state.cpython-36.pyc | Bin 0 -> 15924 bytes tests/stubs.py | 54 ++ tests/test_node_config.py | 19 + tests/test_state.py | 464 ++++++++++++++++++ 15 files changed, 1182 insertions(+) create mode 100644 Makefile create mode 100644 pgbouncemgr/__pycache__/node_config.cpython-36.pyc create mode 100644 pgbouncemgr/__pycache__/state.cpython-36.pyc create mode 100644 pgbouncemgr/manager.py create mode 100644 pgbouncemgr/node_config.py create mode 100644 pgbouncemgr/state.py create mode 100644 pgbouncemgr/state_store.py create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-36.pyc create mode 100644 tests/__pycache__/test_node_config.cpython-36.pyc create mode 100644 tests/__pycache__/test_state.cpython-36.pyc create mode 100644 tests/stubs.py create mode 100644 tests/test_node_config.py create mode 100644 tests/test_state.py diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d366f79 --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +all: build + +lint: + pylint3 pgbouncemgr/*.py + +pytest: + python3 setup.py test + +test: + python3 -m unittest + +run: + PYTHONPATH=./ python3 pgbouncemgr/manager.py + +run-d: + PYTHONPATH=./ python3 pgbouncemgr/manager.py -d + +run-v: + PYTHONPATH=./ python3 pgbouncemgr/manager.py -v + +run-h: + PYTHONPATH=./ python3 pgbouncemgr/manager.py -h + +clean: + @/bin/rm -fR dist + @/bin/rm -fR deb_dist + @/bin/rm -fR build + @/bin/rm -fR *.egg-info + @/bin/rm -f python3-pgbouncemgr-*.tar.gz + @/bin/rm -fR */__pycache__ + +dist-clean: clean + @/bin/rm -fR builds/* + +runtime-deps: + apt-get install python3 python3-psycopg2 pgbouncer + # Create the default directory for the state file. + mkdir -p /var/lib/pgbouncemgr + chown -hR postgres:postgres /var/lib/pgbouncemgr + chmod 770 /var/lib/pgbouncemgr + chmod 660 /var/lib/pgbouncemgr/* + +build-deps: runtime-deps + # I had to add python-all, even though we're building for + # python3. The sstdeb build at some point searches for + # that package. Without the package, the build will crash. + apt-get install \ + pylint3 \ + python3-setuptools \ + python3-stdeb \ + python-all + +build: clean lint test + python3 setup.py --command-packages=stdeb.command bdist_deb + mkdir -p builds + /bin/rm -f builds/*.deb + mv deb_dist/*.deb builds/ + make clean > /dev/null + +upload: build + scp builds/*.deb mmakaay1@dpkg.xs4all.net: diff --git a/pgbouncemgr/__pycache__/node_config.cpython-36.pyc b/pgbouncemgr/__pycache__/node_config.cpython-36.pyc new file mode 100644 index 0000000000000000000000000000000000000000..346d10b4cf3ee9a9d068621f45d728d87fb36dd2 GIT binary patch literal 795 zcmZuuJ&)8d5FI<-Y*_9jzDgt-3Q}+>Xwm7UE9lbDt%R(-*teHcHrcbYe5|(Peg+LC zf5{RR{{jh#H;w`koTGW;*fVe5j9(oeFMhrF{_d+F`azSGV1EYlj{tATkP)}A&vHpd zzNI{=aQrc z=P>^ia1aO5s15`tP)1A)fkTwJ3iB2;x;Ej#c74(9+R8dzMb@CMI^PGi?OdPBpuBA^ z9kYKUH;4OqTD)JmqNy*6Edtlh`kSU0SvG6;vb7(k|3xe{ke_Tng-q+Zt$VGR)Mo$^ zY5ew&|2B6rM%~@z0$F31Lr=o-@N?->ILKf1S5fZ(TqZp6w%4Q5v0uLNEQQ4W*`)qm zu@&AsF7^r*C(dtupldeF{TeU$C4nYc=1J7#IMU4k{~w4b_g|r@{mA3Lj}*`CkW@m6 z{lkg8A)?Gvo@cw-SgpfC>rH2N4eyV%zS*ss;l|wqhhsW-#K7-30Um+lme^v!Ok`=6 Y#IrIN(NE%IdYowXdMJ1rGm%Zd0VUU|LjV8( literal 0 HcmV?d00001 diff --git a/pgbouncemgr/__pycache__/state.cpython-36.pyc b/pgbouncemgr/__pycache__/state.cpython-36.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6cb68354cb0d472cfcd0a08a3cfdeaae0d5f1807 GIT binary patch literal 14407 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 literal 0 HcmV?d00001 diff --git a/pgbouncemgr/manager.py b/pgbouncemgr/manager.py new file mode 100644 index 0000000..10c1fc6 --- /dev/null +++ b/pgbouncemgr/manager.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +"""The manager implements the main process that keeps track of changes in the + PostgreSQL cluster and that reconfigures pgbouncer when needed.""" + +from argparse import ArgumentParser + + +DEFAULT_CONFIG = "/etc/pgbouncer/pgbouncemgr.yaml" +DEFAULT_LOG_FACILITY = "LOG_LOCAL1" + + +def main(): + """Starts the pgbouncemgr main application.""" + args = _parse_arguments() + print(repr(args)) + + +def _parse_arguments(): + parser = ArgumentParser(description="pgbouncemgr") + parser.add_argument( + "-v", "--verbose", + default=False, action="store_true", + help="enable verbose output (default: disabled)") + parser.add_argument( + "-d", "--debug", + default=False, action="store_true", + help="enable debugging output (default: disabled)") + parser.add_argument( + "-f", "--log-facility", + default=DEFAULT_LOG_FACILITY, + help="syslog facility to use (default: %s)" % DEFAULT_LOG_FACILITY) + parser.add_argument( + "--config", + default=DEFAULT_CONFIG, + help="config file (default: %s)" % DEFAULT_CONFIG) + args = parser.parse_args() + return args + + +if __name__ == "__main__": + main() diff --git a/pgbouncemgr/node_config.py b/pgbouncemgr/node_config.py new file mode 100644 index 0000000..0050d3c --- /dev/null +++ b/pgbouncemgr/node_config.py @@ -0,0 +1,16 @@ +class NodeConfig(): + def __init__(self, node_id): + self.node_id = node_id + self.pgbouncer_config = None + self.host = None + self.port = None + + def set_pgbouncer_config(self, path): + self.pgbouncer_config = path + + def export(self): + return { + "pgbouncer_config": self.pgbouncer_config, + "host": self.host, + "port": self.port + } diff --git a/pgbouncemgr/state.py b/pgbouncemgr/state.py new file mode 100644 index 0000000..4dfe731 --- /dev/null +++ b/pgbouncemgr/state.py @@ -0,0 +1,355 @@ +# -*- coding: utf-8 -*- +# no-pylint: disable=missing-docstring,broad-except,too-many-instance-attributes + +from pgbouncemgr.node_config import NodeConfig + + +LEADER_UNKNOWN = "LEADER_UNKNOWN" +LEADER_CONNECTED = "LEADER_CONNECTED" +LEADER_DISCONNECTED = "LEADER_DISCONNECTED" +LEADER_STATUSES = [LEADER_UNKNOWN, LEADER_CONNECTED, LEADER_DISCONNECTED] + +NODE_UNKNOWN = "NODE_UNKNOWN" +NODE_OFFLINE = "NODE_OFFLINE" +NODE_PRIMARY = "NODE_PRIMARY" +NODE_STANDBY = "NODE_STANDBY" +NODE_STATUSES = [NODE_UNKNOWN, NODE_OFFLINE, NODE_PRIMARY, NODE_STANDBY] + + +class StateException(Exception): + """Used for all exceptions that are raised from pgbouncemgr.state.""" + +class DuplicateNodeAdded(StateException): + """Raised when a new node is added to the state using a node_id that + already exists in the contained nodes.""" + def __init__(self, node_id): + super().__init__( + "Node already exists in state (duplicate node_id=%s)" % node_id) + +class UnknownNodeRequested(StateException): + """Raised when a request is made for a node that is not contained + by the State.""" + def __init__(self, node_id): + super().__init__( + "Unknown node requested from state (unknown node_id=%s)" % + node_id) + +class NodeCannotBePromoted(StateException): + """Raised when an attempt is made to promote a node, which cannot + (yet) be promoted.""" + def __init__(self, node, reason): + super().__init__( + "Node '%s' cannot be promoted: %s" % (node.node_id, reason)) + +class InvalidSystemId(StateException): + """Raised when an invalid system_id is used.""" + def __init__(self, invalid): + display = "None" if invalid is None else "'%s'" % invalid + super().__init__( + "Invalid system_id provided; it must be a non-empty string " + + "(invalid system_id=%s)" % display) + +class SystemIdChanged(StateException): + """Raised when an attempt is made to change an already set system_id + for the state. This is not allowed, since it would mean that + pgboucer would be connected to a totally different dataset and + not a replica of the currently connected dataset.""" + def __init__(self, old_id, new_id): + super().__init__( + "The system_id cannot be changed (from=%s, to=%s)" % + (old_id, new_id)) + +class InvalidTimelineId(StateException): + """Raised when an invalid timeline_id is used. The timeline_id must + be an integer value or a value that can be case to an integer.""" + def __init__(self, timeline_id): + super().__init__( + "Invalid timeline_id provided; it must be an integer or a " + + "value that can be cast to an integer (invalid value=%s)" % + timeline_id) + +class TimelineIdLowered(StateException): + """Raised when an attempt is made to lower the timeline_id. + The timeline_id indicates the version of our dataset and decrementing + this timeline_id would mean that we connect the pgbouncer to a + dataset that is older than the currently connected dataset.""" + def __init__(self, old_id, new_id): + super().__init__( + "The timeline_id cannot be lowered (from=%d, to=%d)" % + (old_id, new_id)) + +class InvalidLeaderStatus(StateException): + """Raised when leader_status is set to an invalid value.""" + def __init__(self, status): + super().__init__( + ("The leader_status cannot be set to '%s' " + + "(valid values are: %s)") % (status, ", ".join(LEADER_STATUSES))) + +class InvalidNodeStatus(StateException): + """Raised when the status for a node is set to an invalid value.""" + def __init__(self, status): + super().__init__( + ("The node status cannot be set to '%s' " + + "(valid values are: %s)") % (status, ", ".join(NODE_STATUSES))) + +class State(): + def __init__(self): + self.modified = False + self._system_id = None + self._timeline_id = None + self._pgbouncer_config = None + self._leader_node_id = None + self._leader_error = None + self._leader_status = LEADER_UNKNOWN + self.nodes = {} + + def is_clean_slate(self): + """Returns True when the state does not yet contain a PostgreSQL system_id.""" + return self.system_id is None + + @property + def system_id(self): + return self._system_id + + @system_id.setter + def system_id(self, system_id): + """Sets the PostgreSQL system_id in the state. This is the system_id + that the pgbouncemgr will use as the valid system_id to + connect pgbouncer to.""" + if self._system_id is not None and self._system_id != system_id: + raise SystemIdChanged(self._system_id, system_id) + if system_id is None or system_id.strip() == "": + raise InvalidSystemId(system_id) + if self._system_id == system_id: + return + self.modified = True + self._system_id = system_id + + @property + def timeline_id(self): + return self._timeline_id + + @timeline_id.setter + def timeline_id(self, timeline_id): + """Sets the PostgreSQL timeline_id in the state. This is the + timeline_id of the currently connected dataset.""" + try: + timeline_id = int(timeline_id) + except ValueError: + raise InvalidTimelineId(timeline_id) + if self._timeline_id is not None and timeline_id < self._timeline_id: + raise TimelineIdLowered(self._timeline_id, timeline_id) + if self._timeline_id == timeline_id: + return + self.modified = True + self._timeline_id = timeline_id + + def add_node(self, node): + """Add a node to the state. Node can be a NodeConfig object or + a node_id. In case a node_id is provided, an NodeConfig object + will be created automatically.""" + if not isinstance(node, NodeConfig): + node = NodeConfig(node) + if node.node_id in self.nodes: + raise DuplicateNodeAdded(node.node_id) + node_state = NodeState(node, self) + self.nodes[node.node_id] = node_state + self.modified = True + return node_state + + def get_node(self, node_id): + """Retrieve a node from the state, identified by the provided + node_id.""" + if node_id not in self.nodes: + raise UnknownNodeRequested(node_id) + return self.nodes[node_id] + + def promote_node(self, node): + """Sets the provided node as the selected leader node for the + cluster. The leader status is reset to LEADER_UNKNOWN and should + after this be updated. + + The system_id, timeline_id and pgbouncer_config for the State + object are inherted from the provided leader node, and must + therefore be set before calling this method.""" + node = self.get_node(node.node_id) + if node.system_id is None: + raise NodeCannotBePromoted(node, "the node has no system_id") + if node.timeline_id is None: + raise NodeCannotBePromoted(node, "the node has no timeline_id") + if node.config.pgbouncer_config is None: + raise NodeCannotBePromoted(node, "the node has no pgbouncer_config") + if self._leader_node_id != node.node_id: + self._leader_node_id = node.node_id + self.modified = True + self.leader_status = LEADER_UNKNOWN + self.leader_error = None + self.system_id = node.system_id + self.timeline_id = node.timeline_id + self.pgbouncer_config = node.config.pgbouncer_config + + @property + def leader_node_id(self): + return self._leader_node_id + + @property + def leader_status(self): + return self._leader_status + + @leader_status.setter + def leader_status(self, status): + """Sets the pgbouncer connection status for the current leader + cluster node. Possible statuses are LEADER_UNKNOWN, + LEADER_CONNECTED and LEADER_DISCONNECTED.""" + if self._leader_status == status: + return + if status not in LEADER_STATUSES: + raise InvalidLeaderStatus(status) + self._leader_status = status + self.modified = True + + @property + def leader_error(self): + return self._leader_error + + @leader_error.setter + def leader_error(self, err): + """Sets the pgbouncer connection error for the current leader + cluster node. This error is used to inform about problems that + keep pgbouncer from serving the current leader cluster node.""" + if self._leader_error == err: + return + self._leader_error = err + self.modified = True + + @property + def pgbouncer_config(self): + return self._pgbouncer_config + + @pgbouncer_config.setter + def pgbouncer_config(self, pgbouncer_config): + """Sets the pgbouncer configuration file that must be activated + in order to connect pgbouncer to the correct backend + PostgreSQL server.""" + if self._pgbouncer_config == pgbouncer_config: + return + self._pgbouncer_config = pgbouncer_config + self.modified = True + + def export(self): + """Exports the data for the state that is to be stored in the + state storage.""" + return { + "system_id": self.system_id, + "timeline_id": self.timeline_id, + "pgbouncer_config": self.pgbouncer_config, + "leader_node_id": self.leader_node_id, + "leader_status": self.leader_status, + "leader_error": self.leader_error, + "nodes": dict((i, n.export()) for i, n in self.nodes.items()) + } + + +class NodeState(): + """This class encapsulates the information for a single node in a + PostgreSQL cluster.""" + + def __init__(self, config, parent_state): + self.node_id = config.node_id + self.config = config + self.parent_state = parent_state + self._system_id = None + self._timeline_id = None + self.status = NODE_UNKNOWN + self.err = None + + def reset(self): + """Reset the data for the node.""" + self.system_id = None + self.timeline_id = None + self.status = NODE_UNKNOWN + self.err = None + self.notify_parent() + + def notify_parent(self): + self.parent_state.modified = True + + @property + def system_id(self): + return self._system_id + + @system_id.setter + def system_id(self, system_id): + """Sets the PostgreSQL system_id, which is a cluster-wide identifier for + the database cluster data. When a PostgreSQL server uses a different + system_id than our cluster, then it does not serve the same dataset. + Possibly is was reinstalled or simply a new cluster which has not yet + been synced to the other cluster hosts.""" + if system_id is None or system_id.strip() == "": + raise InvalidSystemId(system_id) + if self._system_id == system_id: + return + self._system_id = system_id + self.notify_parent() + + @property + def timeline_id(self): + return self._timeline_id + + @timeline_id.setter + def timeline_id(self, timeline_id): + """Sets the PostgreSQL timeline_id, which is an identifier for the + version of the dataset that is served by a PostegreSQL server. + This version is incremented when a PIT recovery is done, or when a + database server is promoted to act as a primary server. + In pgbouncemgr, this timeline_id is used to make sure that we never + accidentally activate an old version of the dataset to be the new + primary dataset.""" + try: + timeline_id = int(timeline_id) + except ValueError: + raise InvalidTimelineId(timeline_id) + if self._timeline_id == timeline_id: + return + self._timeline_id = timeline_id + self.notify_parent() + + def set_status(self, status): + """Set the connection status for the node. This is used to indicate + whether or not a connection can be setup to the node from the + pgbouncemgr application and if the node is running in primary + or fallback mode. Possible values are: NODE_UNKNOWN, NODE_OFFLINE, + NODE_PRIMARY and NODE_STANDBY.""" + if self.status == status: + return + if status not in NODE_STATUSES: + raise InvalidNodeStatus(status) + self.status = status + self.notify_parent() + + def set_error(self, err): + """When there is some problem with this node, this method can be used + to set an error message for it, explaining the problem.""" + if self.err == err: + return + self.err = err + self.notify_parent() + + def promote(self): + """Promote this node to be the leader of the cluster. + This means that pgbouncer will be reconfigured to make use of + this node as the primary backend.""" + self.parent_state.promote_node(self) + + def export(self): + """Exports the data for the node that is to be stored in the + state storage.""" + return { + "node_id": self.node_id, + "config": self.config.export(), + "system_id": self.system_id, + "timeline_id": self.timeline_id, + "is_leader": self.parent_state._leader_node_id == self.node_id, + "status": self.status, + "error": self.err, + } diff --git a/pgbouncemgr/state_store.py b/pgbouncemgr/state_store.py new file mode 100644 index 0000000..f2b1fbc --- /dev/null +++ b/pgbouncemgr/state_store.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# no-pylint: disable=missing-docstring,broad-except,too-many-instance-attributes + +import json +from datetime import datetime +from os import rename +from os.path import isfile +from pgbouncemgr.log import format_exception_message + +STORE_ERROR = "error" +STORE_UPDATED = "updated" +STORE_NOCHANGE = "nochange" + +class State(): + system_id = None + timeline_id = None + pgbouncer_config = None + primary_node = None + primary_connected = False + primary_error = None + err = None + old_state = None + + def __init__(self, path): + self.path = path + self.load() + + def load(self): + """Load the state from the state file. + When this fails, an exception will be thrown.""" + # When no state file exists, we start with a clean slate. + if not isfile(self.path): + return + + # Otherwise, read the state from the state file. + with open(self.path, 'r') as stream: + try: + state = json.load(stream) + except json.decoder.JSONDecodeError: + # JSON decoding failed. This is beyond repair. + # Start with a clean slate to have the state data reinitialized. + return + + # Copy the state over to this state object. + self.system_id = state["system_id"] + self.timeline_id = state["timeline_id"] + self.pgbouncer_config = state["pgbouncer_config"] + self.primary_node = state["primary_node"] + + # The folowing properties are always filled dynamically by the manager. + # These are not restored from the state file on startup. + self.primary_connected = False + self.nodes = {} + + def is_clean_slate(self): + return self.system_id is None + + def set_primary_connected(self, connected, err=None): + self.primary_connected = connected + self.primary_error = err + + def store(self, nodes): + """Store the current state in the state file. + Returns True when the state was stored successfully. + Returns False when it failed. The error can be found in the err property.""" + stored_state = { + "system_id": self.system_id, + "timeline_id": self.timeline_id, + "pgbouncer_config": self.pgbouncer_config, + "primary_node": self.primary_node, + "primary_connected": self.primary_connected, + "primary_error": self.primary_error, + "nodes": dict((n.name, { + "host": n.conn_params["host"], + "port": n.conn_params["port"], + "status": n.status, + "system_id": n.system_id, + "timeline_id": n.timeline_id, + "error": n.err}) for n in nodes) + } + + # When the state has not changed, then don't write out a new state. + new_state = json.dumps(stored_state, sort_keys=True, indent=2) + if self.old_state and self.old_state == new_state: + return STORE_NOCHANGE + self.old_state = new_state + + try: + self.err = None + swap_path = "%s..SWAP" % self.path + with open(swap_path, "w") as file_handle: + print(new_state, file=file_handle) + rename(swap_path, self.path) + return STORE_UPDATED + except Exception as exception: + self.err = "Storing state to file (%s) failed: %s" % ( + self.path, format_exception_message(exception)) + return STORE_ERROR diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..ba6d6c8 --- /dev/null +++ b/setup.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +""" +pgbouncemgr +=========== + +PostgreSQL has no support for a multi-primary HA setup. Instead, there is +always a single primary server, which can be used for reading and writing, +and one or more replicate servers, which can only be used for reading. +Updates on the primary server are streamed to the connected replica servers. + +Therefore, when a client must connect to a PostgreSQL HA setup, it needs to +know what node to connect to (i.e. the currently active primary node). +Ideally, it has no knowledge about this, letting the client always connect +to the same endpoint. + +This is where tools like haproxy and pgbouncer come in (possibly themselves +in an HA setup using automatic failover based on keepalived or one of its +friends). The client can connect to haproxy or bgbouncer, and those can be +configured to send the connection to the correct PostgreSQL node. + +And this is where pgbouncemgr comes in. It actively monitors a PostgreSQL HA +cluster. Based on the monitoring data, it decides to what node clients must +connect and (re)configures pgbouncer to make that happen. +""" + +import os +import re +from time import time +from setuptools import setup + + +def _get_version(): + path = os.path.abspath(".") + pkginit = os.path.join(path, "pgbouncemgr", "__init__.py") + with open(pkginit, "r") as fh: + for line in fh.readlines(): + m = re.match(r"^__version__\s*=\s*\"(.*)\"", line) + if (m is not None): + version = m.groups()[0] + return "%s.%d" % (version, time()) + raise Exception("Unable to read version from %s" % pkginit) + + +setup( + name="pgbouncemgr", + version=_get_version(), + url="https://github.xs4all.net/XS4ALL/xs4all-pgbouncemgr", + license="BSD", + author="Maurice Makaay", + author_email="mmakaay1@xs4all.net", + description="Automatic (re)configuration of a pgbouncer server for a PostgreSQL HA cluster", + long_description=__doc__, + packages=[ + "pgbouncemgr" + ], + entry_points = { + 'console_scripts': [ + 'pgbouncemgr=pgbouncemgr.manager:main'] + }, + data_files=[ + ("/etc/systemd/system", ["etc/pgbouncemgr.service"]) + ], + zip_safe=False, + include_package_data=True, + platforms="any", + test_suite="tests.suite" +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8ed8c31 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +import unittest + +def suite(): + test_loader = unittest.TestLoader() + return test_loader.discover('tests') diff --git a/tests/__pycache__/__init__.cpython-36.pyc b/tests/__pycache__/__init__.cpython-36.pyc new file mode 100644 index 0000000000000000000000000000000000000000..953bf6d57e5211b65ea6d83348c63ac719a4d0ae GIT binary patch literal 309 zcmYk1u}TCn5Qb;6w;E2a5no~pO(Aw7f~{Pm)xr_(Hetz;+pJ0UM9}Ii1bg4%UMZGV zzJisLEyRKUn+g9+COMzYrXTO0i#GuHBK-xU>Y5Zz2nrNhs6jDRyh4o%@1UA|lLPZ6 zGY69L)D0;d6C$i}2QTmpYo>6QXV^TIQHL0+BJrU!F(1360xoUtxWe zU1>*S5B`Ae{J9X@r5Dz$MDIduy$%mn^zF(GU86UxKkf9R7*_}>4Q-I}tba;n7);b< ke%gcp$kB7k74rq9u^t!v!yy@oUv8WltiH-^A^(rFU$`_!_W%F@ literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_node_config.cpython-36.pyc b/tests/__pycache__/test_node_config.cpython-36.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7da08f4ecc072cdadba18960a9f34b397238dc2c GIT binary patch literal 927 zcmah{L2uJA6t*2_3*E|gK!5~S5CPE~5I2U90^$}Stz0Z2E33V(Q<`M9%d~2oxVXqjF6w?#={|h2g^Q%popRs5%|w4 zIwl{9iWI*hifeMpR*{GlJtS%T3-*#CrJg>YVA?+b-hn-aL=!<35lm6Vt_XY@pafSj zKy3NFqER|;>f_mTtgW*XdX0i05v&}ReGkEbsulf4FUcuY^pb)SMoFj$RMAGoH>wyb zMSzeMo;%MIg13!vDf8Uw z>ez2}z3;>fK;`d)>}zSVYIc;huy<;-J*%=FXg)PBYki)f1RJ6ZWF5q)z3>mQC3B-o zrwe_))|j9V$Utbq?o#|MpRL=$ywSft-f$J24X)mY-d!UIb%^eIVwF^6t`6ofzp?05oPxD}@{M;%S)ppfBY_&)X zL(=8zTgUq!Mv=xok+Lr5T1r2Va^9##h54?Oc)(r9>;j7!VpvNJ8R|87^|oOJH3?0U s@FwLxS=2L!!-xm{{BWwH8^`FAF|_j%ZWN;22Je$MovOFEqvweJ0EdC@7ytkO literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_state.cpython-36.pyc b/tests/__pycache__/test_state.cpython-36.pyc new file mode 100644 index 0000000000000000000000000000000000000000..95bbc03b25def644135b073c46853efdaaf67e80 GIT binary patch literal 15924 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