Skip to content

Commit

Permalink
Backport: Add Solr support to testserver.
Browse files Browse the repository at this point in the history
  • Loading branch information
jone committed Jul 10, 2019
1 parent 8093627 commit 42696f4
Show file tree
Hide file tree
Showing 10 changed files with 335 additions and 1 deletion.
1 change: 1 addition & 0 deletions README.rst
Expand Up @@ -1039,6 +1039,7 @@ The ports used by the testserver can easily be changed through environment varia

- ``ZSERVER_PORT`` - the port of the GEVER http server (default: ``55001``)
- ``TESTSERVER_CTL_PORT`` - the port of the XMLRPC control server (default: ``55002``).
- ``SOLR_PORT`` - the port of the Solr server which is controlled by the tesserver (default: ``55003``).


Custom fixtures
Expand Down
10 changes: 10 additions & 0 deletions base-plone-4.3.x.cfg
Expand Up @@ -4,11 +4,21 @@ extends =
versions.cfg
sources.cfg
https://raw.githubusercontent.com/4teamwork/ftw-buildouts/master/test-versions.cfg
https://raw.githubusercontent.com/4teamwork/gever-buildouts/master/solr.cfg

package-name = opengever.core
package-namespace = opengever
test-egg = opengever.core[api, tests]

solr-core-name = testing
solr-port = 8984

[solr]
gever-cores =
${buildout:solr-core-name}
testserver
cores = ${solr:gever-cores}

[test]
arguments = ['-s', '${buildout:package-namespace}', '-s', 'plonetheme', '--exit-with-status', '--auto-color', '--auto-progress', '--xml', '--package-path', '${buildout:directory}/${buildout:package-namespace}', '${buildout:package-namespace}', '--package-path', '${buildout:directory}/plonetheme', 'plonetheme']

Expand Down
3 changes: 3 additions & 0 deletions development.cfg
Expand Up @@ -59,6 +59,9 @@ environment-vars +=
zcml +=
opengever.core

[solr]
cores = ${solr:gever-cores}

[test]
initialization +=
os.environ['SABLON_BIN'] = '${buildout:sablon-executable}'
Expand Down
6 changes: 6 additions & 0 deletions docs/HISTORY.txt
@@ -1,6 +1,12 @@
Changelog
=========

2019.3.1 (unreleased)
---------------------

- Backport: Add Solr support to testserver. [jone]


2019.3.0 (2019-06-17)
---------------------

Expand Down
251 changes: 251 additions & 0 deletions opengever/core/solr_testing.py
@@ -0,0 +1,251 @@
from opengever.core.cached_testing import BUILDOUT_DIR
from threading import Thread
import atexit
import io
import os
import requests
import signal
import socket
import subprocess
import time


class SolrServer(object):
"""The SolrServer singleton is in charge of starting and stopping the solr server.
"""

@classmethod
def get_instance(klass):
if not hasattr(klass, '_instance'):
klass._instance = klass()
return klass._instance

def configure(self, port, core):
self._configured = True
self.port = int(port)
self.core = core
SolrReplicationAPIClient.get_instance().configure(port, core)
return self

def start(self):
"""Start the solr server in a subprocess.
"""
assert not self._running, 'Solr was already started.'
self._require_configured()
self._thread = Thread(target=self._run_server_process)
self._thread.daemon = True
self._thread.start()
atexit.register(self.stop)
self._running = True
return self

def stop(self):
"""Make sure the solr server is stopped.
"""
if not self._running:
return self

self._require_configured()
try:
os.kill(self._process.pid, signal.SIGINT)
except KeyboardInterrupt:
pass
except OSError as exc:
if exc.strerror != 'No such process':
raise
self._thread.join()
self._running = False
return self

def is_ready(self):
"""Check whether the solr server has bound the port already.
"""
sock = socket.socket()
sock.settimeout(0.1)
try:
result = sock.connect_ex(('127.0.0.1', self.port))
finally:
sock.close()
return result == 0

def await_ready(self, timeout=60, interval=0.1, verbose=False):
"""Wait until the solr server has bound the port.
"""
self._require_configured()
for index in range(int(timeout / interval)):
if self.is_ready():
return self
if verbose:
print '... waiting for solr ({})'.format(index)
time.sleep(interval)

