mirror of
https://github.com/tahoe-lafs/tahoe-lafs.git
synced 2024-12-19 04:57:54 +00:00
505 lines
14 KiB
Python
505 lines
14 KiB
Python
"""
|
|
Classes which directly represent various kinds of Tahoe processes
|
|
that co-operate to for "a Grid".
|
|
|
|
These methods and objects are used by conftest.py fixtures but may
|
|
also be used as direct helpers for tests that don't want to (or can't)
|
|
rely on 'the' global grid as provided by fixtures like 'alice' or
|
|
'storage_servers'.
|
|
"""
|
|
|
|
from os import mkdir, listdir
|
|
from os.path import join, exists
|
|
from tempfile import mktemp
|
|
from time import sleep
|
|
|
|
from eliot import (
|
|
log_call,
|
|
)
|
|
|
|
from foolscap.furl import (
|
|
decode_furl,
|
|
)
|
|
|
|
from twisted.python.procutils import which
|
|
from twisted.internet.defer import (
|
|
inlineCallbacks,
|
|
returnValue,
|
|
maybeDeferred,
|
|
)
|
|
from twisted.internet.task import (
|
|
deferLater,
|
|
)
|
|
from twisted.internet.interfaces import (
|
|
IProcessTransport,
|
|
IProcessProtocol,
|
|
)
|
|
from twisted.internet.endpoints import (
|
|
TCP4ServerEndpoint,
|
|
)
|
|
from twisted.internet.protocol import (
|
|
Factory,
|
|
Protocol,
|
|
)
|
|
from twisted.internet.error import ProcessTerminated
|
|
|
|
from allmydata.node import read_config
|
|
from .util import (
|
|
_CollectOutputProtocol,
|
|
_MagicTextProtocol,
|
|
_DumpOutputProtocol,
|
|
_ProcessExitedProtocol,
|
|
_run_node,
|
|
_cleanup_tahoe_process,
|
|
_tahoe_runner_optional_coverage,
|
|
TahoeProcess,
|
|
await_client_ready,
|
|
)
|
|
|
|
import attr
|
|
import pytest_twisted
|
|
|
|
|
|
# further directions:
|
|
# - "Grid" is unused, basically -- tie into the rest?
|
|
# - could make a Grid instance mandatory for create_* calls
|
|
# - could instead make create_* calls methods of Grid
|
|
# - Bring more 'util' or 'conftest' code into here
|
|
# - stop()/start()/restart() methods on StorageServer etc
|
|
# - more-complex stuff like config changes (which imply a restart too)?
|
|
|
|
|
|
@attr.s
|
|
class FlogGatherer(object):
|
|
"""
|
|
Flog Gatherer process.
|
|
"""
|
|
|
|
process = attr.ib(
|
|
validator=attr.validators.provides(IProcessTransport)
|
|
)
|
|
protocol = attr.ib(
|
|
validator=attr.validators.provides(IProcessProtocol)
|
|
)
|
|
furl = attr.ib()
|
|
|
|
|
|
@inlineCallbacks
|
|
def create_flog_gatherer(reactor, request, temp_dir, flog_binary):
|
|
out_protocol = _CollectOutputProtocol()
|
|
gather_dir = join(temp_dir, 'flog_gather')
|
|
reactor.spawnProcess(
|
|
out_protocol,
|
|
flog_binary,
|
|
(
|
|
'flogtool', 'create-gatherer',
|
|
'--location', 'tcp:localhost:3117',
|
|
'--port', '3117',
|
|
gather_dir,
|
|
)
|
|
)
|
|
yield out_protocol.done
|
|
|
|
twistd_protocol = _MagicTextProtocol("Gatherer waiting at", "gatherer")
|
|
twistd_process = reactor.spawnProcess(
|
|
twistd_protocol,
|
|
which('twistd')[0],
|
|
(
|
|
'twistd', '--nodaemon', '--python',
|
|
join(gather_dir, 'gatherer.tac'),
|
|
),
|
|
path=gather_dir,
|
|
)
|
|
yield twistd_protocol.magic_seen
|
|
|
|
def cleanup():
|
|
_cleanup_tahoe_process(twistd_process, twistd_protocol.exited)
|
|
|
|
flog_file = mktemp('.flog_dump')
|
|
flog_protocol = _DumpOutputProtocol(open(flog_file, 'w'))
|
|
flog_dir = join(temp_dir, 'flog_gather')
|
|
flogs = [x for x in listdir(flog_dir) if x.endswith('.flog')]
|
|
|
|
print("Dumping {} flogtool logfiles to '{}'".format(len(flogs), flog_file))
|
|
reactor.spawnProcess(
|
|
flog_protocol,
|
|
flog_binary,
|
|
(
|
|
'flogtool', 'dump', join(temp_dir, 'flog_gather', flogs[0])
|
|
),
|
|
)
|
|
print("Waiting for flogtool to complete")
|
|
try:
|
|
pytest_twisted.blockon(flog_protocol.done)
|
|
except ProcessTerminated as e:
|
|
print("flogtool exited unexpectedly: {}".format(str(e)))
|
|
print("Flogtool completed")
|
|
|
|
request.addfinalizer(cleanup)
|
|
|
|
with open(join(gather_dir, 'log_gatherer.furl'), 'r') as f:
|
|
furl = f.read().strip()
|
|
returnValue(
|
|
FlogGatherer(
|
|
protocol=twistd_protocol,
|
|
process=twistd_process,
|
|
furl=furl,
|
|
)
|
|
)
|
|
|
|
|
|
@attr.s
|
|
class StorageServer(object):
|
|
"""
|
|
Represents a Tahoe Storage Server
|
|
"""
|
|
|
|
process = attr.ib(
|
|
validator=attr.validators.instance_of(TahoeProcess)
|
|
)
|
|
protocol = attr.ib(
|
|
validator=attr.validators.provides(IProcessProtocol)
|
|
)
|
|
|
|
@inlineCallbacks
|
|
def restart(self, reactor, request):
|
|
"""
|
|
re-start our underlying process by issuing a TERM, waiting and
|
|
then running again. await_client_ready() will be done as well
|
|
|
|
Note that self.process and self.protocol will be new instances
|
|
after this.
|
|
"""
|
|
self.process.transport.signalProcess('TERM')
|
|
yield self.protocol.exited
|
|
self.process = yield _run_node(
|
|
reactor, self.process.node_dir, request, None,
|
|
)
|
|
self.protocol = self.process.transport.proto
|
|
yield await_client_ready(self.process)
|
|
|
|
|
|
@inlineCallbacks
|
|
def create_storage_server(reactor, request, temp_dir, introducer, flog_gatherer, name, web_port,
|
|
needed=2, happy=3, total=4):
|
|
"""
|
|
Create a new storage server
|
|
"""
|
|
from .util import _create_node
|
|
node_process = yield _create_node(
|
|
reactor, request, temp_dir, introducer.furl, flog_gatherer,
|
|
name, web_port, storage=True, needed=needed, happy=happy, total=total,
|
|
)
|
|
storage = StorageServer(
|
|
process=node_process,
|
|
# node_process is a TahoeProcess. its transport is an
|
|
# IProcessTransport. in practice, this means it is a
|
|
# twisted.internet._baseprocess.BaseProcess. BaseProcess records the
|
|
# process protocol as its proto attribute.
|
|
protocol=node_process.transport.proto,
|
|
)
|
|
returnValue(storage)
|
|
|
|
|
|
@attr.s
|
|
class Client(object):
|
|
"""
|
|
Represents a Tahoe client
|
|
"""
|
|
|
|
process = attr.ib(
|
|
validator=attr.validators.instance_of(TahoeProcess)
|
|
)
|
|
protocol = attr.ib(
|
|
validator=attr.validators.provides(IProcessProtocol)
|
|
)
|
|
|
|
@inlineCallbacks
|
|
def restart(self, reactor, request, servers=1):
|
|
"""
|
|
re-start our underlying process by issuing a TERM, waiting and
|
|
then running again.
|
|
|
|
:param int servers: number of server connections we will wait
|
|
for before being 'ready'
|
|
|
|
Note that self.process and self.protocol will be new instances
|
|
after this.
|
|
"""
|
|
self.process.transport.signalProcess('TERM')
|
|
yield self.protocol.exited
|
|
process = yield _run_node(
|
|
reactor, self.process.node_dir, request, None,
|
|
)
|
|
self.process = process
|
|
self.protocol = self.process.transport.proto
|
|
yield await_client_ready(self.process, minimum_number_of_servers=servers)
|
|
|
|
# XXX add stop / start ?
|
|
# ...maybe "reconfig" of some kind?
|
|
|
|
|
|
@inlineCallbacks
|
|
def create_client(reactor, request, temp_dir, introducer, flog_gatherer, name, web_port,
|
|
needed=2, happy=3, total=4):
|
|
"""
|
|
Create a new storage server
|
|
"""
|
|
from .util import _create_node
|
|
node_process = yield _create_node(
|
|
reactor, request, temp_dir, introducer.furl, flog_gatherer,
|
|
name, web_port, storage=False, needed=needed, happy=happy, total=total,
|
|
)
|
|
returnValue(
|
|
Client(
|
|
process=node_process,
|
|
protocol=node_process.transport.proto,
|
|
)
|
|
)
|
|
|
|
|
|
@attr.s
|
|
class Introducer(object):
|
|
"""
|
|
Reprsents a running introducer
|
|
"""
|
|
|
|
process = attr.ib(
|
|
validator=attr.validators.instance_of(TahoeProcess)
|
|
)
|
|
protocol = attr.ib(
|
|
validator=attr.validators.provides(IProcessProtocol)
|
|
)
|
|
furl = attr.ib()
|
|
|
|
|
|
def _validate_furl(furl_fname):
|
|
"""
|
|
Opens and validates a fURL, ensuring location hints.
|
|
:returns: the furl
|
|
:raises: ValueError if no location hints
|
|
"""
|
|
while not exists(furl_fname):
|
|
print("Don't see {} yet".format(furl_fname))
|
|
sleep(.1)
|
|
furl = open(furl_fname, 'r').read()
|
|
tubID, location_hints, name = decode_furl(furl)
|
|
if not location_hints:
|
|
# If there are no location hints then nothing can ever possibly
|
|
# connect to it and the only thing that can happen next is something
|
|
# will hang or time out. So just give up right now.
|
|
raise ValueError(
|
|
"Introducer ({!r}) fURL has no location hints!".format(
|
|
furl,
|
|
),
|
|
)
|
|
return furl
|
|
|
|
|
|
@inlineCallbacks
|
|
@log_call(
|
|
action_type=u"integration:introducer",
|
|
include_args=["temp_dir", "flog_gatherer"],
|
|
include_result=False,
|
|
)
|
|
def create_introducer(reactor, request, temp_dir, flog_gatherer, port):
|
|
"""
|
|
Run a new Introducer and return an Introducer instance.
|
|
"""
|
|
intro_dir = join(temp_dir, 'introducer{}'.format(port))
|
|
|
|
if not exists(intro_dir):
|
|
mkdir(intro_dir)
|
|
done_proto = _ProcessExitedProtocol()
|
|
_tahoe_runner_optional_coverage(
|
|
done_proto,
|
|
reactor,
|
|
request,
|
|
(
|
|
'create-introducer',
|
|
'--listen=tcp',
|
|
'--hostname=localhost',
|
|
intro_dir,
|
|
),
|
|
)
|
|
yield done_proto.done
|
|
|
|
config = read_config(intro_dir, "tub.port")
|
|
config.set_config("node", "nickname", f"introducer-{port}")
|
|
config.set_config("node", "web.port", f"{port}")
|
|
config.set_config("node", "log_gatherer.furl", flog_gatherer.furl)
|
|
|
|
# on windows, "tahoe start" means: run forever in the foreground,
|
|
# but on linux it means daemonize. "tahoe run" is consistent
|
|
# between platforms.
|
|
protocol = _MagicTextProtocol('introducer running', "introducer")
|
|
transport = _tahoe_runner_optional_coverage(
|
|
protocol,
|
|
reactor,
|
|
request,
|
|
(
|
|
'run',
|
|
intro_dir,
|
|
),
|
|
)
|
|
|
|
def clean():
|
|
return _cleanup_tahoe_process(transport, protocol.exited)
|
|
request.addfinalizer(clean)
|
|
|
|
yield protocol.magic_seen
|
|
|
|
furl_fname = join(intro_dir, 'private', 'introducer.furl')
|
|
while not exists(furl_fname):
|
|
print("Don't see {} yet".format(furl_fname))
|
|
yield deferLater(reactor, .1, lambda: None)
|
|
furl = _validate_furl(furl_fname)
|
|
|
|
returnValue(
|
|
Introducer(
|
|
process=TahoeProcess(transport, intro_dir),
|
|
protocol=protocol,
|
|
furl=furl,
|
|
)
|
|
)
|
|
|
|
|
|
@attr.s
|
|
class Grid(object):
|
|
"""
|
|
Represents an entire Tahoe Grid setup
|
|
|
|
A Grid includes an Introducer, Flog Gatherer and some number of
|
|
Storage Servers.
|
|
"""
|
|
|
|
_reactor = attr.ib()
|
|
_request = attr.ib()
|
|
_temp_dir = attr.ib()
|
|
_port_allocator = attr.ib()
|
|
introducer = attr.ib()
|
|
flog_gatherer = attr.ib()
|
|
storage_servers = attr.ib(factory=list)
|
|
clients = attr.ib(factory=dict)
|
|
|
|
@storage_servers.validator
|
|
def check(self, attribute, value):
|
|
for server in value:
|
|
if not isinstance(server, StorageServer):
|
|
raise ValueError(
|
|
"storage_servers must be StorageServer"
|
|
)
|
|
|
|
@inlineCallbacks
|
|
def add_storage_node(self):
|
|
"""
|
|
Creates a new storage node, returns a StorageServer instance
|
|
(which will already be added to our .storage_servers list)
|
|
"""
|
|
port = yield self._port_allocator()
|
|
print("make {}".format(port))
|
|
name = 'node{}'.format(port)
|
|
web_port = 'tcp:{}:interface=localhost'.format(port)
|
|
server = yield create_storage_server(
|
|
self._reactor,
|
|
self._request,
|
|
self._temp_dir,
|
|
self.introducer,
|
|
self.flog_gatherer,
|
|
name,
|
|
web_port,
|
|
)
|
|
self.storage_servers.append(server)
|
|
returnValue(server)
|
|
|
|
@inlineCallbacks
|
|
def add_client(self, name, needed=2, happy=3, total=4):
|
|
"""
|
|
Create a new client node
|
|
"""
|
|
port = yield self._port_allocator()
|
|
web_port = 'tcp:{}:interface=localhost'.format(port)
|
|
client = yield create_client(
|
|
self._reactor,
|
|
self._request,
|
|
self._temp_dir,
|
|
self.introducer,
|
|
self.flog_gatherer,
|
|
name,
|
|
web_port,
|
|
needed=needed,
|
|
happy=happy,
|
|
total=total,
|
|
)
|
|
self.clients[name] = client
|
|
yield await_client_ready(client.process)
|
|
returnValue(client)
|
|
|
|
|
|
|
|
# XXX THINK can we tie a whole *grid* to a single request? (I think
|
|
# that's all that makes sense)
|
|
@inlineCallbacks
|
|
def create_grid(reactor, request, temp_dir, flog_gatherer, port_allocator):
|
|
"""
|
|
"""
|
|
intro_port = yield port_allocator()
|
|
introducer = yield create_introducer(reactor, request, temp_dir, flog_gatherer, intro_port)
|
|
grid = Grid(
|
|
reactor,
|
|
request,
|
|
temp_dir,
|
|
port_allocator,
|
|
introducer,
|
|
flog_gatherer,
|
|
)
|
|
returnValue(grid)
|
|
|
|
|
|
def create_port_allocator(start_port):
|
|
"""
|
|
Returns a new port-allocator .. which is a zero-argument function
|
|
that returns Deferreds that fire with new, sequential ports
|
|
starting at `start_port` skipping any that already appear to have
|
|
a listener.
|
|
|
|
There can still be a race against other processes allocating ports
|
|
-- between the time when we check the status of the port and when
|
|
our subprocess starts up. This *could* be mitigated by instructing
|
|
the OS to not randomly-allocate ports in some range, and then
|
|
using that range here (explicitly, ourselves).
|
|
|
|
NB once we're Python3-only this could be an async-generator
|
|
"""
|
|
port = [start_port - 1]
|
|
|
|
# import stays here to not interfere with reactor selection -- but
|
|
# maybe this function should be arranged to be called once from a
|
|
# fixture (with the reactor)?
|
|
from twisted.internet import reactor
|
|
|
|
class NothingProtocol(Protocol):
|
|
"""
|
|
I do nothing.
|
|
"""
|
|
|
|
def port_generator():
|
|
print("Checking port {}".format(port))
|
|
port[0] += 1
|
|
ep = TCP4ServerEndpoint(reactor, port[0], interface="localhost")
|
|
d = ep.listen(Factory.forProtocol(NothingProtocol))
|
|
|
|
def good(listening_port):
|
|
unlisten_d = maybeDeferred(listening_port.stopListening)
|
|
def return_port(_):
|
|
return port[0]
|
|
unlisten_d.addBoth(return_port)
|
|
return unlisten_d
|
|
|
|
def try_again(fail):
|
|
return port_generator()
|
|
|
|
d.addCallbacks(good, try_again)
|
|
return d
|
|
return port_generator
|