raise ValueError('Timeout ({}s) while waiting for solr.'.format(timeout))

def print_tail(self, max_lines=100):
"""Print the last lines of the captured stdout of the solr server.
"""
print '\n'.join(self._stdout.getvalue().split('\n')[-max_lines:])

def __init__(self):
assert not hasattr(type(self), '_instance'), 'Use SolrServer.get_instance()'
self._configured = False
self._running = False

def _run_server_process(self):
command = ['bin/solr', 'fg']
env = os.environ.copy()
env.setdefault('SOLR_PORT', str(self.port))
self._stdout = io.StringIO()
self._process = subprocess.Popen(command, stdout=subprocess.PIPE, env=env)
while True:
if self._process.poll():
return
self._stdout.writelines((self._process.stdout.readline().decode('utf-8'),))

def _require_configured(self):
if not self._configured:
raise ValueError('Configure first with SolrServer.get_instance().configure()')


class SolrReplicationAPIClient(object):
"""This is a client for the Solr Replication API.
See https://lucene.apache.org/solr/guide/7_6/making-and-restoring-backups.html
for details regarding the Replication API.
Basic usage:
- Start Solr on SOLR_PORT
- If necessary, delete all data in Solr. For example using
curl http://localhost:12333/solr/solrtest/update?commit=true -H "Content-type: application/json" --data-binary "{'delete': {'query': '*:*'}}"
- Pick a unique backup name below. Backups will be created in
var/solr/solrtest/data/
- Run the tests below
"""

@classmethod
def get_instance(klass):
if not hasattr(klass, '_instance'):
klass._instance = klass()
return klass._instance

def configure(self, port, core):
self._configured = True
self.port = int(port)
self.core = core
self.base_url = 'http://localhost:{}/solr/{}'.format(port, core)
return self

def __init__(self):
assert not hasattr(type(self), '_instance'), 'Use SolrReplicationAPIClient.get_instance()'
self._configured = False
self.session = requests.session()
self.session.headers.update({'Accept': 'application/json'})

def clear(self):
"""Delete all documents from Solr.
"""
self._require_configured()
response = requests.get(self.base_url + '/update?commit=true',
json={'delete': {'query': '*:*'}})
try:
response.raise_for_status()
except Exception:
print response.json()
raise
return response.json()

def create_backup(self, name):
"""Create a backup of the snapshot state identified by `name`.
"""
self._require_configured()
backup_name = 'bak-{}'.format(name)

# When the backup exists, delete it. Solr can't do that.
backup_path = BUILDOUT_DIR.joinpath(
'var', 'solr', self.core, 'data', 'snapshot.{}'.format(backup_name))
if backup_path.exists():
backup_path.rmtree()

# First, trigger solr commit so that changes are writte to disk.
self.session.get(url=self.base_url + '/update?commit=true').raise_for_status()

response = self.session.get(url=self.base_url + '/replication',
params={'command': 'backup', 'name': backup_name})
try:
response.raise_for_status()
except Exception:
print response.json()
raise
return response

def restore_backup(self, name):
"""Restore a backup. `name` refers to the snapshot name.
"""
self._require_configured()
response = self.session.get(url=self.base_url + '/replication',
params={'command': 'restore', 'name': 'bak-{}'.format(name)})
try:
response.raise_for_status()
except Exception:
print response.json()
raise
return response

def restore_status(self):
"""Check for the progress of a running restore operation.
"""
self._require_configured()
response = self.session.get(url=self.base_url + '/replication',
params={'command': 'restorestatus'})
try:
response.raise_for_status()
except Exception:
print response.json()
raise
response_data = response.json()

# Only newer Solr versions have a response (!) status
if 'status' in response_data and response_data['status'] != 'OK':
print response
print response_data
raise Exception('Failed to check restore status')

return response_data['restorestatus']

def await_restored(self, timeout=60, interval=0.1):
"""Block until the solr server has no restore in progress.
"""
for index in range(int(timeout / interval)):
status = self.restore_status()
if status['status'] == 'No restore actions in progress':
return
if status['status'] not in ('success', 'In Progress'):
raise ValueError('Unexpected restore status: {!r}'.format(status['status']))
if status['status'] == 'success':
return
time.sleep(interval)

raise ValueError('Timeout ({}s) while waiting for restore to finish.'.format(timeout))

def _require_configured(self):
if not self._configured:
raise ValueError('Configure first with SolrServer.get_instance().configure()')


if __name__ == '__main__':
# selftest:
# ./bin/zopepy opengever/core/solr_testing.py
port = 18988
core = 'fritz'
SolrReplicationAPIClient.get_instance().configure(port, core)
server = SolrServer.get_instance().configure(port)

server.start()
print '... starting'
server.await_ready(verbose=True)
print '... solr output:'
server.print_tail(max_lines=10)
print '... stopping'
server.stop()
print '... finished'
38 changes: 38 additions & 0 deletions opengever/core/testserver.py
Expand Up @@ -4,23 +4,52 @@
from ftw.testing import freeze
from ftw.testing import staticuid
from ftw.testing.layer import COMPONENT_REGISTRY_ISOLATION
from opengever.base.interfaces import ISearchSettings
from opengever.base.model import create_session
from opengever.core.solr_testing import SolrReplicationAPIClient
from opengever.core.solr_testing import SolrServer
from opengever.core.testing import OpengeverFixture
from opengever.testing.helpers import incrementing_intids
from plone import api
from plone.app.testing import applyProfile
from plone.app.testing import FunctionalTesting
from plone.testing import z2
from zope.configuration import xmlconfig
from zope.globalrequest import setRequest
import imp
import os
import pytz
import transaction


SOLR_PORT = os.environ.get('SOLR_PORT', '55003')
SOLR_CORE = os.environ.get('SOLR_CORE', 'testserver')


class TestserverLayer(OpengeverFixture):
defaultBases = (COMPONENT_REGISTRY_ISOLATION,)

def setUpZope(self, app, configurationContext):
SolrServer.get_instance().configure(SOLR_PORT, SOLR_CORE).start()
import collective.indexing.monkey # noqa
import ftw.solr.patches # noqa

super(TestserverLayer, self).setUpZope(app, configurationContext)

# Solr must be started before registering the connection since ftw.solr
# will get the schema from solr and cache it.
SolrServer.get_instance().await_ready()
xmlconfig.string(
'<configure xmlns:solr="http://namespaces.plone.org/solr">'
' <solr:connection host="localhost"'
' port="{SOLR_PORT}"'
' base="/solr/{SOLR_CORE}" />'
'</configure>'.format(SOLR_PORT=SOLR_PORT, SOLR_CORE=SOLR_CORE),
context=configurationContext)

# Clear solr from potential artefacts of the previous run.
SolrReplicationAPIClient.get_instance().clear()

def setUpPloneSite(self, portal):
session.current_session = session.BuilderSession()
session.current_session.session = create_session()
Expand All @@ -31,12 +60,19 @@ def setUpPloneSite(self, portal):
portal.portal_languages.use_combined_language_codes = True
portal.portal_languages.addSupportedLanguage('de-ch')

api.portal.set_registry_record('use_solr', True, interface=ISearchSettings)

setRequest(portal.REQUEST)
print 'Installing fixture. Have patience.'
self.get_fixture_class()()
print 'Finished installing fixture.'
setRequest(None)

# Commit before creating the solr backup, since collective.indexing
# flushes on commit.
transaction.commit()
SolrReplicationAPIClient.get_instance().create_backup('fixture')

def setupLanguageTool(self, portal):
lang_tool = api.portal.get_tool('portal_languages')
lang_tool.setDefaultLanguage('de')
Expand Down Expand Up @@ -85,9 +121,11 @@ def testSetUp(self):
self.context_manager = self.isolation()
self.context_manager.__enter__()
transaction.commit()
SolrReplicationAPIClient.get_instance().await_restored()

def testTearDown(self):
self.context_manager.__exit__(None, None, None)
SolrReplicationAPIClient.get_instance().restore_backup('fixture')
super(TestServerFunctionalTesting, self).testTearDown()


Expand Down

0 comments on commit 42696f4

Please sign in to comment.