From e10a032fc37e7ebd46c3b04ae251e345beb6b73c Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 11 Jun 2019 11:51:23 -0400 Subject: [PATCH 01/96] Add the basic plugin interfaces and some documentation --- src/allmydata/interfaces.py | 125 ++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/src/allmydata/interfaces.py b/src/allmydata/interfaces.py index dee197ca2..48639f861 100644 --- a/src/allmydata/interfaces.py +++ b/src/allmydata/interfaces.py @@ -1,5 +1,8 @@ from zope.interface import Interface, Attribute +from twisted.plugin import ( + IPlugin, +) from foolscap.api import StringConstraint, ListOf, TupleOf, SetOf, DictOf, \ ChoiceOf, IntegerConstraint, Any, RemoteInterface, Referenceable @@ -3023,3 +3026,125 @@ class IConnectionStatus(Interface): connection hint and the handler it is using) to the status string (pending, connected, refused, or other errors). """) + + + +class IFoolscapStoragePlugin(IPlugin): + """ + An ``IStoragePlugin`` provides client- and server-side implementations of + a Foolscap-based protocol which can be used to store and retrieve data. + + Implementations are free to apply access control or authorization policies + to this storage service and doing so is a large part of the motivation for + providing this point of pluggability. + + There should be enough information and hook points to support at + least these use-cases: + + - anonymous, everything allowed (current default) + - "storage club" / "friend-net" (possibly identity based) + - cryptocurrencies (ideally, paying for each API call) + - anonymous tokens (payment for service, but without identities) + """ + human_name = Attribute( + """ + How this plugin shall be referred to (e.g. in Storage Server + announcements, to users, etc). This is intended as a mostly unique, + human-facing identifier for the plugin. + + :type: ``unicode`` + """ + ) + + id = Attribute( + """ + + A globally unique identifier for this specific storage plugin. + Identifiers are represented as URLs. The domain portion of the URL + provides a self-organization hierarchy for avoid collisions between + plugins maintained by different agencies. A recommended structure for + such URLs is:: + + https://example.org/tahoe-lafs/storage/foobar/v1 + + :type: ``hyperlink.URL`` + """ + ) + + def get_storage_server(configuration, get_anonymous_storage_server): + """ + Get an ``IAnnounceableStorageServer`` provider that gives an announcement + for and an implementation of the server side of the storage protocol. + This will be exposed and offered to clients in the storage server's + announcement. + + :param dict configuration: Any configuration given in the section for + this plugin in the node's configuration file. As an example, the + configuration for the original anonymous-access filesystem-based + storage server might look like:: + + {u"storedir": u"/foo/bar/storage", + u"nodeid": u"abcdefg...", + u"reserved_space": 0, + u"discard_storage": False, + u"readonly_storage": False, + u"expiration_enabled": False, + u"expiration_mode": u"age", + u"expiration_override_lease_duration": None, + u"expiration_cutoff_date": None, + u"expiration_sharetypes": ("mutable, "immutable"), + } + + :param get_anonymous_storage_server: A no-argument callable which + returns a single instance of the original, anonymous-access + storage server. This may be helpful in providing actual storage + implementation behavior for a wrapper-style plugin. This is also + provided to keep the Python API offered by Tahoe-LAFS to plugin + developers narrow (do not try to find and instantiate the original + storage server yourself; if you want it, call this). + + :rtype: ``Deferred`` firing with ``IAnnounceableStorageServer`` + """ + + def get_storage_client(configuration, announcement): + """ + Get an ``IStorageServer`` provider that implements the client side of the + storage protocol. + + :param dict configuration: Any configuration given in the section for + this plugin in the node's configuration file. + + :param dict announcement: The announcement for the corresponding + server portion of this plugin received from a storage server which + is offering it. + + :rtype: ``Deferred`` firing with ``IStorageServer`` + """ + + +class IAnnounceableStorageServer(Interface): + announcement = Attribute( + """ + Data for an announcement for the associated storage server. + + :note: This does not include the storage server nickname nor Foolscap + fURL. These will be added to the announcement automatically. It + may be usual for this announcement to contain no information. + Once the client connects to this server it can use other methods + to query for additional information (eg, in the manner of + ``RIStorageServer.remote_get_version``). The announcement only + needs to contain information to help the client determine how to + connect. + + :type: ``dict`` of JSON-serializable types + """ + ) + + storage_server = Attribute( + """ + A Foolscap referenceable object implementing the server side of the + storage protocol. + + :type: ``IReferenceable`` provider + """ + ) From 8a22764fb1d48f04adcd770ec7b3948bfdf37d77 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 11 Jun 2019 16:26:19 -0400 Subject: [PATCH 02/96] Combine human_name and id --- src/allmydata/interfaces.py | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/allmydata/interfaces.py b/src/allmydata/interfaces.py index 48639f861..704c7df4d 100644 --- a/src/allmydata/interfaces.py +++ b/src/allmydata/interfaces.py @@ -3046,31 +3046,22 @@ class IFoolscapStoragePlugin(IPlugin): - cryptocurrencies (ideally, paying for each API call) - anonymous tokens (payment for service, but without identities) """ - human_name = Attribute( + name = Attribute( """ - How this plugin shall be referred to (e.g. in Storage Server - announcements, to users, etc). This is intended as a mostly unique, - human-facing identifier for the plugin. + A name for referring to this plugin. This name is both user-facing + (for example, it is written in configuration files) and machine-facing + (for example, it may be used to construct URLs). It should be unique + across all plugins for this interface. Two plugins with the same name + cannot be used in one client. + + Because it is used to construct URLs, it is constrained to URL safe + characters (it must be a *segment* as defined by RFC 3986, section + 3.3). :type: ``unicode`` """ ) - id = Attribute( - """ - - A globally unique identifier for this specific storage plugin. - Identifiers are represented as URLs. The domain portion of the URL - provides a self-organization hierarchy for avoid collisions between - plugins maintained by different agencies. A recommended structure for - such URLs is:: - - https://example.org/tahoe-lafs/storage/foobar/v1 - - :type: ``hyperlink.URL`` - """ - ) - def get_storage_server(configuration, get_anonymous_storage_server): """ Get an ``IAnnounceableStorageServer`` provider that gives an announcement From 2c49c97fcdc31a0555c53b7d94f421f91222cc40 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 11 Jun 2019 16:27:05 -0400 Subject: [PATCH 03/96] more unicode literals --- src/allmydata/interfaces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/allmydata/interfaces.py b/src/allmydata/interfaces.py index 704c7df4d..a1e465018 100644 --- a/src/allmydata/interfaces.py +++ b/src/allmydata/interfaces.py @@ -3083,7 +3083,7 @@ class IFoolscapStoragePlugin(IPlugin): u"expiration_mode": u"age", u"expiration_override_lease_duration": None, u"expiration_cutoff_date": None, - u"expiration_sharetypes": ("mutable, "immutable"), + u"expiration_sharetypes": (u"mutable, u"immutable"), } :param get_anonymous_storage_server: A no-argument callable which From de1b488f6478db3cb94bc72bc2f5d4e1c2709b91 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 12 Jun 2019 16:03:26 -0400 Subject: [PATCH 04/96] news fragment --- newsfragments/3049.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/3049.feature diff --git a/newsfragments/3049.feature b/newsfragments/3049.feature new file mode 100644 index 000000000..67a4068aa --- /dev/null +++ b/newsfragments/3049.feature @@ -0,0 +1 @@ +allmydata.interfaces.IFoolscapStoragePlugin has been introduced, an extension point for customizing the storage protocol. From 4216bd6ed1200e2097a96842584a6583dde11742 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 17 Jun 2019 16:38:44 -0400 Subject: [PATCH 05/96] news fragment --- newsfragments/3086.minor | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 newsfragments/3086.minor diff --git a/newsfragments/3086.minor b/newsfragments/3086.minor new file mode 100644 index 000000000..e69de29bb From fb4c5cf91f19b033842a3398d75715f889ce1152 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 17 Jun 2019 16:44:17 -0400 Subject: [PATCH 06/96] Allow for dynamic configuration validation rules --- src/allmydata/client.py | 16 +++-- src/allmydata/node.py | 19 +++--- src/allmydata/test/test_configutil.py | 96 +++++++++++++++++++++++++-- src/allmydata/util/configutil.py | 70 +++++++++++++++++-- 4 files changed, 172 insertions(+), 29 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 360aa568f..78dfd50bf 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -18,7 +18,10 @@ from allmydata.immutable.upload import Uploader from allmydata.immutable.offloaded import Helper from allmydata.control import ControlServer from allmydata.introducer.client import IntroducerClient -from allmydata.util import (hashutil, base32, pollmixin, log, idlib, yamlutil) +from allmydata.util import ( + hashutil, base32, pollmixin, log, idlib, + yamlutil, configutil, +) from allmydata.util.encodingutil import (get_filesystem_encoding, from_utf8_or_none) from allmydata.util.abbreviate import parse_abbreviated_size @@ -39,9 +42,9 @@ GiB=1024*MiB TiB=1024*GiB PiB=1024*TiB -def _valid_config_sections(): - cfg = node._common_config_sections() - cfg.update({ +def _valid_config(): + cfg = node._common_valid_config() + return cfg.update(configutil.ValidConfiguration({ "client": ( "helper.furl", "introducer.furl", @@ -93,8 +96,7 @@ def _valid_config_sections(): "local.directory", "poll_interval", ), - }) - return cfg + })) # this is put into README in new node-directories CLIENT_README = """ @@ -180,7 +182,7 @@ def read_config(basedir, portnumfile, generated_files=[]): return node.read_config( basedir, portnumfile, generated_files=generated_files, - _valid_config_sections=_valid_config_sections, + _valid_config=_valid_config(), ) diff --git a/src/allmydata/node.py b/src/allmydata/node.py index 426a0f796..757e650b7 100644 --- a/src/allmydata/node.py +++ b/src/allmydata/node.py @@ -25,8 +25,8 @@ from allmydata.util.fileutil import abspath_expanduser_unicode from allmydata.util.encodingutil import get_filesystem_encoding, quote_output from allmydata.util import configutil -def _common_config_sections(): - return { +def _common_valid_config(): + return configutil.ValidConfiguration({ "connections": ( "tcp", ), @@ -63,7 +63,7 @@ def _common_config_sections(): "onion.external_port", "onion.private_key_file", ), - } + }) # Add our application versions to the data that Foolscap's LogPublisher # reports. @@ -152,7 +152,7 @@ def create_node_dir(basedir, readme_text): f.write(readme_text) -def read_config(basedir, portnumfile, generated_files=[], _valid_config_sections=None): +def read_config(basedir, portnumfile, generated_files=[], _valid_config=None): """ Read and validate configuration. @@ -163,15 +163,14 @@ def read_config(basedir, portnumfile, generated_files=[], _valid_config_sections :param list generated_files: a list of automatically-generated configuration files. - :param dict _valid_config_sections: (internal use, optional) a - dict-of-dicts structure defining valid configuration sections and - keys + :param ValidConfiguration _valid_config: (internal use, optional) a + structure defining valid configuration sections and keys :returns: :class:`allmydata.node._Config` instance """ basedir = abspath_expanduser_unicode(unicode(basedir)) - if _valid_config_sections is None: - _valid_config_sections = _common_config_sections + if _valid_config is None: + _valid_config = _common_valid_config() # complain if there's bad stuff in the config dir _error_about_old_config_files(basedir, generated_files) @@ -188,7 +187,7 @@ def read_config(basedir, portnumfile, generated_files=[], _valid_config_sections if e.errno != errno.ENOENT: raise - configutil.validate_config(config_fname, parser, _valid_config_sections()) + configutil.validate_config(config_fname, parser, _valid_config) # make sure we have a private configuration area fileutil.make_dirs(os.path.join(basedir, "private"), 0o700) diff --git a/src/allmydata/test/test_configutil.py b/src/allmydata/test/test_configutil.py index 5786be8e0..45eb6ac25 100644 --- a/src/allmydata/test/test_configutil.py +++ b/src/allmydata/test/test_configutil.py @@ -9,6 +9,16 @@ from .. import client class ConfigUtilTests(GridTestMixin, unittest.TestCase): + def setUp(self): + super(ConfigUtilTests, self).setUp() + self.static_valid_config = configutil.ValidConfiguration( + dict(node=['valid']), + ) + self.dynamic_valid_config = configutil.ValidConfiguration( + dict(), + lambda section_name: section_name == "node", + lambda section_name, item_name: (section_name, item_name) == ("node", "valid"), + ) def test_config_utils(self): self.basedir = "cli/ConfigUtilTests/test-config-utils" @@ -44,7 +54,32 @@ class ConfigUtilTests(GridTestMixin, unittest.TestCase): config = configutil.get_config(fname) # should succeed, no exceptions - configutil.validate_config(fname, config, dict(node=['valid'])) + configutil.validate_config( + fname, + config, + self.static_valid_config, + ) + + def test_config_dynamic_validation_success(self): + """ + A configuration with sections and items that are not matched by the static + validation but are matched by the dynamic validation is considered + valid. + """ + d = self.mktemp() + os.mkdir(d) + fname = os.path.join(d, 'tahoe.cfg') + + with open(fname, 'w') as f: + f.write('[node]\nvalid = foo\n') + + config = configutil.get_config(fname) + # should succeed, no exceptions + configutil.validate_config( + fname, + config, + self.dynamic_valid_config, + ) def test_config_validation_invalid_item(self): d = self.mktemp() @@ -58,11 +93,16 @@ class ConfigUtilTests(GridTestMixin, unittest.TestCase): e = self.assertRaises( configutil.UnknownConfigError, configutil.validate_config, - fname, config, dict(node=['valid']), + fname, config, + self.static_valid_config, ) self.assertIn("section [node] contains unknown option 'invalid'", str(e)) def test_config_validation_invalid_section(self): + """ + A configuration with a section that is matched by neither the static nor + dynamic validators is rejected. + """ d = self.mktemp() os.mkdir(d) fname = os.path.join(d, 'tahoe.cfg') @@ -74,10 +114,53 @@ class ConfigUtilTests(GridTestMixin, unittest.TestCase): e = self.assertRaises( configutil.UnknownConfigError, configutil.validate_config, - fname, config, dict(node=['valid']), + fname, config, + self.static_valid_config, ) self.assertIn("contains unknown section [invalid]", str(e)) + def test_config_dynamic_validation_invalid_section(self): + """ + A configuration with a section that is matched by neither the static nor + dynamic validators is rejected. + """ + d = self.mktemp() + os.mkdir(d) + fname = os.path.join(d, 'tahoe.cfg') + + with open(fname, 'w') as f: + f.write('[node]\nvalid = foo\n[invalid]\n') + + config = configutil.get_config(fname) + e = self.assertRaises( + configutil.UnknownConfigError, + configutil.validate_config, + fname, config, + self.dynamic_valid_config, + ) + self.assertIn("contains unknown section [invalid]", str(e)) + + def test_config_dynamic_validation_invalid_item(self): + """ + A configuration with a section, item pair that is matched by neither the + static nor dynamic validators is rejected. + """ + d = self.mktemp() + os.mkdir(d) + fname = os.path.join(d, 'tahoe.cfg') + + with open(fname, 'w') as f: + f.write('[node]\nvalid = foo\ninvalid = foo\n') + + config = configutil.get_config(fname) + e = self.assertRaises( + configutil.UnknownConfigError, + configutil.validate_config, + fname, config, + self.dynamic_valid_config, + ) + self.assertIn("section [node] contains unknown option 'invalid'", str(e)) + def test_create_client_config(self): d = self.mktemp() os.mkdir(d) @@ -97,5 +180,8 @@ class ConfigUtilTests(GridTestMixin, unittest.TestCase): config = configutil.get_config(fname) # should succeed, no exceptions - configutil.validate_config(fname, config, - client._valid_config_sections()) + configutil.validate_config( + fname, + config, + client._valid_config(), + ) diff --git a/src/allmydata/util/configutil.py b/src/allmydata/util/configutil.py index 78894e301..d58bc4217 100644 --- a/src/allmydata/util/configutil.py +++ b/src/allmydata/util/configutil.py @@ -1,6 +1,7 @@ from ConfigParser import SafeConfigParser +import attr class UnknownConfigError(Exception): """ @@ -36,15 +37,16 @@ def write_config(tahoe_cfg, config): finally: f.close() -def validate_config(fname, cfg, valid_sections): +def validate_config(fname, cfg, valid_config): """ - raises UnknownConfigError if there are any unknown sections or config - values. + :param ValidConfiguration valid_config: The definition of a valid + configuration. + + :raises UnknownConfigError: if there are any unknown sections or config + values. """ for section in cfg.sections(): - try: - valid_in_section = valid_sections[section] - except KeyError: + if not valid_config.is_valid_section(section): raise UnknownConfigError( "'{fname}' contains unknown section [{section}]".format( fname=fname, @@ -52,7 +54,7 @@ def validate_config(fname, cfg, valid_sections): ) ) for option in cfg.options(section): - if option not in valid_in_section: + if not valid_config.is_valid_item(section, option): raise UnknownConfigError( "'{fname}' section [{section}] contains unknown option '{option}'".format( fname=fname, @@ -60,3 +62,57 @@ def validate_config(fname, cfg, valid_sections): option=option, ) ) + + +@attr.s +class ValidConfiguration(object): + """ + :ivar dict[bytes, tuple[bytes]] _static_valid_sections: A mapping from + valid section names to valid items in those sections. + + :ivar _is_valid_section: A callable which accepts a section name as bytes + and returns True if that section name is valid, False otherwise. + + :ivar _is_valid_item: A callable which accepts a section name as bytes and + an item name as bytes and returns True if that section, item pair is + valid, False otherwise. + """ + _static_valid_sections = attr.ib() + _is_valid_section = attr.ib(default=lambda section_name: False) + _is_valid_item = attr.ib(default=lambda section_name, item_name: False) + + def is_valid_section(self, section_name): + """ + :return: True if the given section name is valid, False otherwise. + """ + return ( + section_name in self._static_valid_sections or + self._is_valid_section(section_name) + ) + + def is_valid_item(self, section_name, item_name): + """ + :return: True if the given section name, ite name pair is valid, False + otherwise. + """ + return ( + item_name in self._static_valid_sections.get(section_name, ()) or + self._is_valid_item(section_name, item_name) + ) + + + def update(self, valid_config): + static_valid_sections = self._static_valid_sections.copy() + static_valid_sections.update(valid_config._static_valid_sections) + return ValidConfiguration( + static_valid_sections, + _either(self._is_valid_section, valid_config._is_valid_section), + _either(self._is_valid_item, valid_config._is_valid_item), + ) + + +def _either(f, g): + """ + :return: A function which returns True if either f or g returns True. + """ + return lambda *a, **kw: f(*a, **kw) or g(*a, **kw) From 7e17ffb75d68e58b6dbb020b6ae33f59f5ce9eda Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 18 Jun 2019 08:42:46 -0400 Subject: [PATCH 07/96] Also update the introducer's use of read_config --- src/allmydata/introducer/server.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/allmydata/introducer/server.py b/src/allmydata/introducer/server.py index c88f8e7a5..0a933bd01 100644 --- a/src/allmydata/introducer/server.py +++ b/src/allmydata/introducer/server.py @@ -30,10 +30,7 @@ are set to disallow users other than its owner from reading the contents of the files. See the 'configuration.rst' documentation file for details. """ - -def _valid_config_sections(): - return node._common_config_sections() - +_valid_config = node._common_valid_config class FurlFileConflictError(Exception): pass @@ -52,7 +49,7 @@ def create_introducer(basedir=u"."): config = read_config( basedir, u"client.port", generated_files=["introducer.furl"], - _valid_config_sections=_valid_config_sections, + _valid_config=_valid_config(), ) i2p_provider = create_i2p_provider(reactor, config) From b737c6f5c53b33eba3143b25d8249c027e79b950 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 18 Jun 2019 08:46:53 -0400 Subject: [PATCH 08/96] Use the client config helper to read/test client config --- src/allmydata/test/test_client.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index 3d9ed479e..2fd169b48 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -124,10 +124,9 @@ class Basic(testutil.ReallyEqualMixin, testutil.NonASCIIPathMixin, unittest.Test try: e = self.assertRaises( EnvironmentError, - read_config, + client.read_config, basedir, "client.port", - _valid_config_sections=client._valid_config_sections, ) self.assertIn("Permission denied", str(e)) finally: @@ -152,10 +151,9 @@ class Basic(testutil.ReallyEqualMixin, testutil.NonASCIIPathMixin, unittest.Test e = self.failUnlessRaises( OldConfigError, - read_config, + client.read_config, basedir, "client.port", - _valid_config_sections=client._valid_config_sections, ) abs_basedir = fileutil.abspath_expanduser_unicode(unicode(basedir)).encode(sys.getfilesystemencoding()) self.failUnlessIn(os.path.join(abs_basedir, "introducer.furl"), e.args[0]) From f19b94a43da2ae0e9c9b89475802c28b2db4f1cf Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 18 Jun 2019 08:59:00 -0400 Subject: [PATCH 09/96] remove unused import --- src/allmydata/test/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index 2fd169b48..0f864483f 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -30,7 +30,7 @@ import allmydata import allmydata.frontends.magic_folder import allmydata.util.log -from allmydata.node import OldConfigError, OldConfigOptionError, UnescapedHashError, _Config, read_config, create_node_dir +from allmydata.node import OldConfigError, OldConfigOptionError, UnescapedHashError, _Config, create_node_dir from allmydata.node import config_from_string from allmydata.frontends.auth import NeedRootcapLookupScheme from allmydata import client From 8060be556eb7a60f13bef494d743a7ddab19d0ef Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 19 Jun 2019 14:14:21 -0400 Subject: [PATCH 10/96] news fragment --- newsfragments/3097.minor | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 newsfragments/3097.minor diff --git a/newsfragments/3097.minor b/newsfragments/3097.minor new file mode 100644 index 000000000..e69de29bb From 1c6433b43bd7ef168823c8ce728ba3fcc492168e Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 19 Jun 2019 14:19:37 -0400 Subject: [PATCH 11/96] Factor details of the storage announcement out of NativeStorageClient A separate object can be responsible for the details of each kind of announcement. --- src/allmydata/storage_client.py | 185 ++++++++++++++++------ src/allmydata/test/test_storage_client.py | 68 +++++++- 2 files changed, 206 insertions(+), 47 deletions(-) diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index ba9a02191..2b2acee96 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -38,6 +38,9 @@ from eliot import ( log_call, ) from foolscap.api import eventually +from foolscap.reconnector import ( + ReconnectionInfo, +) from allmydata.interfaces import ( IStorageBroker, IDisplayableServer, @@ -257,7 +260,7 @@ class StorageFarmBroker(service.MultiService): # tubids. This clause maps the old tubids to our existing servers. for s in self.servers.values(): if isinstance(s, NativeStorageServer): - if serverid == s._tubid: + if serverid == s.get_tubid(): return s return StubServer(serverid) @@ -274,6 +277,116 @@ class StubServer(object): def get_nickname(self): return "?" +class _AnonymousStorage(object): + """ + Abstraction for connecting to an anonymous storage server. + """ + @classmethod + def from_announcement(cls, server_id, ann): + """ + Create an instance from an announcement like:: + + {"anonymous-storage-FURL": "pb://...@...", + "permutation-seed-base32": "...", + "nickname": "...", + } + + *nickname* is optional. + """ + self = cls() + self._furl = str(ann["anonymous-storage-FURL"]) + m = re.match(r'pb://(\w+)@', self._furl) + assert m, self._furl + tubid_s = m.group(1).lower() + self._tubid = base32.a2b(tubid_s) + if "permutation-seed-base32" in ann: + ps = base32.a2b(str(ann["permutation-seed-base32"])) + elif re.search(r'^v0-[0-9a-zA-Z]{52}$', server_id): + ps = base32.a2b(server_id[3:]) + else: + log.msg("unable to parse serverid '%(server_id)s as pubkey, " + "hashing it to get permutation-seed, " + "may not converge with other clients", + server_id=server_id, + facility="tahoe.storage_broker", + level=log.UNUSUAL, umid="qu86tw") + ps = hashlib.sha256(server_id).digest() + self._permutation_seed = ps + + assert server_id + self._long_description = server_id + if server_id.startswith("v0-"): + # remove v0- prefix from abbreviated name + self._short_description = server_id[3:3+8] + else: + self._short_description = server_id[:8] + self._nickname = ann.get("nickname", "") + return self + + def get_permutation_seed(self): + return self._permutation_seed + + def get_name(self): + return self._short_description + + def get_longname(self): + return self._long_description + + def get_lease_seed(self): + return self._tubid + + def get_tubid(self): + return self._tubid + + def get_nickname(self): + return self._nickname + + def connect_to(self, tub, got_connection): + return tub.connectTo(self._furl, got_connection) + + +class _NullStorage(object): + """ + Abstraction for *not* communicating with a storage server of a type with + which we can't communicate. + """ + def get_permutation_seed(self): + return hashlib.sha256("").digest() + + def get_name(self): + return "" + + def get_longname(self): + return "" + + def get_lease_seed(self): + return hashlib.sha256("").digest() + + def get_tubid(self): + return hashlib.sha256("").digest() + + def get_nickname(self): + return "" + + def connect_to(self, tub, got_connection): + return NonReconnector() + + +class NonReconnector(object): + """ + A ``foolscap.reconnector.Reconnector``-alike that doesn't do anything. + """ + def stopConnecting(self): + pass + + def reset(self): + pass + + def getReconnectionInfo(self): + return ReconnectionInfo() + +_null_storage = _NullStorage() + @implementer(IServer) class NativeStorageServer(service.MultiService): @@ -311,33 +424,7 @@ class NativeStorageServer(service.MultiService): self._tub_maker = tub_maker self._handler_overrides = handler_overrides - assert "anonymous-storage-FURL" in ann, ann - furl = str(ann["anonymous-storage-FURL"]) - m = re.match(r'pb://(\w+)@', furl) - assert m, furl - tubid_s = m.group(1).lower() - self._tubid = base32.a2b(tubid_s) - if "permutation-seed-base32" in ann: - ps = base32.a2b(str(ann["permutation-seed-base32"])) - elif re.search(r'^v0-[0-9a-zA-Z]{52}$', server_id): - ps = base32.a2b(server_id[3:]) - else: - log.msg("unable to parse serverid '%(server_id)s as pubkey, " - "hashing it to get permutation-seed, " - "may not converge with other clients", - server_id=server_id, - facility="tahoe.storage_broker", - level=log.UNUSUAL, umid="qu86tw") - ps = hashlib.sha256(server_id).digest() - self._permutation_seed = ps - - assert server_id - self._long_description = server_id - if server_id.startswith("v0-"): - # remove v0- prefix from abbreviated name - self._short_description = server_id[3:3+8] - else: - self._short_description = server_id[:8] + self._init_from_announcement(ann) self.last_connect_time = None self.last_loss_time = None @@ -348,6 +435,29 @@ class NativeStorageServer(service.MultiService): self._trigger_cb = None self._on_status_changed = ObserverList() + def _init_from_announcement(self, ann): + storage = _null_storage + if "anonymous-storage-FURL" in ann: + storage = _AnonymousStorage.from_announcement(self._server_id, ann) + self._storage = storage + + def get_permutation_seed(self): + return self._storage.get_permutation_seed() + def get_name(self): # keep methodname short + # TODO: decide who adds [] in the short description. It should + # probably be the output side, not here. + return self._storage.get_name() + def get_longname(self): + return self._storage.get_longname() + def get_tubid(self): + return self._storage.get_tubid() + def get_lease_seed(self): + return self._storage.get_tubid() + def get_foolscap_write_enabler_seed(self): + return self._storage.get_tubid() + def get_nickname(self): + return self._storage.get_nickname() + def on_status_changed(self, status_changed): """ :param status_changed: a callable taking a single arg (the @@ -368,25 +478,10 @@ class NativeStorageServer(service.MultiService): return "" % self.get_name() def get_serverid(self): return self._server_id - def get_permutation_seed(self): - return self._permutation_seed def get_version(self): if self._rref: return self._rref.version return None - def get_name(self): # keep methodname short - # TODO: decide who adds [] in the short description. It should - # probably be the output side, not here. - return self._short_description - def get_longname(self): - return self._long_description - def get_lease_seed(self): - return self._tubid - def get_foolscap_write_enabler_seed(self): - return self._tubid - - def get_nickname(self): - return self.announcement.get("nickname", "") def get_announcement(self): return self.announcement def get_remote_host(self): @@ -412,13 +507,11 @@ class NativeStorageServer(service.MultiService): available_space = protocol_v1_version.get('maximum-immutable-share-size', None) return available_space - def start_connecting(self, trigger_cb): self._tub = self._tub_maker(self._handler_overrides) self._tub.setServiceParent(self) - furl = str(self.announcement["anonymous-storage-FURL"]) self._trigger_cb = trigger_cb - self._reconnector = self._tub.connectTo(furl, self._got_connection) + self._reconnector = self._storage.connect_to(self._tub, self._got_connection) def _got_connection(self, rref): lp = log.msg(format="got connection to %(name)s, getting versions", diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index a78f91acb..45e0bc336 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -1,10 +1,14 @@ import hashlib from mock import Mock -from allmydata.util import base32, yamlutil + +from twisted.application.service import ( + Service, +) from twisted.trial import unittest from twisted.internet.defer import succeed, inlineCallbacks +from allmydata.util import base32, yamlutil from allmydata.storage_client import NativeStorageServer from allmydata.storage_client import StorageFarmBroker @@ -43,6 +47,68 @@ class TestNativeStorageServer(unittest.TestCase): nss = NativeStorageServer("server_id", ann, None, {}) self.assertEqual(nss.get_nickname(), "") + +class UnrecognizedAnnouncement(unittest.TestCase): + """ + Tests for handling of announcements that aren't recognized and don't use + *anonymous-storage-FURL*. + + Recognition failure is created by making up something completely novel for + these tests. In real use, recognition failure would most likely come from + an announcement generated by a storage server plugin which is not loaded + in the client. + """ + ann = { + u"name": u"tahoe-lafs-testing-v1", + u"any-parameter": 12345, + } + server_id = b"abc" + + def _tub_maker(self, overrides): + return Service() + + def native_storage_server(self): + """ + Make a ``NativeStorageServer`` out of an unrecognizable announcement. + """ + return NativeStorageServer( + self.server_id, + self.ann, + self._tub_maker, + {}, + ) + + def test_no_exceptions(self): + """ + ``NativeStorageServer`` can be instantiated with an unrecognized + announcement. + """ + self.native_storage_server() + + def test_start_connecting(self): + """ + ``NativeStorageServer.start_connecting`` does not raise an exception. + """ + server = self.native_storage_server() + server.start_connecting(None) + + def test_stop_connecting(self): + """ + ``NativeStorageServer.stop_connecting`` does not raise an exception. + """ + server = self.native_storage_server() + server.start_connecting(None) + server.stop_connecting() + + def test_try_to_connect(self): + """ + ``NativeStorageServer.try_to_connect`` does not raise an exception. + """ + server = self.native_storage_server() + server.start_connecting(None) + server.try_to_connect() + + class TestStorageFarmBroker(unittest.TestCase): def test_static_servers(self): From 87b37a7e27591c87e7f8c321c204904ed3cba71b Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 21 Jun 2019 15:26:08 -0400 Subject: [PATCH 12/96] be more data-type-y --- src/allmydata/storage_client.py | 103 ++++++++++++++++---------------- 1 file changed, 52 insertions(+), 51 deletions(-) diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 2b2acee96..93ee5c97d 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -277,10 +277,32 @@ class StubServer(object): def get_nickname(self): return "?" + +@attr.s(frozen=True) class _AnonymousStorage(object): """ Abstraction for connecting to an anonymous storage server. """ + nickname = attr.ib() + permutation_seed = attr.ib() + tubid = attr.ib() + + _furl = attr.ib() + _short_description = attr.ib() + _long_description = attr.ib() + + @property + def name(self): + return self._short_description + + @property + def longname(self): + return self._long_description + + @property + def lease_seed(self): + return self.tubid + @classmethod def from_announcement(cls, server_id, ann): """ @@ -293,12 +315,11 @@ class _AnonymousStorage(object): *nickname* is optional. """ - self = cls() - self._furl = str(ann["anonymous-storage-FURL"]) - m = re.match(r'pb://(\w+)@', self._furl) - assert m, self._furl + furl = str(ann["anonymous-storage-FURL"]) + m = re.match(r'pb://(\w+)@', furl) + assert m, furl tubid_s = m.group(1).lower() - self._tubid = base32.a2b(tubid_s) + tubid = base32.a2b(tubid_s) if "permutation-seed-base32" in ann: ps = base32.a2b(str(ann["permutation-seed-base32"])) elif re.search(r'^v0-[0-9a-zA-Z]{52}$', server_id): @@ -311,35 +332,25 @@ class _AnonymousStorage(object): facility="tahoe.storage_broker", level=log.UNUSUAL, umid="qu86tw") ps = hashlib.sha256(server_id).digest() - self._permutation_seed = ps + permutation_seed = ps assert server_id - self._long_description = server_id + long_description = server_id if server_id.startswith("v0-"): # remove v0- prefix from abbreviated name - self._short_description = server_id[3:3+8] + short_description = server_id[3:3+8] else: - self._short_description = server_id[:8] - self._nickname = ann.get("nickname", "") - return self + short_description = server_id[:8] + nickname = ann.get("nickname", "") - def get_permutation_seed(self): - return self._permutation_seed - - def get_name(self): - return self._short_description - - def get_longname(self): - return self._long_description - - def get_lease_seed(self): - return self._tubid - - def get_tubid(self): - return self._tubid - - def get_nickname(self): - return self._nickname + return cls( + nickname=nickname, + permutation_seed=permutation_seed, + furl=furl, + tubid=tubid, + short_description=short_description, + long_description=long_description, + ) def connect_to(self, tub, got_connection): return tub.connectTo(self._furl, got_connection) @@ -350,23 +361,13 @@ class _NullStorage(object): Abstraction for *not* communicating with a storage server of a type with which we can't communicate. """ - def get_permutation_seed(self): - return hashlib.sha256("").digest() + nickname = "" + permutation_seed = hashlib.sha256("").digest() + tubid = hashlib.sha256("").digest() + lease_seed = hashlib.sha256("").digest() - def get_name(self): - return "" - - def get_longname(self): - return "" - - def get_lease_seed(self): - return hashlib.sha256("").digest() - - def get_tubid(self): - return hashlib.sha256("").digest() - - def get_nickname(self): - return "" + name = "" + longname = "" def connect_to(self, tub, got_connection): return NonReconnector() @@ -442,21 +443,21 @@ class NativeStorageServer(service.MultiService): self._storage = storage def get_permutation_seed(self): - return self._storage.get_permutation_seed() + return self._storage.permutation_seed def get_name(self): # keep methodname short # TODO: decide who adds [] in the short description. It should # probably be the output side, not here. - return self._storage.get_name() + return self._storage.name def get_longname(self): - return self._storage.get_longname() + return self._storage.longname def get_tubid(self): - return self._storage.get_tubid() + return self._storage.tubid def get_lease_seed(self): - return self._storage.get_tubid() + return self._storage.lease_seed def get_foolscap_write_enabler_seed(self): - return self._storage.get_tubid() + return self._storage.tubid def get_nickname(self): - return self._storage.get_nickname() + return self._storage.nickname def on_status_changed(self, status_changed): """ From 0f0ca5598ac3d7e0470550d61a5f284a313ab6a3 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 21 Jun 2019 15:35:24 -0400 Subject: [PATCH 13/96] at least minimally test the other implementation --- src/allmydata/test/test_storage_client.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index 45e0bc336..321eda2cd 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -108,6 +108,20 @@ class UnrecognizedAnnouncement(unittest.TestCase): server.start_connecting(None) server.try_to_connect() + def test_various_data_methods(self): + """ + The data accessors of ``NativeStorageServer`` that depend on the + announcement do not raise an exception. + """ + server = self.native_storage_server() + server.get_permutation_seed() + server.get_name() + server.get_longname() + server.get_tubid() + server.get_lease_seed() + server.get_foolscap_write_enabler_seed() + server.get_nickname() + class TestStorageFarmBroker(unittest.TestCase): From a9687259a60e946196c814268047ebf94e4e518d Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 14 Jun 2019 10:23:22 -0400 Subject: [PATCH 14/96] news fragment --- newsfragments/3053.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/3053.feature diff --git a/newsfragments/3053.feature b/newsfragments/3053.feature new file mode 100644 index 000000000..8882aecb0 --- /dev/null +++ b/newsfragments/3053.feature @@ -0,0 +1 @@ +Storage servers can now be configured to load plugins for allmydata.interfaces.IFoolscapStoragePlugin and offer them to clients. \ No newline at end of file From 49abfbb62a734e87c08406a29a398cfdcdf04885 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 14 Jun 2019 10:24:10 -0400 Subject: [PATCH 15/96] storage server plugin configuration --- docs/configuration.rst | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/configuration.rst b/docs/configuration.rst index 3d7d68ef3..9b5d12097 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -9,6 +9,7 @@ Configuring a Tahoe-LAFS node #. `Connection Management`_ #. `Client Configuration`_ #. `Storage Server Configuration`_ +#. `Storage Server Plugin Configuration`_ #. `Frontend Configuration`_ #. `Running A Helper`_ #. `Running An Introducer`_ @@ -798,6 +799,33 @@ Storage Server Configuration In addition, see :doc:`accepting-donations` for a convention encouraging donations to storage server operators. + +Storage Server Plugin Configuration +=================================== + +In addition to the built-in storage server, +it is also possible to load and configure storage server plugins into Tahoe-LAFS. + +Plugins to load are specified in the ``[storage]`` section. + +``plugins = (string, optional)`` + + This gives a comma-separated list of plugin names. + Plugins named here will be loaded and offered to clients. + The default is for no such plugins to be loaded. + +Each plugin can also be configured in a dedicated section. +The section for each plugin is named after the plugin itself:: + + [storageserver.plugins.] + +For example, +the configuration section for a plugin named ``acme-foo-v1`` is ``[storageserver.plugins.acme-foo-v1]``. + +The contents of such sections are defined by the plugins themselves. +Refer to the documentation provided with those plugins. + + Running A Helper ================ From 212f96dfe72a54be71b2ba341513e042bdb50691 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 14 Jun 2019 11:56:02 -0400 Subject: [PATCH 16/96] Baseline tests for anonymous storage server announcements --- src/allmydata/client.py | 17 +++- src/allmydata/test/matchers.py | 49 ++++++++++ src/allmydata/test/test_client.py | 150 +++++++++++++++++++++++++++++- 3 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 src/allmydata/test/matchers.py diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 78dfd50bf..993484a5b 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -210,7 +210,7 @@ def create_client(basedir=u".", _client_factory=None): return defer.fail() -def create_client_from_config(config, _client_factory=None): +def create_client_from_config(config, _client_factory=None, introducer_factory=None): """ Creates a new client instance (a subclass of Node). Most code should probably use `create_client` instead. @@ -222,6 +222,9 @@ def create_client_from_config(config, _client_factory=None): :param _client_factory: for testing; the class to instantiate instead of _Client + + :param introducer_factory: for testing; the class to instantiate instead + of IntroducerClient """ try: if _client_factory is None: @@ -239,7 +242,7 @@ def create_client_from_config(config, _client_factory=None): ) control_tub = node.create_control_tub() - introducer_clients = create_introducer_clients(config, main_tub) + introducer_clients = create_introducer_clients(config, main_tub, introducer_factory) storage_broker = create_storage_farm_broker( config, default_connection_handlers, foolscap_connection_handlers, tub_options, introducer_clients @@ -281,12 +284,18 @@ def _sequencer(config): return seqnum, nonce -def create_introducer_clients(config, main_tub): +def create_introducer_clients(config, main_tub, introducer_factory=None): """ Read, validate and parse any 'introducers.yaml' configuration. + :param introducer_factory: for testing; the class to instantiate instead + of IntroducerClient + :returns: a list of IntroducerClient instances """ + if introducer_factory is None: + introducer_factory = IntroducerClient + # we return this list introducer_clients = [] @@ -332,7 +341,7 @@ def create_introducer_clients(config, main_tub): for petname, introducer in introducers.items(): introducer_cache_filepath = FilePath(config.get_private_path("introducer_{}_cache.yaml".format(petname))) - ic = IntroducerClient( + ic = introducer_factory( main_tub, introducer['furl'].encode("ascii"), config.nickname, diff --git a/src/allmydata/test/matchers.py b/src/allmydata/test/matchers.py new file mode 100644 index 000000000..018934599 --- /dev/null +++ b/src/allmydata/test/matchers.py @@ -0,0 +1,49 @@ +""" +Testtools-style matchers useful to the Tahoe-LAFS test suite. +""" + +from testtools.matchers import ( + AfterPreprocessing, + MatchesStructure, + MatchesDict, + Always, + Equals, +) + +from foolscap.furl import ( + decode_furl, +) + +from allmydata.util import ( + base32, +) + + +def matches_anonymous_storage_announcement(): + """ + Match an anonymous storage announcement. + """ + return MatchesStructure( + # Has each of these keys with associated values that match + service_name=Equals("storage"), + ann=MatchesDict({ + "anonymous-storage-FURL": matches_furl(), + "permutation-seed-base32": matches_base32(), + }), + # Not sure what kind of assertion to make against the key + signing_key=Always(), + ) + + +def matches_furl(): + """ + Match any Foolscap fURL byte string. + """ + return AfterPreprocessing(decode_furl, Always()) + + +def matches_base32(): + """ + Match any base32 encoded byte string. + """ + return AfterPreprocessing(base32.a2b, Always()) diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index 0f864483f..5901bc1a9 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -1,5 +1,8 @@ import os, sys import mock + +import attr + import twisted from yaml import ( safe_dump, @@ -21,6 +24,7 @@ from twisted.python.filepath import ( from testtools.matchers import ( Equals, AfterPreprocessing, + MatchesListwise, ) from testtools.twistedsupport import ( succeeded, @@ -41,10 +45,12 @@ from allmydata.interfaces import IFilesystemNode, IFileNode, \ IImmutableFileNode, IMutableFileNode, IDirectoryNode from foolscap.api import flushEventualQueue import allmydata.test.common_util as testutil -from allmydata.test.common import ( +from .common import ( SyncTestCase, ) - +from .matchers import ( + matches_anonymous_storage_announcement, +) BASECONFIG = ("[client]\n" "introducer.furl = \n" @@ -980,3 +986,143 @@ class NodeMaker(testutil.ReallyEqualMixin, unittest.TestCase): self.failUnlessReallyEqual(n.get_uri(), unknown_rw) self.failUnlessReallyEqual(n.get_write_uri(), unknown_rw) self.failUnlessReallyEqual(n.get_readonly_uri(), "ro." + unknown_ro) + + + +@attr.s +class MemoryIntroducerClient(object): + """ + A model-only (no behavior) stand-in for ``IntroducerClient``. + """ + tub = attr.ib() + introducer_furl = attr.ib() + nickname = attr.ib() + my_version = attr.ib() + oldest_supported = attr.ib() + app_versions = attr.ib() + sequencer = attr.ib() + cache_filepath = attr.ib() + + subscribed_to = attr.ib(default=attr.Factory(list)) + published_announcements = attr.ib(default=attr.Factory(list)) + + + def setServiceParent(self, parent): + pass + + + def subscribe_to(self, service_name, cb, *args, **kwargs): + self.subscribed_to.append(Subscription(service_name, cb, args, kwargs)) + + + def publish(self, service_name, ann, signing_key): + self.published_announcements.append(Announcement(service_name, ann, signing_key)) + + + +@attr.s +class Subscription(object): + """ + A model of an introducer subscription. + """ + service_name = attr.ib() + cb = attr.ib() + args = attr.ib() + kwargs = attr.ib() + + + +@attr.s +class Announcement(object): + """ + A model of an introducer announcement. + """ + service_name = attr.ib() + ann = attr.ib() + signing_key = attr.ib() + + + +def get_published_announcements(client): + """ + Get a flattened list of all announcements sent using all introducer + clients. + """ + return list( + announcement + for introducer_client + in client.introducer_clients + for announcement + in introducer_client.published_announcements + ) + + + +class StorageAnnouncementTests(SyncTestCase): + """ + Tests for the storage announcement published by the client. + """ + def setUp(self): + super(StorageAnnouncementTests, self).setUp() + self.basedir = self.useFixture(TempDir()).path + create_node_dir(self.basedir, u"") + + + def get_config(self, storage_enabled): + return b""" +[node] +tub.location = tcp:192.0.2.0:1234 + +[storage] +enabled = {storage_enabled} + +[client] +introducer.furl = pb://abcde@nowhere/fake +""".format(storage_enabled=storage_enabled) + + + def test_no_announcement(self): + """ + No storage announcement is published if storage is not enabled. + """ + config = config_from_string( + self.basedir, + u"tub.port", + self.get_config(storage_enabled=False), + ) + self.assertThat( + client.create_client_from_config(config, introducer_factory=MemoryIntroducerClient), + succeeded(AfterPreprocessing( + get_published_announcements, + Equals([]), + )), + ) + + + def test_anonymous_storage_announcement(self): + """ + A storage announcement with the anonymous storage fURL is published when + storage is enabled. + """ + config = config_from_string( + self.basedir, + u"tub.port", + self.get_config(storage_enabled=True), + ) + client_deferred = client.create_client_from_config( + config, + introducer_factory=MemoryIntroducerClient, + ) + self.assertThat( + client_deferred, + # The Deferred succeeds + succeeded(AfterPreprocessing( + # The announcements published by the client should ... + get_published_announcements, + # Match the following list (of one element) ... + MatchesListwise([ + # The only element in the list ... + matches_anonymous_storage_announcement(), + ]), + )), + ) From 25287870ee7f6e546e5fdc4cf9e972e8657d9113 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 14 Jun 2019 15:48:28 -0400 Subject: [PATCH 17/96] Add a tool for matching the node key in the announcement And use it in the recently added test --- src/allmydata/test/matchers.py | 36 ++++++++++++++++++++++++++++--- src/allmydata/test/test_client.py | 2 +- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/allmydata/test/matchers.py b/src/allmydata/test/matchers.py index 018934599..fdfc4b552 100644 --- a/src/allmydata/test/matchers.py +++ b/src/allmydata/test/matchers.py @@ -2,7 +2,10 @@ Testtools-style matchers useful to the Tahoe-LAFS test suite. """ +import attr + from testtools.matchers import ( + Mismatch, AfterPreprocessing, MatchesStructure, MatchesDict, @@ -17,9 +20,37 @@ from foolscap.furl import ( from allmydata.util import ( base32, ) +from allmydata.node import ( + read_config, +) +from allmydata.crypto import ( + ed25519, + error, +) + +@attr.s +class MatchesNodePublicKey(object): + """ + Match an object representing the node's private key. + + To verify, the private key is loaded from the node's private config + directory at the time the match is checked. + """ + basedir = attr.ib() + + def match(self, other): + config = read_config(self.basedir, u"tub.port") + privkey_bytes = config.get_private_config("node.privkey") + private_key = ed25519.signing_keypair_from_string(privkey_bytes)[0] + signature = ed25519.sign_data(private_key, b"") + other_public_key = ed25519.verifying_key_from_signing_key(other) + try: + ed25519.verify_signature(other_public_key, signature, b"") + except error.BadSignature: + return Mismatch("The signature did not verify.") -def matches_anonymous_storage_announcement(): +def matches_anonymous_storage_announcement(basedir): """ Match an anonymous storage announcement. """ @@ -30,8 +61,7 @@ def matches_anonymous_storage_announcement(): "anonymous-storage-FURL": matches_furl(), "permutation-seed-base32": matches_base32(), }), - # Not sure what kind of assertion to make against the key - signing_key=Always(), + signing_key=MatchesNodePublicKey(basedir), ) diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index 5901bc1a9..c4576d8aa 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -1122,7 +1122,7 @@ introducer.furl = pb://abcde@nowhere/fake # Match the following list (of one element) ... MatchesListwise([ # The only element in the list ... - matches_anonymous_storage_announcement(), + matches_anonymous_storage_announcement(self.basedir), ]), )), ) From 9608404b6e9ead60b0e450ec2a00338653914699 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 14 Jun 2019 15:59:53 -0400 Subject: [PATCH 18/96] Factor plugin helper behavior into its own fixture --- src/allmydata/test/common.py | 53 +++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/src/allmydata/test/common.py b/src/allmydata/test/common.py index 9e3be0e43..502dff5c1 100644 --- a/src/allmydata/test/common.py +++ b/src/allmydata/test/common.py @@ -80,6 +80,37 @@ from .eliotutil import ( TEST_RSA_KEY_SIZE = 522 + +class UseTestPlugins(object): + """ + A fixture which enables loading Twisted plugins from the Tahoe-LAFS test + suite. + """ + def setUp(self): + """ + Add the testing package ``plugins`` directory to the ``twisted.plugins`` + aggregate package. + """ + import twisted.plugins + testplugins = FilePath(__file__).sibling("plugins") + twisted.plugins.__path__.insert(0, testplugins.path) + + + def cleanUp(self): + """ + Remove the testing package ``plugins`` directory from the + ``twisted.plugins`` aggregate package. + """ + import twisted.plugins + testplugins = FilePath(__file__).sibling("plugins") + twisted.plugins.__path__.remove(testplugins.path) + + + def getDetails(self): + return {} + + + @implementer(IPlugin, IStreamServerEndpointStringParser) class AdoptedServerPort(object): """ @@ -135,23 +166,17 @@ class SameProcessStreamEndpointAssigner(object): """ def setUp(self): self._cleanups = [] + # Make sure the `adopt-socket` endpoint is recognized. We do this + # instead of providing a dropin because we don't want to make this + # endpoint available to random other applications. + f = UseTestPlugins() + f.setUp() + self._cleanups.append(f.cleanUp) def tearDown(self): for c in self._cleanups: c() - def _patch_plugins(self): - """ - Add the testing package ``plugins`` directory to the ``twisted.plugins`` - aggregate package. Arrange for it to be removed again when the - fixture is torn down. - """ - import twisted.plugins - testplugins = FilePath(__file__).sibling("plugins") - twisted.plugins.__path__.insert(0, testplugins.path) - self._cleanups.append(lambda: twisted.plugins.__path__.remove(testplugins.path)) - - def assign(self, reactor): """ Make a new streaming server endpoint and return its string description. @@ -183,10 +208,6 @@ class SameProcessStreamEndpointAssigner(object): host, port = s.getsockname() location_hint = "tcp:%s:%d" % (host, port) port_endpoint = "adopt-socket:fd=%d" % (s.fileno(),) - # Make sure `adopt-socket` is recognized. We do this instead of - # providing a dropin because we don't want to make this endpoint - # available to random other applications. - self._patch_plugins() else: # On other platforms, we blindly guess and hope we get lucky. portnum = iputil.allocate_tcp_port() From 646cd452b97a5f48dcc4078088abf8f776adf127 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 14 Jun 2019 16:34:10 -0400 Subject: [PATCH 19/96] Add tests for announcements for plugins And a basic implementation --- src/allmydata/client.py | 133 +++++++++++++++++- src/allmydata/test/matchers.py | 11 ++ .../test/plugins/tahoe_lafs_dropin.py | 6 + src/allmydata/test/storage_plugin.py | 58 ++++++++ src/allmydata/test/test_client.py | 106 +++++++++++++- 5 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 src/allmydata/test/storage_plugin.py diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 993484a5b..9ad8c0fed 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -3,7 +3,11 @@ from base64 import urlsafe_b64encode from functools import partial from errno import ENOENT, EPERM +import attr from zope.interface import implementer +from twisted.plugin import ( + getPlugins, +) from twisted.internet import reactor, defer from twisted.application import service from twisted.application.internet import TimerService @@ -30,7 +34,14 @@ from allmydata.util.i2p_provider import create as create_i2p_provider from allmydata.util.tor_provider import create as create_tor_provider from allmydata.stats import StatsProvider from allmydata.history import History -from allmydata.interfaces import IStatsProducer, SDMF_VERSION, MDMF_VERSION, DEFAULT_MAX_SEGMENT_SIZE +from allmydata.interfaces import ( + IStatsProducer, + SDMF_VERSION, + MDMF_VERSION, + DEFAULT_MAX_SEGMENT_SIZE, + IFoolscapStoragePlugin, + IAnnounceableStorageServer, +) from allmydata.nodemaker import NodeMaker from allmydata.blacklist import Blacklist from allmydata import node @@ -394,6 +405,15 @@ def create_storage_farm_broker(config, default_connection_handlers, foolscap_con return sb + +@implementer(IAnnounceableStorageServer) +@attr.s +class AnnounceableStorageServer(object): + announcement = attr.ib() + storage_server = attr.ib() + + + @implementer(IStatsProducer) class _Client(node.Node, pollmixin.PollMixin): @@ -597,6 +617,117 @@ class _Client(node.Node, pollmixin.PollMixin): for ic in self.introducer_clients: ic.publish("storage", ann, self._node_private_key) + self._init_storage_plugins() + + + def _init_storage_plugins(self): + """ + Load, register, and announce any configured storage plugins. + """ + storage_plugin_names = self._get_enabled_storage_plugin_names() + plugins = list(self._collect_storage_plugins(storage_plugin_names)) + # TODO What if some names aren't found? + announceable_storage_servers = self._create_plugin_storage_servers(plugins) + self._enable_storage_servers(announceable_storage_servers) + + + def _get_enabled_storage_plugin_names(self): + """ + Get the names of storage plugins that are enabled in the configuration. + """ + return { + self.config.get_config( + "storage", "plugins", b"" + ).decode("ascii") + } + + + def _collect_storage_plugins(self, storage_plugin_names): + """ + Get the storage plugins with names matching those given. + """ + return list( + plugin + for plugin + in getPlugins(IFoolscapStoragePlugin) + if plugin.name in storage_plugin_names + ) + + + def _create_plugin_storage_servers(self, plugins): + """ + Cause each storage plugin to instantiate its storage server and return + them all. + """ + return list( + self._add_to_announcement( + {u"name": plugin.name}, + plugin.get_storage_server( + self._get_storage_plugin_configuration(plugin.name), + lambda: self.getServiceNamed(StorageServer.name) + ), + ) + for plugin + in plugins + ) + + + def _add_to_announcement(self, information, announceable_storage_server): + """ + Create a new ``AnnounceableStorageServer`` based on + ``announceable_storage_server`` with ``information`` added to its + ``announcement``. + """ + updated_announcement = announceable_storage_server.announcement.copy() + updated_announcement.update(information) + return AnnounceableStorageServer( + updated_announcement, + announceable_storage_server.storage_server, + ) + + + def _get_storage_plugin_configuration(self, storage_plugin_name): + return dict( + # Need to reach past the Tahoe-LAFS-supplied wrapper around the + # underlying ConfigParser... + self.config.config.items("storageserver.plugins." + storage_plugin_name) + ) + + + def _enable_storage_servers(self, announceable_storage_servers): + """ + Register and announce the given storage servers. + """ + for announceable in announceable_storage_servers: + self._enable_storage_server(announceable) + + + def _enable_storage_server(self, announceable_storage_server): + """ + Register and announce a storage server. + """ + furl_file = self.config.get_private_path( + "storage-plugin.{}.furl".format( + # Oops, why don't I have a better handle on this value? + announceable_storage_server.announcement[u"name"], + ), + ) + furl = self.tub.registerReference( + announceable_storage_server.storage_server, + furlFile=furl_file.encode(get_filesystem_encoding()), + ) + announceable_storage_server = self._add_to_announcement( + {u"storage-server-FURL": furl}, + announceable_storage_server, + ) + for ic in self.introducer_clients: + ic.publish( + "storage", + announceable_storage_server.announcement, + self._node_key, + ) + + def init_client(self): helper_furl = self.config.get_config("client", "helper.furl", None) if helper_furl in ("None", ""): diff --git a/src/allmydata/test/matchers.py b/src/allmydata/test/matchers.py index fdfc4b552..2e9942647 100644 --- a/src/allmydata/test/matchers.py +++ b/src/allmydata/test/matchers.py @@ -77,3 +77,14 @@ def matches_base32(): Match any base32 encoded byte string. """ return AfterPreprocessing(base32.a2b, Always()) + + + +class MatchesSameElements(object): + """ + Match if the two-tuple value given contains two elements that are equal to + each other. + """ + def match(self, value): + left, right = value + return Equals(left).match(right) diff --git a/src/allmydata/test/plugins/tahoe_lafs_dropin.py b/src/allmydata/test/plugins/tahoe_lafs_dropin.py index 9faf5f07f..f4f31721d 100644 --- a/src/allmydata/test/plugins/tahoe_lafs_dropin.py +++ b/src/allmydata/test/plugins/tahoe_lafs_dropin.py @@ -2,4 +2,10 @@ from allmydata.test.common import ( AdoptedServerPort, ) +from allmydata.test.storage_plugin import ( + DummyStorage, +) + adoptedEndpointParser = AdoptedServerPort() + +dummyStorage = DummyStorage() diff --git a/src/allmydata/test/storage_plugin.py b/src/allmydata/test/storage_plugin.py new file mode 100644 index 000000000..64ee34ed0 --- /dev/null +++ b/src/allmydata/test/storage_plugin.py @@ -0,0 +1,58 @@ +""" +A storage server plugin the test suite can use to validate the +functionality. +""" + +import attr + +from zope.interface import ( + implementer, +) + +from foolscap.api import ( + RemoteInterface, +) + +from allmydata.interfaces import ( + IFoolscapStoragePlugin, +) +from allmydata.client import ( + AnnounceableStorageServer, +) + + +class RIDummy(RemoteInterface): + __remote_name__ = "RIDummy.tahoe.allmydata.com" + + def just_some_method(): + """ + Just some method so there is something callable on this object. We won't + pretend to actually offer any storage capabilities. + """ + + + +@implementer(IFoolscapStoragePlugin) +class DummyStorage(object): + name = u"tahoe-lafs-dummy-v1" + + def get_storage_server(self, configuration, get_anonymous_storage_server): + return AnnounceableStorageServer( + announcement={u"value": configuration[u"some"]}, + storage_server=DummyStorageServer(get_anonymous_storage_server), + ) + + + def get_storage_client(self, configuration, announcement): + pass + + + +@implementer(RIDummy) +@attr.s(cmp=True, hash=True) +class DummyStorageServer(object): + # TODO Requirement of some interface that instances be hashable + get_anonymous_storage_server = attr.ib(cmp=False) + + def remote_just_some_method(self): + pass diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index c4576d8aa..c7875f600 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -25,6 +25,8 @@ from testtools.matchers import ( Equals, AfterPreprocessing, MatchesListwise, + MatchesDict, + MatchesStructure, ) from testtools.twistedsupport import ( succeeded, @@ -39,7 +41,11 @@ from allmydata.node import config_from_string from allmydata.frontends.auth import NeedRootcapLookupScheme from allmydata import client from allmydata.storage_client import StorageFarmBroker -from allmydata.util import base32, fileutil, encodingutil +from allmydata.util import ( + base32, + fileutil, + encodingutil, +) from allmydata.util.fileutil import abspath_expanduser_unicode from allmydata.interfaces import IFilesystemNode, IFileNode, \ IImmutableFileNode, IMutableFileNode, IDirectoryNode @@ -47,9 +53,13 @@ from foolscap.api import flushEventualQueue import allmydata.test.common_util as testutil from .common import ( SyncTestCase, + UseTestPlugins, ) from .matchers import ( + MatchesNodePublicKey, + MatchesSameElements, matches_anonymous_storage_announcement, + matches_furl, ) BASECONFIG = ("[client]\n" @@ -1068,17 +1078,24 @@ class StorageAnnouncementTests(SyncTestCase): create_node_dir(self.basedir, u"") - def get_config(self, storage_enabled): + def get_config(self, storage_enabled, more_storage=b"", more_sections=b""): return b""" [node] tub.location = tcp:192.0.2.0:1234 [storage] enabled = {storage_enabled} +{more_storage} [client] introducer.furl = pb://abcde@nowhere/fake -""".format(storage_enabled=storage_enabled) + +{more_sections} +""".format( + storage_enabled=storage_enabled, + more_storage=more_storage, + more_sections=more_sections, +) def test_no_announcement(self): @@ -1126,3 +1143,86 @@ introducer.furl = pb://abcde@nowhere/fake ]), )), ) + + + def test_single_storage_plugin_announcement(self): + """ + The announcement from a single enabled storage plugin is published when + storage is enabled. + """ + self.useFixture(UseTestPlugins()) + + config = config_from_string( + self.basedir, + u"tub.port", + self.get_config( + storage_enabled=True, + more_storage=b"plugins=tahoe-lafs-dummy-v1", + more_sections=( + b"[storageserver.plugins.tahoe-lafs-dummy-v1]\n" + b"some = thing\n" + ), + ), + ) + matches_dummy_announcement = MatchesStructure( + service_name=Equals("storage"), + ann=MatchesDict({ + # Everyone gets a name and a fURL added to their announcement. + u"name": Equals(u"tahoe-lafs-dummy-v1"), + u"storage-server-FURL": matches_furl(), + # The plugin can contribute things, too. + u"value": Equals(u"thing"), + }), + signing_key=MatchesNodePublicKey(self.basedir), + ) + self.assertThat( + client.create_client_from_config(config, introducer_factory=MemoryIntroducerClient), + succeeded(AfterPreprocessing( + get_published_announcements, + MatchesListwise([ + matches_anonymous_storage_announcement(self.basedir), + matches_dummy_announcement, + ]), + )), + ) + + + def test_stable_storage_server_furl(self): + """ + The value for the ``storage-server-FURL`` item in the announcement for a + particular storage server plugin is stable across different node + instantiations. + """ + self.useFixture(UseTestPlugins()) + + config = config_from_string( + self.basedir, + u"tub.port", + self.get_config( + storage_enabled=True, + more_storage=b"plugins=tahoe-lafs-dummy-v1", + more_sections=( + b"[storageserver.plugins.tahoe-lafs-dummy-v1]\n" + b"some = thing\n" + ), + ), + ) + node_a = client.create_client_from_config( + config, + introducer_factory=MemoryIntroducerClient, + ) + node_b = client.create_client_from_config( + config, + introducer_factory=MemoryIntroducerClient, + ) + + self.assertThat( + defer.gatherResults([node_a, node_b]), + succeeded(AfterPreprocessing( + lambda (a, b): ( + get_published_announcements(a), + get_published_announcements(b), + ), + MatchesSameElements(), + )), + ) From e2982c012920d45c1e2803f0c7d70ce160ab9fa9 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 14 Jun 2019 17:54:35 -0400 Subject: [PATCH 20/96] Support multiple plugins --- src/allmydata/client.py | 12 +++-- .../test/plugins/tahoe_lafs_dropin.py | 3 +- src/allmydata/test/storage_plugin.py | 3 +- src/allmydata/test/test_client.py | 46 +++++++++++++++++++ 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 9ad8c0fed..8eed471bd 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -635,11 +635,11 @@ class _Client(node.Node, pollmixin.PollMixin): """ Get the names of storage plugins that are enabled in the configuration. """ - return { + return set( self.config.get_config( "storage", "plugins", b"" - ).decode("ascii") - } + ).decode("ascii").split(u",") + ) def _collect_storage_plugins(self, storage_plugin_names): @@ -668,7 +668,11 @@ class _Client(node.Node, pollmixin.PollMixin): ), ) for plugin - in plugins + # The order is fairly arbitrary and it is not meant to convey + # anything but providing *some* stable ordering makes the data a + # little easier to deal with (mainly in tests and when manually + # inspecting it). + in sorted(plugins, key=lambda p: p.name) ) diff --git a/src/allmydata/test/plugins/tahoe_lafs_dropin.py b/src/allmydata/test/plugins/tahoe_lafs_dropin.py index f4f31721d..24651e388 100644 --- a/src/allmydata/test/plugins/tahoe_lafs_dropin.py +++ b/src/allmydata/test/plugins/tahoe_lafs_dropin.py @@ -8,4 +8,5 @@ from allmydata.test.storage_plugin import ( adoptedEndpointParser = AdoptedServerPort() -dummyStorage = DummyStorage() +dummyStoragev1 = DummyStorage(u"tahoe-lafs-dummy-v1") +dummyStoragev2 = DummyStorage(u"tahoe-lafs-dummy-v2") diff --git a/src/allmydata/test/storage_plugin.py b/src/allmydata/test/storage_plugin.py index 64ee34ed0..c82eda8f7 100644 --- a/src/allmydata/test/storage_plugin.py +++ b/src/allmydata/test/storage_plugin.py @@ -33,8 +33,9 @@ class RIDummy(RemoteInterface): @implementer(IFoolscapStoragePlugin) +@attr.s class DummyStorage(object): - name = u"tahoe-lafs-dummy-v1" + name = attr.ib() def get_storage_server(self, configuration, get_anonymous_storage_server): return AnnounceableStorageServer( diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index c7875f600..e26cd2950 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -1187,6 +1187,52 @@ introducer.furl = pb://abcde@nowhere/fake ) + def test_multiple_storage_plugin_announcements(self): + """ + The announcements from several enabled storage plugins are published when + storage is enabled. + """ + self.useFixture(UseTestPlugins()) + + config = config_from_string( + self.basedir, + u"tub.port", + self.get_config( + storage_enabled=True, + more_storage=b"plugins=tahoe-lafs-dummy-v1,tahoe-lafs-dummy-v2", + more_sections=( + b"[storageserver.plugins.tahoe-lafs-dummy-v1]\n" + b"some = thing-1\n" + b"[storageserver.plugins.tahoe-lafs-dummy-v2]\n" + b"some = thing-2\n" + ), + ), + ) + def matches_dummy_announcement(v): + return MatchesStructure( + service_name=Equals("storage"), + ann=MatchesDict({ + # Everyone gets a name and a fURL added to their announcement. + u"name": Equals(u"tahoe-lafs-dummy-v{}".format(v)), + u"storage-server-FURL": matches_furl(), + # The plugin can contribute things, too. + u"value": Equals(u"thing-{}".format(v)), + }), + signing_key=MatchesNodePublicKey(self.basedir), + ) + self.assertThat( + client.create_client_from_config(config, introducer_factory=MemoryIntroducerClient), + succeeded(AfterPreprocessing( + get_published_announcements, + MatchesListwise([ + matches_anonymous_storage_announcement(self.basedir), + matches_dummy_announcement(b"1"), + matches_dummy_announcement(b"2"), + ]), + )), + ) + + def test_stable_storage_server_furl(self): """ The value for the ``storage-server-FURL`` item in the announcement for a From f606beb0651e64169092ae631bf34508eb7b630d Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 14 Jun 2019 18:06:14 -0400 Subject: [PATCH 21/96] Test and support plugins without any configuration --- src/allmydata/client.py | 16 ++++- src/allmydata/test/storage_plugin.py | 2 +- src/allmydata/test/test_client.py | 102 ++++++++++++++++++++------- 3 files changed, 89 insertions(+), 31 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 8eed471bd..68bb6c0fb 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -2,6 +2,7 @@ import os, stat, time, weakref from base64 import urlsafe_b64encode from functools import partial from errno import ENOENT, EPERM +from ConfigParser import NoSectionError import attr from zope.interface import implementer @@ -691,11 +692,20 @@ class _Client(node.Node, pollmixin.PollMixin): def _get_storage_plugin_configuration(self, storage_plugin_name): - return dict( + """ + Load the configuration for a storage server plugin with the given name. + + :return dict: The matching configuration. + """ + try: # Need to reach past the Tahoe-LAFS-supplied wrapper around the # underlying ConfigParser... - self.config.config.items("storageserver.plugins." + storage_plugin_name) - ) + config = self.config.config.items( + "storageserver.plugins." + storage_plugin_name, + ) + except NoSectionError: + config = [] + return dict(config) def _enable_storage_servers(self, announceable_storage_servers): diff --git a/src/allmydata/test/storage_plugin.py b/src/allmydata/test/storage_plugin.py index c82eda8f7..a11116705 100644 --- a/src/allmydata/test/storage_plugin.py +++ b/src/allmydata/test/storage_plugin.py @@ -39,7 +39,7 @@ class DummyStorage(object): def get_storage_server(self, configuration, get_anonymous_storage_server): return AnnounceableStorageServer( - announcement={u"value": configuration[u"some"]}, + announcement={u"value": configuration.get(u"some", u"default-value")}, storage_server=DummyStorageServer(get_anonymous_storage_server), ) diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index e26cd2950..d347e7e21 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -1068,6 +1068,34 @@ def get_published_announcements(client): +def matches_dummy_announcement(basedir, name, value): + """ + Matches the announcement for the ``DummyStorage`` storage server plugin. + + :param str basedir: The path to the node the storage server plugin is + loaded into. + + :param unicode name: The name of the dummy plugin. + + :param unicode value: The arbitrary value in the dummy plugin + announcement. + + :return: a testtools-style matcher + """ + return MatchesStructure( + service_name=Equals("storage"), + ann=MatchesDict({ + # Everyone gets a name and a fURL added to their announcement. + u"name": Equals(name), + u"storage-server-FURL": matches_furl(), + # The plugin can contribute things, too. + u"value": Equals(value), + }), + signing_key=MatchesNodePublicKey(basedir), + ) + + + class StorageAnnouncementTests(SyncTestCase): """ Tests for the storage announcement published by the client. @@ -1152,6 +1180,7 @@ introducer.furl = pb://abcde@nowhere/fake """ self.useFixture(UseTestPlugins()) + value = u"thing" config = config_from_string( self.basedir, u"tub.port", @@ -1160,28 +1189,21 @@ introducer.furl = pb://abcde@nowhere/fake more_storage=b"plugins=tahoe-lafs-dummy-v1", more_sections=( b"[storageserver.plugins.tahoe-lafs-dummy-v1]\n" - b"some = thing\n" + b"some = {}\n".format(value) ), ), ) - matches_dummy_announcement = MatchesStructure( - service_name=Equals("storage"), - ann=MatchesDict({ - # Everyone gets a name and a fURL added to their announcement. - u"name": Equals(u"tahoe-lafs-dummy-v1"), - u"storage-server-FURL": matches_furl(), - # The plugin can contribute things, too. - u"value": Equals(u"thing"), - }), - signing_key=MatchesNodePublicKey(self.basedir), - ) self.assertThat( client.create_client_from_config(config, introducer_factory=MemoryIntroducerClient), succeeded(AfterPreprocessing( get_published_announcements, MatchesListwise([ matches_anonymous_storage_announcement(self.basedir), - matches_dummy_announcement, + matches_dummy_announcement( + self.basedir, + u"tahoe-lafs-dummy-v1", + value, + ), ]), )), ) @@ -1208,26 +1230,22 @@ introducer.furl = pb://abcde@nowhere/fake ), ), ) - def matches_dummy_announcement(v): - return MatchesStructure( - service_name=Equals("storage"), - ann=MatchesDict({ - # Everyone gets a name and a fURL added to their announcement. - u"name": Equals(u"tahoe-lafs-dummy-v{}".format(v)), - u"storage-server-FURL": matches_furl(), - # The plugin can contribute things, too. - u"value": Equals(u"thing-{}".format(v)), - }), - signing_key=MatchesNodePublicKey(self.basedir), - ) self.assertThat( client.create_client_from_config(config, introducer_factory=MemoryIntroducerClient), succeeded(AfterPreprocessing( get_published_announcements, MatchesListwise([ matches_anonymous_storage_announcement(self.basedir), - matches_dummy_announcement(b"1"), - matches_dummy_announcement(b"2"), + matches_dummy_announcement( + self.basedir, + u"tahoe-lafs-dummy-v1", + u"thing-1", + ), + matches_dummy_announcement( + self.basedir, + u"tahoe-lafs-dummy-v2", + u"thing-2", + ), ]), )), ) @@ -1272,3 +1290,33 @@ introducer.furl = pb://abcde@nowhere/fake MatchesSameElements(), )), ) + + + def test_storage_plugin_without_configuration(self): + """ + A storage plugin with no configuration is loaded and announced. + """ + self.useFixture(UseTestPlugins()) + + config = config_from_string( + self.basedir, + u"tub.port", + self.get_config( + storage_enabled=True, + more_storage=b"plugins=tahoe-lafs-dummy-v1", + ), + ) + self.assertThat( + client.create_client_from_config(config, introducer_factory=MemoryIntroducerClient), + succeeded(AfterPreprocessing( + get_published_announcements, + MatchesListwise([ + matches_anonymous_storage_announcement(self.basedir), + matches_dummy_announcement( + self.basedir, + u"tahoe-lafs-dummy-v1", + u"default-value", + ), + ]), + )), + ) From a45e2bebfec842afc36d1d85c6cb82cd9498877a Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 17 Jun 2019 14:18:36 -0400 Subject: [PATCH 22/96] Allow the new plugins item in the [storage] section --- src/allmydata/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 68bb6c0fb..f7b86f04d 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -90,6 +90,7 @@ def _valid_config(): "readonly", "reserved_space", "storage_dir", + "plugins", ), "sftpd": ( "accounts.file", From 7919cf205ee2c77511f9c9de400a5e3fa875f8a9 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 17 Jun 2019 14:23:08 -0400 Subject: [PATCH 23/96] Test the actual interface get_storage_server is supposed to return a Deferred --- src/allmydata/client.py | 25 +++++++++++++++++-------- src/allmydata/test/storage_plugin.py | 14 +++++++++++--- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index f7b86f04d..b4eb98402 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -551,6 +551,7 @@ class _Client(node.Node, pollmixin.PollMixin): self.config.write_config_file("permutation-seed", seed+"\n") return seed.strip() + @defer.inlineCallbacks def init_storage(self): # should we run a storage server (and publish it for others to use)? if not self.config.get_config("storage", "enabled", True, boolean=True): @@ -619,9 +620,10 @@ class _Client(node.Node, pollmixin.PollMixin): for ic in self.introducer_clients: ic.publish("storage", ann, self._node_private_key) - self._init_storage_plugins() + yield self._init_storage_plugins() + @defer.inlineCallbacks def _init_storage_plugins(self): """ Load, register, and announce any configured storage plugins. @@ -629,7 +631,7 @@ class _Client(node.Node, pollmixin.PollMixin): storage_plugin_names = self._get_enabled_storage_plugin_names() plugins = list(self._collect_storage_plugins(storage_plugin_names)) # TODO What if some names aren't found? - announceable_storage_servers = self._create_plugin_storage_servers(plugins) + announceable_storage_servers = yield self._create_plugin_storage_servers(plugins) self._enable_storage_servers(announceable_storage_servers) @@ -660,21 +662,28 @@ class _Client(node.Node, pollmixin.PollMixin): """ Cause each storage plugin to instantiate its storage server and return them all. + + :return: A ``Deferred`` that fires with storage servers instantiated + by all of the given storage server plugins. """ - return list( - self._add_to_announcement( - {u"name": plugin.name}, + return defer.gatherResults( + list( plugin.get_storage_server( self._get_storage_plugin_configuration(plugin.name), - lambda: self.getServiceNamed(StorageServer.name) - ), - ) + lambda: self.getServiceNamed(StorageServer.name), + ).addCallback( + partial( + self._add_to_announcement, + {u"name": plugin.name}, + ), + ) for plugin # The order is fairly arbitrary and it is not meant to convey # anything but providing *some* stable ordering makes the data a # little easier to deal with (mainly in tests and when manually # inspecting it). in sorted(plugins, key=lambda p: p.name) + ), ) diff --git a/src/allmydata/test/storage_plugin.py b/src/allmydata/test/storage_plugin.py index a11116705..970d91388 100644 --- a/src/allmydata/test/storage_plugin.py +++ b/src/allmydata/test/storage_plugin.py @@ -9,6 +9,10 @@ from zope.interface import ( implementer, ) +from twisted.internet.defer import ( + succeed, +) + from foolscap.api import ( RemoteInterface, ) @@ -38,9 +42,13 @@ class DummyStorage(object): name = attr.ib() def get_storage_server(self, configuration, get_anonymous_storage_server): - return AnnounceableStorageServer( - announcement={u"value": configuration.get(u"some", u"default-value")}, - storage_server=DummyStorageServer(get_anonymous_storage_server), + announcement = {u"value": configuration.get(u"some", u"default-value")} + storage_server = DummyStorageServer(get_anonymous_storage_server) + return succeed( + AnnounceableStorageServer( + announcement, + storage_server, + ), ) From a6959d111c2fa9d98d6ecac068013c90b8d8c1e7 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 17 Jun 2019 14:29:43 -0400 Subject: [PATCH 24/96] Log init_storage and its result (particularly failures) --- src/allmydata/client.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index b4eb98402..15389bf15 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -6,6 +6,14 @@ from ConfigParser import NoSectionError import attr from zope.interface import implementer + +from eliot import ( + start_action, +) +from eliot.twisted import ( + DeferredContext, +) + from twisted.plugin import ( getPlugins, ) @@ -456,7 +464,10 @@ class _Client(node.Node, pollmixin.PollMixin): self.init_stats_provider() self.init_secrets() self.init_node_key() - self.init_storage() + + with start_action(action_type=u"client:init-storage").context(): + DeferredContext(self.init_storage()).addActionFinish() + self.init_control() self._key_generator = KeyGenerator() key_gen_furl = config.get_config("client", "key_generator.furl", None) From 3bc21e1b7293571159f396053d0c1e4de002526f Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 17 Jun 2019 14:39:26 -0400 Subject: [PATCH 25/96] Re-synchronize the fake with the real implementation --- src/allmydata/test/no_network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/allmydata/test/no_network.py b/src/allmydata/test/no_network.py index 1333dba60..bb9d14dad 100644 --- a/src/allmydata/test/no_network.py +++ b/src/allmydata/test/no_network.py @@ -249,7 +249,7 @@ class _NoNetworkClient(_Client): def init_key_gen(self): pass def init_storage(self): - pass + return defer.succeed(None) def init_client_storage_broker(self): self.storage_broker = NoNetworkStorageBroker() self.storage_broker.client = self From 6cf48f7d4f9355243ad73648a52bc9fe1cacffe0 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 19 Jun 2019 10:17:41 -0400 Subject: [PATCH 26/96] Separate async initialization from _Client.__init__ --- src/allmydata/client.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 15389bf15..7399b92d2 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -283,7 +283,9 @@ def create_client_from_config(config, _client_factory=None, introducer_factory=N for ic in introducer_clients: ic.setServiceParent(client) storage_broker.setServiceParent(client) - return defer.succeed(client) + d = client.init_storage_plugins() + d.addCallback(lambda ignored: client) + return d except Exception: return defer.fail() @@ -464,10 +466,7 @@ class _Client(node.Node, pollmixin.PollMixin): self.init_stats_provider() self.init_secrets() self.init_node_key() - - with start_action(action_type=u"client:init-storage").context(): - DeferredContext(self.init_storage()).addActionFinish() - + self.init_storage() self.init_control() self._key_generator = KeyGenerator() key_gen_furl = config.get_config("client", "key_generator.furl", None) @@ -562,7 +561,6 @@ class _Client(node.Node, pollmixin.PollMixin): self.config.write_config_file("permutation-seed", seed+"\n") return seed.strip() - @defer.inlineCallbacks def init_storage(self): # should we run a storage server (and publish it for others to use)? if not self.config.get_config("storage", "enabled", True, boolean=True): @@ -631,11 +629,9 @@ class _Client(node.Node, pollmixin.PollMixin): for ic in self.introducer_clients: ic.publish("storage", ann, self._node_private_key) - yield self._init_storage_plugins() - @defer.inlineCallbacks - def _init_storage_plugins(self): + def init_storage_plugins(self): """ Load, register, and announce any configured storage plugins. """ From 3719a107bebb71899d5c1976a651f142e7da4dfa Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 19 Jun 2019 10:46:02 -0400 Subject: [PATCH 27/96] Stop putting a useless client section in here [client] is not a valid common section so this fails if there's validation --- src/allmydata/test/test_connections.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/allmydata/test/test_connections.py b/src/allmydata/test/test_connections.py index 5ba6a0db5..ccec13336 100644 --- a/src/allmydata/test/test_connections.py +++ b/src/allmydata/test/test_connections.py @@ -12,9 +12,7 @@ from ..util.i2p_provider import create as create_i2p_provider from ..util.tor_provider import create as create_tor_provider -BASECONFIG = ("[client]\n" - "introducer.furl = \n" - ) +BASECONFIG = "" class TCP(unittest.TestCase): @@ -568,4 +566,3 @@ class Status(unittest.TestCase): {"h1 via hand1": "st1", "h2": "st2"}) self.assertEqual(cs.last_connection_time, None) self.assertEqual(cs.last_received_time, 5) - From 23e163125932e571e5b399fb8bd3a1167af82862 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 19 Jun 2019 10:46:28 -0400 Subject: [PATCH 28/96] switch from node to client for config loading apparently clients are the things with storage --- src/allmydata/test/test_client.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index d347e7e21..e3f80bb94 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -694,7 +694,7 @@ class IntroducerClients(unittest.TestCase): "[client]\n" "introducer.furl = None\n" ) - config = config_from_string("basedir", "client.port", cfg) + config = client.config_from_string("basedir", "client.port", cfg) with self.assertRaises(ValueError) as ctx: client.create_introducer_clients(config, main_tub=None) @@ -1130,7 +1130,7 @@ introducer.furl = pb://abcde@nowhere/fake """ No storage announcement is published if storage is not enabled. """ - config = config_from_string( + config = client.config_from_string( self.basedir, u"tub.port", self.get_config(storage_enabled=False), @@ -1149,7 +1149,7 @@ introducer.furl = pb://abcde@nowhere/fake A storage announcement with the anonymous storage fURL is published when storage is enabled. """ - config = config_from_string( + config = client.config_from_string( self.basedir, u"tub.port", self.get_config(storage_enabled=True), @@ -1181,7 +1181,7 @@ introducer.furl = pb://abcde@nowhere/fake self.useFixture(UseTestPlugins()) value = u"thing" - config = config_from_string( + config = client.config_from_string( self.basedir, u"tub.port", self.get_config( @@ -1216,7 +1216,7 @@ introducer.furl = pb://abcde@nowhere/fake """ self.useFixture(UseTestPlugins()) - config = config_from_string( + config = client.config_from_string( self.basedir, u"tub.port", self.get_config( @@ -1259,7 +1259,7 @@ introducer.furl = pb://abcde@nowhere/fake """ self.useFixture(UseTestPlugins()) - config = config_from_string( + config = client.config_from_string( self.basedir, u"tub.port", self.get_config( @@ -1298,7 +1298,7 @@ introducer.furl = pb://abcde@nowhere/fake """ self.useFixture(UseTestPlugins()) - config = config_from_string( + config = client.config_from_string( self.basedir, u"tub.port", self.get_config( From 756c21c2512022f134f763119991bc2fef9474f7 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 19 Jun 2019 10:47:23 -0400 Subject: [PATCH 29/96] actually provide validating client-config-from-string function --- src/allmydata/client.py | 31 +++++++++++++++++++++++++++---- src/allmydata/node.py | 12 +++++++++--- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 7399b92d2..1fed97215 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -62,9 +62,17 @@ GiB=1024*MiB TiB=1024*GiB PiB=1024*TiB -def _valid_config(): - cfg = node._common_valid_config() - return cfg.update(configutil.ValidConfiguration({ +def _is_valid_section(section_name): + """ + Check for valid dynamic configuration section names. + + Currently considers all possible storage server plugin sections valid. + """ + return section_name.startswith("storageserver.plugins.") + + +_client_config = configutil.ValidConfiguration( + static_valid_sections={ "client": ( "helper.furl", "introducer.furl", @@ -117,7 +125,16 @@ def _valid_config(): "local.directory", "poll_interval", ), - })) + }, + is_valid_section=_is_valid_section, + # Anything in a valid section is a valid item, for now. + is_valid_item=lambda section, ignored: _is_valid_section(section), +) + + +def _valid_config(): + cfg = node._common_valid_config() + return cfg.update(_client_config) # this is put into README in new node-directories CLIENT_README = """ @@ -207,6 +224,12 @@ def read_config(basedir, portnumfile, generated_files=[]): ) +config_from_string = partial( + node.config_from_string, + _valid_config=_valid_config(), +) + + def create_client(basedir=u".", _client_factory=None): """ Creates a new client instance (a subclass of Node). diff --git a/src/allmydata/node.py b/src/allmydata/node.py index 757e650b7..b0880f099 100644 --- a/src/allmydata/node.py +++ b/src/allmydata/node.py @@ -195,14 +195,20 @@ def read_config(basedir, portnumfile, generated_files=[], _valid_config=None): return _Config(parser, portnumfile, basedir, config_fname) -def config_from_string(basedir, portnumfile, config_str): +def config_from_string(basedir, portnumfile, config_str, _valid_config=None): """ - load configuration from in-memory string + load and validate configuration from in-memory string """ + if _valid_config is None: + _valid_config = _common_valid_config() + # load configuration from in-memory string parser = ConfigParser.SafeConfigParser() parser.readfp(BytesIO(config_str)) - return _Config(parser, portnumfile, basedir, '') + + fname = "" + configutil.validate_config(fname, parser, _valid_config) + return _Config(parser, portnumfile, basedir, fname) def get_app_versions(): From fd9ae2414991494032e533714556bea20873c2ec Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 19 Jun 2019 12:08:32 -0400 Subject: [PATCH 30/96] fix indentation --- src/allmydata/client.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 1fed97215..b72726bc6 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -707,12 +707,12 @@ class _Client(node.Node, pollmixin.PollMixin): {u"name": plugin.name}, ), ) - for plugin - # The order is fairly arbitrary and it is not meant to convey - # anything but providing *some* stable ordering makes the data a - # little easier to deal with (mainly in tests and when manually - # inspecting it). - in sorted(plugins, key=lambda p: p.name) + for plugin + # The order is fairly arbitrary and it is not meant to convey + # anything but providing *some* stable ordering makes the data + # a little easier to deal with (mainly in tests and when + # manually inspecting it). + in sorted(plugins, key=lambda p: p.name) ), ) From 1c68157c1f162f9eb376dcc9f6e1f4409284dbbb Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 19 Jun 2019 12:40:34 -0400 Subject: [PATCH 31/96] verify behavior if there is a poorly behaved plugin --- src/allmydata/test/storage_plugin.py | 3 +++ src/allmydata/test/test_client.py | 29 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/allmydata/test/storage_plugin.py b/src/allmydata/test/storage_plugin.py index 970d91388..3091de2f7 100644 --- a/src/allmydata/test/storage_plugin.py +++ b/src/allmydata/test/storage_plugin.py @@ -42,6 +42,9 @@ class DummyStorage(object): name = attr.ib() def get_storage_server(self, configuration, get_anonymous_storage_server): + if u"invalid" in configuration: + raise Exception("The plugin is unhappy.") + announcement = {u"value": configuration.get(u"some", u"default-value")} storage_server = DummyStorageServer(get_anonymous_storage_server) return succeed( diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index e3f80bb94..0fcb6e572 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -27,9 +27,11 @@ from testtools.matchers import ( MatchesListwise, MatchesDict, MatchesStructure, + Always, ) from testtools.twistedsupport import ( succeeded, + failed, ) import allmydata @@ -1320,3 +1322,30 @@ introducer.furl = pb://abcde@nowhere/fake ]), )), ) + + + def test_broken_storage_plugin(self): + """ + A storage plugin that raises an exception from ``get_storage_server`` + causes ``client.create_client_from_config`` to return ``Deferred`` + that fails. + """ + self.useFixture(UseTestPlugins()) + + config = client.config_from_string( + self.basedir, + u"tub.port", + self.get_config( + storage_enabled=True, + more_storage=b"plugins=tahoe-lafs-dummy-v1", + more_sections=( + b"[storageserver.plugins.tahoe-lafs-dummy-v1]\n" + # This will make it explode on instantiation. + b"invalid = configuration\n" + ) + ), + ) + self.assertThat( + client.create_client_from_config(config, introducer_factory=MemoryIntroducerClient), + failed(Always()), + ) From deb3109f43430706611f68569775150469bb2dc5 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 19 Jun 2019 12:40:49 -0400 Subject: [PATCH 32/96] please report all errors --- src/allmydata/scripts/tahoe_daemonize.py | 9 ++++++--- src/allmydata/test/cli/test_daemonize.py | 8 ++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/allmydata/scripts/tahoe_daemonize.py b/src/allmydata/scripts/tahoe_daemonize.py index 48fa16d56..40585fb26 100644 --- a/src/allmydata/scripts/tahoe_daemonize.py +++ b/src/allmydata/scripts/tahoe_daemonize.py @@ -115,6 +115,7 @@ class DaemonizeTheRealService(Service, HookMixin): - 'running': triggered when startup has completed; it triggers with None of successful or a Failure otherwise. """ + stderr = sys.stderr def __init__(self, nodetype, basedir, options): super(DaemonizeTheRealService, self).__init__() @@ -145,10 +146,12 @@ class DaemonizeTheRealService(Service, HookMixin): raise ValueError("unknown nodetype %s" % self.nodetype) def handle_config_error(fail): - fail.trap(UnknownConfigError) - self.stderr.write("\nConfiguration error:\n{}\n\n".format(fail.value)) + if fail.check(UnknownConfigError): + self.stderr.write("\nConfiguration error:\n{}\n\n".format(fail.value)) + else: + self.stderr.write("\nUnknown error\n") + fail.printTraceback(self.stderr) reactor.stop() - return d = service_factory() diff --git a/src/allmydata/test/cli/test_daemonize.py b/src/allmydata/test/cli/test_daemonize.py index 414061977..e9a6d1ca0 100644 --- a/src/allmydata/test/cli/test_daemonize.py +++ b/src/allmydata/test/cli/test_daemonize.py @@ -1,4 +1,7 @@ import os +from io import ( + BytesIO, +) from os.path import dirname, join from mock import patch, Mock from six.moves import StringIO @@ -52,6 +55,7 @@ class Util(unittest.TestCase): def test_daemonize_no_keygen(self): tmpdir = self.mktemp() + stderr = BytesIO() plug = DaemonizeTahoeNodePlugin('key-generator', tmpdir) with patch('twisted.internet.reactor') as r: @@ -59,8 +63,8 @@ class Util(unittest.TestCase): d = fn() d.addErrback(lambda _: None) # ignore the error we'll trigger r.callWhenRunning = call - r.stop = 'foo' service = plug.makeService(self.options) + service.stderr = stderr service.parent = Mock() # we'll raise ValueError because there's no key-generator # .. BUT we do this in an async function called via @@ -70,7 +74,7 @@ class Util(unittest.TestCase): def done(f): self.assertIn( "key-generator support removed", - str(f), + stderr.getvalue(), ) return None d.addBoth(done) From e825e635909cfacb4085ade84ff0571f5273ae1d Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 26 Jun 2019 08:39:05 -0400 Subject: [PATCH 33/96] This returned to being synchronous --- src/allmydata/test/no_network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/allmydata/test/no_network.py b/src/allmydata/test/no_network.py index bb9d14dad..1333dba60 100644 --- a/src/allmydata/test/no_network.py +++ b/src/allmydata/test/no_network.py @@ -249,7 +249,7 @@ class _NoNetworkClient(_Client): def init_key_gen(self): pass def init_storage(self): - return defer.succeed(None) + pass def init_client_storage_broker(self): self.storage_broker = NoNetworkStorageBroker() self.storage_broker.client = self From 58db131787be0b979af0e87b83796496d01e5b56 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 26 Jun 2019 15:00:20 -0400 Subject: [PATCH 34/96] remove unused imports --- src/allmydata/client.py | 7 ------- src/allmydata/test/test_client.py | 1 - 2 files changed, 8 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index b72726bc6..10755d646 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -7,13 +7,6 @@ from ConfigParser import NoSectionError import attr from zope.interface import implementer -from eliot import ( - start_action, -) -from eliot.twisted import ( - DeferredContext, -) - from twisted.plugin import ( getPlugins, ) diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index 0fcb6e572..11574000d 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -39,7 +39,6 @@ import allmydata.frontends.magic_folder import allmydata.util.log from allmydata.node import OldConfigError, OldConfigOptionError, UnescapedHashError, _Config, create_node_dir -from allmydata.node import config_from_string from allmydata.frontends.auth import NeedRootcapLookupScheme from allmydata import client from allmydata.storage_client import StorageFarmBroker From 8516459fa3de2114fb3d5b1230db6a9c77fe5a8c Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 26 Jun 2019 15:48:54 -0400 Subject: [PATCH 35/96] Python 3 syntax compatibility --- src/allmydata/test/test_client.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index 11574000d..4ec3e71af 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -1,6 +1,8 @@ import os, sys import mock - +from functools import ( + partial, +) import attr import twisted @@ -1284,10 +1286,7 @@ introducer.furl = pb://abcde@nowhere/fake self.assertThat( defer.gatherResults([node_a, node_b]), succeeded(AfterPreprocessing( - lambda (a, b): ( - get_published_announcements(a), - get_published_announcements(b), - ), + partial(map, get_published_announcements), MatchesSameElements(), )), ) From 251eda0b8086fc0edbd393b74c3cd6572018e902 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Thu, 27 Jun 2019 14:59:04 -0400 Subject: [PATCH 36/96] rename introducer_factory parameter to be private --- src/allmydata/client.py | 16 ++++++++-------- src/allmydata/test/test_client.py | 31 +++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 10755d646..eb8ad3e9e 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -247,7 +247,7 @@ def create_client(basedir=u".", _client_factory=None): return defer.fail() -def create_client_from_config(config, _client_factory=None, introducer_factory=None): +def create_client_from_config(config, _client_factory=None, _introducer_factory=None): """ Creates a new client instance (a subclass of Node). Most code should probably use `create_client` instead. @@ -260,7 +260,7 @@ def create_client_from_config(config, _client_factory=None, introducer_factory=N :param _client_factory: for testing; the class to instantiate instead of _Client - :param introducer_factory: for testing; the class to instantiate instead + :param _introducer_factory: for testing; the class to instantiate instead of IntroducerClient """ try: @@ -279,7 +279,7 @@ def create_client_from_config(config, _client_factory=None, introducer_factory=N ) control_tub = node.create_control_tub() - introducer_clients = create_introducer_clients(config, main_tub, introducer_factory) + introducer_clients = create_introducer_clients(config, main_tub, _introducer_factory) storage_broker = create_storage_farm_broker( config, default_connection_handlers, foolscap_connection_handlers, tub_options, introducer_clients @@ -323,17 +323,17 @@ def _sequencer(config): return seqnum, nonce -def create_introducer_clients(config, main_tub, introducer_factory=None): +def create_introducer_clients(config, main_tub, _introducer_factory=None): """ Read, validate and parse any 'introducers.yaml' configuration. - :param introducer_factory: for testing; the class to instantiate instead + :param _introducer_factory: for testing; the class to instantiate instead of IntroducerClient :returns: a list of IntroducerClient instances """ - if introducer_factory is None: - introducer_factory = IntroducerClient + if _introducer_factory is None: + _introducer_factory = IntroducerClient # we return this list introducer_clients = [] @@ -380,7 +380,7 @@ def create_introducer_clients(config, main_tub, introducer_factory=None): for petname, introducer in introducers.items(): introducer_cache_filepath = FilePath(config.get_private_path("introducer_{}_cache.yaml".format(petname))) - ic = introducer_factory( + ic = _introducer_factory( main_tub, introducer['furl'].encode("ascii"), config.nickname, diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index 4ec3e71af..4a36a6e8a 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -1139,7 +1139,10 @@ introducer.furl = pb://abcde@nowhere/fake self.get_config(storage_enabled=False), ) self.assertThat( - client.create_client_from_config(config, introducer_factory=MemoryIntroducerClient), + client.create_client_from_config( + config, + _introducer_factory=MemoryIntroducerClient, + ), succeeded(AfterPreprocessing( get_published_announcements, Equals([]), @@ -1159,7 +1162,7 @@ introducer.furl = pb://abcde@nowhere/fake ) client_deferred = client.create_client_from_config( config, - introducer_factory=MemoryIntroducerClient, + _introducer_factory=MemoryIntroducerClient, ) self.assertThat( client_deferred, @@ -1197,7 +1200,10 @@ introducer.furl = pb://abcde@nowhere/fake ), ) self.assertThat( - client.create_client_from_config(config, introducer_factory=MemoryIntroducerClient), + client.create_client_from_config( + config, + _introducer_factory=MemoryIntroducerClient, + ), succeeded(AfterPreprocessing( get_published_announcements, MatchesListwise([ @@ -1234,7 +1240,10 @@ introducer.furl = pb://abcde@nowhere/fake ), ) self.assertThat( - client.create_client_from_config(config, introducer_factory=MemoryIntroducerClient), + client.create_client_from_config( + config, + _introducer_factory=MemoryIntroducerClient, + ), succeeded(AfterPreprocessing( get_published_announcements, MatchesListwise([ @@ -1276,11 +1285,11 @@ introducer.furl = pb://abcde@nowhere/fake ) node_a = client.create_client_from_config( config, - introducer_factory=MemoryIntroducerClient, + _introducer_factory=MemoryIntroducerClient, ) node_b = client.create_client_from_config( config, - introducer_factory=MemoryIntroducerClient, + _introducer_factory=MemoryIntroducerClient, ) self.assertThat( @@ -1307,7 +1316,10 @@ introducer.furl = pb://abcde@nowhere/fake ), ) self.assertThat( - client.create_client_from_config(config, introducer_factory=MemoryIntroducerClient), + client.create_client_from_config( + config, + _introducer_factory=MemoryIntroducerClient, + ), succeeded(AfterPreprocessing( get_published_announcements, MatchesListwise([ @@ -1344,6 +1356,9 @@ introducer.furl = pb://abcde@nowhere/fake ), ) self.assertThat( - client.create_client_from_config(config, introducer_factory=MemoryIntroducerClient), + client.create_client_from_config( + config, + _introducer_factory=MemoryIntroducerClient, + ), failed(Always()), ) From d2e16df5ccac5fde2136b7694ec183f8bc6d7b23 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Thu, 27 Jun 2019 14:59:12 -0400 Subject: [PATCH 37/96] link to a ticket about implementing better missing-plugin behavior --- src/allmydata/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index eb8ad3e9e..1bb3c9ff4 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -653,7 +653,8 @@ class _Client(node.Node, pollmixin.PollMixin): """ storage_plugin_names = self._get_enabled_storage_plugin_names() plugins = list(self._collect_storage_plugins(storage_plugin_names)) - # TODO What if some names aren't found? + # TODO Handle missing plugins + # https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3118 announceable_storage_servers = yield self._create_plugin_storage_servers(plugins) self._enable_storage_servers(announceable_storage_servers) From 6068b6c1b226344f67b1e5403e083206df4e9464 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Thu, 27 Jun 2019 15:07:45 -0400 Subject: [PATCH 38/96] don't reach through the tahoe-lafs config object --- src/allmydata/client.py | 6 ++---- src/allmydata/node.py | 3 +++ src/allmydata/test/test_node.py | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 1bb3c9ff4..a9c6acd3c 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -729,12 +729,10 @@ class _Client(node.Node, pollmixin.PollMixin): """ Load the configuration for a storage server plugin with the given name. - :return dict: The matching configuration. + :return dict[bytes, bytes]: The matching configuration. """ try: - # Need to reach past the Tahoe-LAFS-supplied wrapper around the - # underlying ConfigParser... - config = self.config.config.items( + config = self.config.items( "storageserver.plugins." + storage_plugin_name, ) except NoSectionError: diff --git a/src/allmydata/node.py b/src/allmydata/node.py index b0880f099..4c5fe48a0 100644 --- a/src/allmydata/node.py +++ b/src/allmydata/node.py @@ -292,6 +292,9 @@ class _Config(object): "Unable to write config file '{}'".format(fn), ) + def items(self, section): + return self.config.items(section) + def get_config(self, section, option, default=_None, boolean=False): try: if boolean: diff --git a/src/allmydata/test/test_node.py b/src/allmydata/test/test_node.py index a8a6b3743..bee94861e 100644 --- a/src/allmydata/test/test_node.py +++ b/src/allmydata/test/test_node.py @@ -4,6 +4,7 @@ import stat import sys import time import mock +from textwrap import dedent from unittest import skipIf @@ -175,6 +176,29 @@ class TestCase(testutil.SignalMixin, unittest.TestCase): with self.assertRaises(Exception): config.get_config_from_file("it_does_not_exist", required=True) + def test_config_items(self): + """ + All items in a config section can be retrieved. + """ + basedir = u"test_node/test_config_items" + create_node_dir(basedir, "testing") + + with open(os.path.join(basedir, 'tahoe.cfg'), 'wt') as f: + f.write(dedent( + """ + [node] + nickname = foo + timeout.disconnect = 12 + """ + )) + config = read_config(basedir, "portnum") + self.assertEqual( + config.items("node"), + [(b"nickname", b"foo"), + (b"timeout.disconnect", b"12"), + ], + ) + @skipIf( "win32" in sys.platform.lower() or "cygwin" in sys.platform.lower(), "We don't know how to set permissions on Windows.", From 9c240b61ac41fe94d3ff75d5502bd6ddb34e547d Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Thu, 27 Jun 2019 15:29:37 -0400 Subject: [PATCH 39/96] Persist the furl ourselves rather than relying on Foolscap Going via our config abstraction here will let us change how config is persisted more easily, later. --- src/allmydata/client.py | 52 +++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index a9c6acd3c..82e0546a5 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -4,6 +4,10 @@ from functools import partial from errno import ENOENT, EPERM from ConfigParser import NoSectionError +from foolscap.furl import ( + decode_furl, +) + import attr from zope.interface import implementer @@ -433,6 +437,40 @@ def create_storage_farm_broker(config, default_connection_handlers, foolscap_con return sb +def _register_reference(key, config, tub, referenceable): + """ + Register a referenceable in a tub with a stable fURL. + + Stability is achieved by storing the fURL in the configuration the first + time and then reading it back on for future calls. + + :param bytes key: An identifier for this reference which can be used to + identify its fURL in the configuration. + + :param _Config config: The configuration to use for fURL persistence. + + :param Tub tub: The tub in which to register the reference. + + :param Referenceable referenceable: The referenceable to register in the + Tub. + + :return bytes: The fURL at which the object is registered. + """ + persisted_furl = config.get_private_config( + key, + default=None, + ) + name = None + if persisted_furl is not None: + _, _, name = decode_furl(persisted_furl) + registered_furl = tub.registerReference( + referenceable, + name=name, + ) + if persisted_furl is None: + config.write_private_config(key, registered_furl) + return registered_furl + @implementer(IAnnounceableStorageServer) @attr.s @@ -752,15 +790,15 @@ class _Client(node.Node, pollmixin.PollMixin): """ Register and announce a storage server. """ - furl_file = self.config.get_private_path( - "storage-plugin.{}.furl".format( - # Oops, why don't I have a better handle on this value? - announceable_storage_server.announcement[u"name"], - ), + config_key = b"storage-plugin.{}.furl".format( + # Oops, why don't I have a better handle on this value? + announceable_storage_server.announcement[u"name"], ) - furl = self.tub.registerReference( + furl = _register_reference( + config_key, + self.config, + self.tub, announceable_storage_server.storage_server, - furlFile=furl_file.encode(get_filesystem_encoding()), ) announceable_storage_server = self._add_to_announcement( {u"storage-server-FURL": furl}, From 016e18ac9c70ff30657ef41dae8456edbc1be831 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 11:46:04 -0400 Subject: [PATCH 40/96] news fragment --- newsfragments/3119.minor | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 newsfragments/3119.minor diff --git a/newsfragments/3119.minor b/newsfragments/3119.minor new file mode 100644 index 000000000..e69de29bb From 624591e4123ed82be77fb9354395c7081f007644 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 11:51:32 -0400 Subject: [PATCH 41/96] Change the tests to match against the announcement we need --- src/allmydata/test/matchers.py | 16 ++++--- src/allmydata/test/test_client.py | 74 +++++++++++++++---------------- 2 files changed, 47 insertions(+), 43 deletions(-) diff --git a/src/allmydata/test/matchers.py b/src/allmydata/test/matchers.py index 2e9942647..5e96311a4 100644 --- a/src/allmydata/test/matchers.py +++ b/src/allmydata/test/matchers.py @@ -9,6 +9,7 @@ from testtools.matchers import ( AfterPreprocessing, MatchesStructure, MatchesDict, + MatchesListwise, Always, Equals, ) @@ -50,17 +51,20 @@ class MatchesNodePublicKey(object): return Mismatch("The signature did not verify.") -def matches_anonymous_storage_announcement(basedir): +def matches_storage_announcement(basedir, options=None): """ Match an anonymous storage announcement. """ + announcement = { + u"anonymous-storage-FURL": matches_furl(), + u"permutation-seed-base32": matches_base32(), + } + if options: + announcement[u"storage-options"] = MatchesListwise(options) return MatchesStructure( # Has each of these keys with associated values that match - service_name=Equals("storage"), - ann=MatchesDict({ - "anonymous-storage-FURL": matches_furl(), - "permutation-seed-base32": matches_base32(), - }), + service_name=Equals(u"storage"), + ann=MatchesDict(announcement), signing_key=MatchesNodePublicKey(basedir), ) diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index 4a36a6e8a..e9065c93e 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -28,7 +28,6 @@ from testtools.matchers import ( AfterPreprocessing, MatchesListwise, MatchesDict, - MatchesStructure, Always, ) from testtools.twistedsupport import ( @@ -59,9 +58,8 @@ from .common import ( UseTestPlugins, ) from .matchers import ( - MatchesNodePublicKey, MatchesSameElements, - matches_anonymous_storage_announcement, + matches_storage_announcement, matches_furl, ) @@ -1071,12 +1069,10 @@ def get_published_announcements(client): -def matches_dummy_announcement(basedir, name, value): +def matches_dummy_announcement(name, value): """ - Matches the announcement for the ``DummyStorage`` storage server plugin. - - :param str basedir: The path to the node the storage server plugin is - loaded into. + Matches the portion of an announcement for the ``DummyStorage`` storage + server plugin. :param unicode name: The name of the dummy plugin. @@ -1085,17 +1081,13 @@ def matches_dummy_announcement(basedir, name, value): :return: a testtools-style matcher """ - return MatchesStructure( - service_name=Equals("storage"), - ann=MatchesDict({ - # Everyone gets a name and a fURL added to their announcement. - u"name": Equals(name), - u"storage-server-FURL": matches_furl(), - # The plugin can contribute things, too. - u"value": Equals(value), - }), - signing_key=MatchesNodePublicKey(basedir), - ) + return MatchesDict({ + # Everyone gets a name and a fURL added to their announcement. + u"name": Equals(name), + u"storage-server-FURL": matches_furl(), + # The plugin can contribute things, too. + u"value": Equals(value), + }) @@ -1173,7 +1165,7 @@ introducer.furl = pb://abcde@nowhere/fake # Match the following list (of one element) ... MatchesListwise([ # The only element in the list ... - matches_anonymous_storage_announcement(self.basedir), + matches_storage_announcement(self.basedir), ]), )), ) @@ -1207,11 +1199,14 @@ introducer.furl = pb://abcde@nowhere/fake succeeded(AfterPreprocessing( get_published_announcements, MatchesListwise([ - matches_anonymous_storage_announcement(self.basedir), - matches_dummy_announcement( + matches_storage_announcement( self.basedir, - u"tahoe-lafs-dummy-v1", - value, + options=[ + matches_dummy_announcement( + u"tahoe-lafs-dummy-v1", + value, + ), + ], ), ]), )), @@ -1247,16 +1242,18 @@ introducer.furl = pb://abcde@nowhere/fake succeeded(AfterPreprocessing( get_published_announcements, MatchesListwise([ - matches_anonymous_storage_announcement(self.basedir), - matches_dummy_announcement( + matches_storage_announcement( self.basedir, - u"tahoe-lafs-dummy-v1", - u"thing-1", - ), - matches_dummy_announcement( - self.basedir, - u"tahoe-lafs-dummy-v2", - u"thing-2", + options=[ + matches_dummy_announcement( + u"tahoe-lafs-dummy-v1", + u"thing-1", + ), + matches_dummy_announcement( + u"tahoe-lafs-dummy-v2", + u"thing-2", + ), + ], ), ]), )), @@ -1323,11 +1320,14 @@ introducer.furl = pb://abcde@nowhere/fake succeeded(AfterPreprocessing( get_published_announcements, MatchesListwise([ - matches_anonymous_storage_announcement(self.basedir), - matches_dummy_announcement( + matches_storage_announcement( self.basedir, - u"tahoe-lafs-dummy-v1", - u"default-value", + options=[ + matches_dummy_announcement( + u"tahoe-lafs-dummy-v1", + u"default-value", + ), + ], ), ]), )), From 07bf8a3b8c25018159e09cdf43901b2b316da10c Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 11:51:42 -0400 Subject: [PATCH 42/96] Change this helper to reflect the fact that old announcements are irrelevant --- src/allmydata/test/test_client.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index e9065c93e..caafc1cd5 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -1056,16 +1056,21 @@ class Announcement(object): def get_published_announcements(client): """ - Get a flattened list of all announcements sent using all introducer + Get a flattened list of the latest announcements sent using all introducer clients. + + Only the most recent announcement for any particular service name is acted + on so these are the only announcements returned. """ - return list( - announcement + return { + announcement.service_name: announcement for introducer_client in client.introducer_clients + # Visit the announcements in the order they were sent. The last one + # will win in the dictionary we construct. for announcement in introducer_client.published_announcements - ) + }.values() From 53861e2a0f6844eb305f066e3c77c15bc4fc2833 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 11:55:13 -0400 Subject: [PATCH 43/96] Change the shape of the storage announcement(s) Instead of generating a sequence of announcements like: - anonymous storage server announcement - plugin 1 storage server announcement - ... - plugin N storage server announcement The client now generates a single announcement like: - anonymous storage server details - storage-options - plugin 1 storage server details - ... - plugin N storage server details --- src/allmydata/client.py | 361 +++++++++++++++++++++++----------------- 1 file changed, 211 insertions(+), 150 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 82e0546a5..4e557cd07 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -251,6 +251,7 @@ def create_client(basedir=u".", _client_factory=None): return defer.fail() +@defer.inlineCallbacks def create_client_from_config(config, _client_factory=None, _introducer_factory=None): """ Creates a new client instance (a subclass of Node). Most code @@ -267,47 +268,151 @@ def create_client_from_config(config, _client_factory=None, _introducer_factory= :param _introducer_factory: for testing; the class to instantiate instead of IntroducerClient """ - try: - if _client_factory is None: - _client_factory = _Client + if _client_factory is None: + _client_factory = _Client - i2p_provider = create_i2p_provider(reactor, config) - tor_provider = create_tor_provider(reactor, config) - handlers = node.create_connection_handlers(reactor, config, i2p_provider, tor_provider) - default_connection_handlers, foolscap_connection_handlers = handlers - tub_options = node.create_tub_options(config) + i2p_provider = create_i2p_provider(reactor, config) + tor_provider = create_tor_provider(reactor, config) + handlers = node.create_connection_handlers(reactor, config, i2p_provider, tor_provider) + default_connection_handlers, foolscap_connection_handlers = handlers + tub_options = node.create_tub_options(config) - main_tub = node.create_main_tub( - config, tub_options, default_connection_handlers, - foolscap_connection_handlers, i2p_provider, tor_provider, - ) - control_tub = node.create_control_tub() + main_tub = node.create_main_tub( + config, tub_options, default_connection_handlers, + foolscap_connection_handlers, i2p_provider, tor_provider, + ) + control_tub = node.create_control_tub() - introducer_clients = create_introducer_clients(config, main_tub, _introducer_factory) - storage_broker = create_storage_farm_broker( - config, default_connection_handlers, foolscap_connection_handlers, - tub_options, introducer_clients - ) + introducer_clients = create_introducer_clients(config, main_tub, _introducer_factory) + storage_broker = create_storage_farm_broker( + config, default_connection_handlers, foolscap_connection_handlers, + tub_options, introducer_clients + ) - client = _client_factory( + client = _client_factory( + config, + main_tub, + control_tub, + i2p_provider, + tor_provider, + introducer_clients, + storage_broker, + ) + + # Initialize storage separately after creating the client. This is + # necessary because we need to pass a reference to the client in to the + # storage plugins to allow them to initialize themselves (specifically, + # they may want the anonymous IStorageServer implementation so they don't + # have to duplicate all of its basic storage functionality). A better way + # to do this, eventually, may be to create that implementation first and + # then pass it in to both storage plugin creation and the client factory. + # This avoids making a partially initialized client object escape the + # client factory and removes the circular dependency between these + # objects. + storage_plugins = yield _StoragePlugins.from_config( + client.get_anonymous_storage_server, + config, + ) + client.init_storage(storage_plugins.announceable_storage_servers) + + i2p_provider.setServiceParent(client) + tor_provider.setServiceParent(client) + for ic in introducer_clients: + ic.setServiceParent(client) + storage_broker.setServiceParent(client) + defer.returnValue(client) + + +@attr.s +class _StoragePlugins(object): + announceable_storage_servers = attr.ib() + + @classmethod + @defer.inlineCallbacks + def from_config(cls, get_anonymous_storage_server, config): + """ + Load and configured storage plugins. + """ + storage_plugin_names = cls._get_enabled_storage_plugin_names(config) + plugins = list(cls._collect_storage_plugins(storage_plugin_names)) + # TODO Handle missing plugins + # https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3118 + announceable_storage_servers = yield cls._create_plugin_storage_servers( + get_anonymous_storage_server, config, - main_tub, - control_tub, - i2p_provider, - tor_provider, - introducer_clients, - storage_broker, + plugins, ) - i2p_provider.setServiceParent(client) - tor_provider.setServiceParent(client) - for ic in introducer_clients: - ic.setServiceParent(client) - storage_broker.setServiceParent(client) - d = client.init_storage_plugins() - d.addCallback(lambda ignored: client) - return d - except Exception: - return defer.fail() + defer.returnValue(cls( + announceable_storage_servers, + )) + + @classmethod + def _get_enabled_storage_plugin_names(cls, config): + """ + Get the names of storage plugins that are enabled in the configuration. + """ + return set( + config.get_config( + "storage", "plugins", b"" + ).decode("ascii").split(u",") + ) + + @classmethod + def _collect_storage_plugins(cls, storage_plugin_names): + """ + Get the storage plugins with names matching those given. + """ + return list( + plugin + for plugin + in getPlugins(IFoolscapStoragePlugin) + if plugin.name in storage_plugin_names + ) + + @classmethod + def _create_plugin_storage_servers(cls, get_anonymous_storage_server, config, plugins): + """ + Cause each storage plugin to instantiate its storage server and return + them all. + + :return: A ``Deferred`` that fires with storage servers instantiated + by all of the given storage server plugins. + """ + return defer.gatherResults( + list( + plugin.get_storage_server( + cls._get_storage_plugin_configuration(config, plugin.name), + get_anonymous_storage_server, + ).addCallback( + partial( + _add_to_announcement, + {u"name": plugin.name}, + ), + ) + for plugin + # The order is fairly arbitrary and it is not meant to convey + # anything but providing *some* stable ordering makes the data + # a little easier to deal with (mainly in tests and when + # manually inspecting it). + in sorted(plugins, key=lambda p: p.name) + ), + ) + + @classmethod + def _get_storage_plugin_configuration(cls, config, storage_plugin_name): + """ + Load the configuration for a storage server plugin with the given name. + + :return dict[bytes, bytes]: The matching configuration. + """ + try: + config = config.items( + "storageserver.plugins." + storage_plugin_name, + ) + except NoSectionError: + config = [] + return dict(config) + def _sequencer(config): @@ -480,6 +585,21 @@ class AnnounceableStorageServer(object): +def _add_to_announcement(information, announceable_storage_server): + """ + Create a new ``AnnounceableStorageServer`` based on + ``announceable_storage_server`` with ``information`` added to its + ``announcement``. + """ + updated_announcement = announceable_storage_server.announcement.copy() + updated_announcement.update(information) + return AnnounceableStorageServer( + updated_announcement, + announceable_storage_server.storage_server, + ) + + + @implementer(IStatsProducer) class _Client(node.Node, pollmixin.PollMixin): @@ -520,7 +640,6 @@ class _Client(node.Node, pollmixin.PollMixin): self.init_stats_provider() self.init_secrets() self.init_node_key() - self.init_storage() self.init_control() self._key_generator = KeyGenerator() key_gen_furl = config.get_config("client", "key_generator.furl", None) @@ -615,13 +734,24 @@ class _Client(node.Node, pollmixin.PollMixin): self.config.write_config_file("permutation-seed", seed+"\n") return seed.strip() - def init_storage(self): - # should we run a storage server (and publish it for others to use)? - if not self.config.get_config("storage", "enabled", True, boolean=True): - return - if not self._is_tub_listening(): - raise ValueError("config error: storage is enabled, but tub " - "is not listening ('tub.port=' is empty)") + def get_anonymous_storage_server(self): + """ + Get the anonymous ``IStorageServer`` implementation for this node. + + Note this will return an object even if storage is disabled on this + node (but the object will not be exposed, peers will not be able to + access it, and storage will remain disabled). + + The one and only instance for this node is always returned. It is + created first if necessary. + """ + try: + ss = self.getServiceNamed(StorageServer.name) + except KeyError: + pass + else: + return ss + readonly = self.config.get_config("storage", "readonly", False, boolean=True) config_storedir = self.get_config( @@ -674,108 +804,44 @@ class _Client(node.Node, pollmixin.PollMixin): expiration_cutoff_date=cutoff_date, expiration_sharetypes=expiration_sharetypes) ss.setServiceParent(self) + return ss + def init_storage(self, announceable_storage_servers): + # should we run a storage server (and publish it for others to use)? + if not self.config.get_config("storage", "enabled", True, boolean=True): + return + if not self._is_tub_listening(): + raise ValueError("config error: storage is enabled, but tub " + "is not listening ('tub.port=' is empty)") + + ss = self.get_anonymous_storage_server() furl_file = self.config.get_private_path("storage.furl").encode(get_filesystem_encoding()) furl = self.tub.registerReference(ss, furlFile=furl_file) - ann = {"anonymous-storage-FURL": furl, - "permutation-seed-base32": self._init_permutation_seed(ss), - } + + anonymous_announcement = { + "anonymous-storage-FURL": furl, + "permutation-seed-base32": self._init_permutation_seed(ss), + } + + enabled_storage_servers = self._enable_storage_servers( + announceable_storage_servers, + ) + plugins_announcement = {} + storage_options = list( + storage_server.announcement + for storage_server + in enabled_storage_servers + ) + if storage_options: + # Only add the new key if there are any plugins enabled. + plugins_announcement[u"storage-options"] = storage_options + + total_announcement = {} + total_announcement.update(anonymous_announcement) + total_announcement.update(plugins_announcement) + for ic in self.introducer_clients: - ic.publish("storage", ann, self._node_private_key) - - - @defer.inlineCallbacks - def init_storage_plugins(self): - """ - Load, register, and announce any configured storage plugins. - """ - storage_plugin_names = self._get_enabled_storage_plugin_names() - plugins = list(self._collect_storage_plugins(storage_plugin_names)) - # TODO Handle missing plugins - # https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3118 - announceable_storage_servers = yield self._create_plugin_storage_servers(plugins) - self._enable_storage_servers(announceable_storage_servers) - - - def _get_enabled_storage_plugin_names(self): - """ - Get the names of storage plugins that are enabled in the configuration. - """ - return set( - self.config.get_config( - "storage", "plugins", b"" - ).decode("ascii").split(u",") - ) - - - def _collect_storage_plugins(self, storage_plugin_names): - """ - Get the storage plugins with names matching those given. - """ - return list( - plugin - for plugin - in getPlugins(IFoolscapStoragePlugin) - if plugin.name in storage_plugin_names - ) - - - def _create_plugin_storage_servers(self, plugins): - """ - Cause each storage plugin to instantiate its storage server and return - them all. - - :return: A ``Deferred`` that fires with storage servers instantiated - by all of the given storage server plugins. - """ - return defer.gatherResults( - list( - plugin.get_storage_server( - self._get_storage_plugin_configuration(plugin.name), - lambda: self.getServiceNamed(StorageServer.name), - ).addCallback( - partial( - self._add_to_announcement, - {u"name": plugin.name}, - ), - ) - for plugin - # The order is fairly arbitrary and it is not meant to convey - # anything but providing *some* stable ordering makes the data - # a little easier to deal with (mainly in tests and when - # manually inspecting it). - in sorted(plugins, key=lambda p: p.name) - ), - ) - - - def _add_to_announcement(self, information, announceable_storage_server): - """ - Create a new ``AnnounceableStorageServer`` based on - ``announceable_storage_server`` with ``information`` added to its - ``announcement``. - """ - updated_announcement = announceable_storage_server.announcement.copy() - updated_announcement.update(information) - return AnnounceableStorageServer( - updated_announcement, - announceable_storage_server.storage_server, - ) - - - def _get_storage_plugin_configuration(self, storage_plugin_name): - """ - Load the configuration for a storage server plugin with the given name. - - :return dict[bytes, bytes]: The matching configuration. - """ - try: - config = self.config.items( - "storageserver.plugins." + storage_plugin_name, - ) - except NoSectionError: - config = [] - return dict(config) + ic.publish("storage", total_announcement, self._node_private_key) def _enable_storage_servers(self, announceable_storage_servers): @@ -783,12 +849,12 @@ class _Client(node.Node, pollmixin.PollMixin): Register and announce the given storage servers. """ for announceable in announceable_storage_servers: - self._enable_storage_server(announceable) + yield self._enable_storage_server(announceable) def _enable_storage_server(self, announceable_storage_server): """ - Register and announce a storage server. + Register a storage server. """ config_key = b"storage-plugin.{}.furl".format( # Oops, why don't I have a better handle on this value? @@ -800,16 +866,11 @@ class _Client(node.Node, pollmixin.PollMixin): self.tub, announceable_storage_server.storage_server, ) - announceable_storage_server = self._add_to_announcement( + announceable_storage_server = _add_to_announcement( {u"storage-server-FURL": furl}, announceable_storage_server, ) - for ic in self.introducer_clients: - ic.publish( - "storage", - announceable_storage_server.announcement, - self._node_key, - ) + return announceable_storage_server def init_client(self): From 895cf37a84e45ed9a893b8085805506258156e94 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 12:02:38 -0400 Subject: [PATCH 44/96] docstrings --- src/allmydata/client.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 4e557cd07..878bf3a09 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -325,6 +325,13 @@ def create_client_from_config(config, _client_factory=None, _introducer_factory= @attr.s class _StoragePlugins(object): + """ + Functionality related to getting storage plugins set up and ready for use. + + :ivar list[IAnnounceableStorageServer] announceable_storage_servers: The + announceable storage servers that should be used according to node + configuration. + """ announceable_storage_servers = attr.ib() @classmethod @@ -332,6 +339,14 @@ class _StoragePlugins(object): def from_config(cls, get_anonymous_storage_server, config): """ Load and configured storage plugins. + + :param get_anonymous_storage_server: A no-argument callable which + returns the node's anonymous ``IStorageServer`` implementation. + + :param _Config config: The node's configuration. + + :return: A ``_StoragePlugins`` initialized from the given + configuration. """ storage_plugin_names = cls._get_enabled_storage_plugin_names(config) plugins = list(cls._collect_storage_plugins(storage_plugin_names)) From 4133febad68ad9691672df0f0fce52df876b7992 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Thu, 27 Jun 2019 09:48:51 -0400 Subject: [PATCH 45/96] news fragment --- newsfragments/3054.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/3054.feature diff --git a/newsfragments/3054.feature b/newsfragments/3054.feature new file mode 100644 index 000000000..5042829b3 --- /dev/null +++ b/newsfragments/3054.feature @@ -0,0 +1 @@ +Storage clients can not be configured to load plugins for allmydata.interfaces.IFoolscapStoragePlugin and use them to negotiate with servers. From 3b6e1e344bca71cf8e18bcee996b141e99ebce3c Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Thu, 27 Jun 2019 13:15:10 -0400 Subject: [PATCH 46/96] Don't blow up the web status if we get an unrecognized announcement --- src/allmydata/test/test_storage_client.py | 25 +++++++++++++++++++++++ src/allmydata/util/connection_status.py | 22 +++++++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index 321eda2cd..fbd4fa199 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -8,9 +8,16 @@ from twisted.application.service import ( from twisted.trial import unittest from twisted.internet.defer import succeed, inlineCallbacks +from foolscap.api import ( + Tub, +) + from allmydata.util import base32, yamlutil from allmydata.storage_client import NativeStorageServer from allmydata.storage_client import StorageFarmBroker +from allmydata.interfaces import ( + IConnectionStatus, +) class NativeStorageServerWithVersion(NativeStorageServer): @@ -48,6 +55,24 @@ class TestNativeStorageServer(unittest.TestCase): self.assertEqual(nss.get_nickname(), "") +class GetConnectionStatus(unittest.TestCase): + """ + Tests for ``NativeStorageServer.get_connection_status``. + """ + def test_unrecognized_announcement(self): + """ + When ``NativeStorageServer`` is constructed with a storage announcement it + doesn't recognize, its ``get_connection_status`` nevertheless returns + an object which provides ``IConnectionStatus``. + """ + # Pretty hard to recognize anything from an empty announcement. + ann = {} + nss = NativeStorageServer("server_id", ann, Tub, {}) + nss.start_connecting(lambda: None) + connection_status = nss.get_connection_status() + self.assertTrue(IConnectionStatus.providedBy(connection_status)) + + class UnrecognizedAnnouncement(unittest.TestCase): """ Tests for handling of announcements that aren't recognized and don't use diff --git a/src/allmydata/util/connection_status.py b/src/allmydata/util/connection_status.py index 3f5dd5278..44c12f220 100644 --- a/src/allmydata/util/connection_status.py +++ b/src/allmydata/util/connection_status.py @@ -12,6 +12,20 @@ class ConnectionStatus(object): self.last_connection_time = last_connection_time self.last_received_time = last_received_time + @classmethod + def unstarted(cls): + """ + Create a ``ConnectionStatus`` representing a connection for which no + attempts have yet been made. + """ + return cls( + connected=False, + summary=u"unstarted", + non_connected_statuses=[], + last_connection_time=None, + last_received_time=None, + ) + def _hint_statuses(which, handlers, statuses): non_connected_statuses = {} for hint in which: @@ -23,10 +37,12 @@ def _hint_statuses(which, handlers, statuses): def from_foolscap_reconnector(rc, last_received): ri = rc.getReconnectionInfo() + # See foolscap/reconnector.py, ReconnectionInfo, for details about + # possible states. state = ri.state - # the Reconnector shouldn't even be exposed until it is started, so we - # should never see "unstarted" - assert state in ("connected", "connecting", "waiting"), state + if state == "unstarted": + return ConnectionStatus.unstarted() + ci = ri.connectionInfo connected = False last_connected = None From e8b38d8cd66a4d205ef2643a0822d528fe094417 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Thu, 27 Jun 2019 14:48:31 -0400 Subject: [PATCH 47/96] move some testing helpers into the common module --- src/allmydata/test/common.py | 82 +++++++++++++++++++++++++++++++ src/allmydata/test/test_client.py | 77 +---------------------------- 2 files changed, 84 insertions(+), 75 deletions(-) diff --git a/src/allmydata/test/common.py b/src/allmydata/test/common.py index 502dff5c1..0be9f45f7 100644 --- a/src/allmydata/test/common.py +++ b/src/allmydata/test/common.py @@ -26,6 +26,8 @@ from errno import ( EADDRINUSE, ) +import attr + import treq from zope.interface import implementer @@ -73,6 +75,9 @@ from allmydata.util.consumer import download_to_data import allmydata.test.common_util as testutil from allmydata.immutable.upload import Uploader +from ..crypto import ( + ed25519, +) from .eliotutil import ( EliotLoggedRunTest, ) @@ -81,6 +86,83 @@ from .eliotutil import ( TEST_RSA_KEY_SIZE = 522 +@attr.s +class MemoryIntroducerClient(object): + """ + A model-only (no behavior) stand-in for ``IntroducerClient``. + """ + tub = attr.ib() + introducer_furl = attr.ib() + nickname = attr.ib() + my_version = attr.ib() + oldest_supported = attr.ib() + app_versions = attr.ib() + sequencer = attr.ib() + cache_filepath = attr.ib() + + subscribed_to = attr.ib(default=attr.Factory(list)) + published_announcements = attr.ib(default=attr.Factory(list)) + + + def setServiceParent(self, parent): + pass + + + def subscribe_to(self, service_name, cb, *args, **kwargs): + self.subscribed_to.append(Subscription(service_name, cb, args, kwargs)) + + + def publish(self, service_name, ann, signing_key): + self.published_announcements.append(Announcement( + service_name, + ann, + ed25519.string_from_signing_key(signing_key), + )) + + + +@attr.s +class Subscription(object): + """ + A model of an introducer subscription. + """ + service_name = attr.ib() + cb = attr.ib() + args = attr.ib() + kwargs = attr.ib() + + + +@attr.s +class Announcement(object): + """ + A model of an introducer announcement. + """ + service_name = attr.ib() + ann = attr.ib() + signing_key_bytes = attr.ib(type=bytes) + + @property + def signing_key(self): + return ed25519.signing_keypair_from_string(self.signing_key_bytes)[0] + + + +def get_published_announcements(client): + """ + Get a flattened list of all announcements sent using all introducer + clients. + """ + return list( + announcement + for introducer_client + in client.introducer_clients + for announcement + in introducer_client.published_announcements + ) + + + class UseTestPlugins(object): """ A fixture which enables loading Twisted plugins from the Tahoe-LAFS test diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index caafc1cd5..169efe229 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -3,7 +3,6 @@ import mock from functools import ( partial, ) -import attr import twisted from yaml import ( @@ -56,6 +55,8 @@ import allmydata.test.common_util as testutil from .common import ( SyncTestCase, UseTestPlugins, + MemoryIntroducerClient, + get_published_announcements, ) from .matchers import ( MatchesSameElements, @@ -1000,80 +1001,6 @@ class NodeMaker(testutil.ReallyEqualMixin, unittest.TestCase): -@attr.s -class MemoryIntroducerClient(object): - """ - A model-only (no behavior) stand-in for ``IntroducerClient``. - """ - tub = attr.ib() - introducer_furl = attr.ib() - nickname = attr.ib() - my_version = attr.ib() - oldest_supported = attr.ib() - app_versions = attr.ib() - sequencer = attr.ib() - cache_filepath = attr.ib() - - subscribed_to = attr.ib(default=attr.Factory(list)) - published_announcements = attr.ib(default=attr.Factory(list)) - - - def setServiceParent(self, parent): - pass - - - def subscribe_to(self, service_name, cb, *args, **kwargs): - self.subscribed_to.append(Subscription(service_name, cb, args, kwargs)) - - - def publish(self, service_name, ann, signing_key): - self.published_announcements.append(Announcement(service_name, ann, signing_key)) - - - -@attr.s -class Subscription(object): - """ - A model of an introducer subscription. - """ - service_name = attr.ib() - cb = attr.ib() - args = attr.ib() - kwargs = attr.ib() - - - -@attr.s -class Announcement(object): - """ - A model of an introducer announcement. - """ - service_name = attr.ib() - ann = attr.ib() - signing_key = attr.ib() - - - -def get_published_announcements(client): - """ - Get a flattened list of the latest announcements sent using all introducer - clients. - - Only the most recent announcement for any particular service name is acted - on so these are the only announcements returned. - """ - return { - announcement.service_name: announcement - for introducer_client - in client.introducer_clients - # Visit the announcements in the order they were sent. The last one - # will win in the dictionary we construct. - for announcement - in introducer_client.published_announcements - }.values() - - - def matches_dummy_announcement(name, value): """ Matches the portion of an announcement for the ``DummyStorage`` storage From 6b7e0dd7005e38fca6b306b5d551cad14072abb9 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Thu, 27 Jun 2019 14:51:41 -0400 Subject: [PATCH 48/96] add a test for the negative case --- src/allmydata/client.py | 1 + src/allmydata/test/test_storage_client.py | 77 ++++++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 878bf3a09..82e1be168 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -80,6 +80,7 @@ _client_config = configutil.ValidConfiguration( "shares.needed", "shares.total", "stats_gatherer.furl", + "storage.plugins", ), "drop_upload": ( # deprecated already? "enabled", diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index fbd4fa199..a70ba462f 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -1,20 +1,39 @@ import hashlib from mock import Mock +from fixtures import ( + TempDir, +) + from twisted.application.service import ( Service, ) from twisted.trial import unittest from twisted.internet.defer import succeed, inlineCallbacks +from twisted.python.filepath import ( + FilePath, +) from foolscap.api import ( Tub, ) +from .common import ( + SyncTestCase, + UseTestPlugins, + MemoryIntroducerClient, +) from allmydata.util import base32, yamlutil -from allmydata.storage_client import NativeStorageServer -from allmydata.storage_client import StorageFarmBroker +from allmydata.client import ( + config_from_string, + create_client_from_config, +) +from allmydata.storage_client import ( + NativeStorageServer, + StorageFarmBroker, + _NullStorage, +) from allmydata.interfaces import ( IConnectionStatus, ) @@ -148,6 +167,60 @@ class UnrecognizedAnnouncement(unittest.TestCase): server.get_nickname() +class PluginMatchedAnnouncement(SyncTestCase): + """ + Tests for handling by ``NativeStorageServer`` of storage server + announcements that are handled by an ``IFoolscapStoragePlugin``. + """ + def setUp(self): + super(PluginMatchedAnnouncement, self).setUp() + tempdir = TempDir() + self.useFixture(tempdir) + self.basedir = FilePath(tempdir.path) + self.basedir.child(u"private").makedirs() + self.useFixture(UseTestPlugins()) + + @inlineCallbacks + def test_ignored_non_enabled_plugin(self): + """ + An announcement that could be matched by a plugin that is not enabled is + not matched. + """ + config = config_from_string( + self.basedir.asTextMode().path, + u"tub.port", +""" +[client] +introducer.furl = pb://tubid@example.invalid/swissnum +storage.plugins = tahoe-lafs-dummy-v1 +""", + ) + node = yield create_client_from_config( + config, + introducer_factory=MemoryIntroducerClient, + ) + [introducer_client] = node.introducer_clients + server_id = b"v0-abcdef" + ann = { + u"service-name": u"storage", + # notice how the announcement is for a different storage plugin + # than the one that is enabled. + u"name": u"tahoe-lafs-dummy-v2", + } + for subscription in introducer_client.subscribed_to: + if subscription.service_name == u"storage": + subscription.cb( + server_id, + ann, + *subscription.args, + **subscription.kwargs + ) + + storage_broker = node.get_storage_broker() + native_storage_server = storage_broker.servers[server_id] + self.assertIsInstance(native_storage_server._storage, _NullStorage) + + class TestStorageFarmBroker(unittest.TestCase): def test_static_servers(self): From 7e9e380912dcedbdfa86e102c36a8f153edb8671 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 08:10:57 -0400 Subject: [PATCH 49/96] adjust to the changed parameter name --- src/allmydata/test/test_storage_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index a70ba462f..66ba4a1fa 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -197,7 +197,7 @@ storage.plugins = tahoe-lafs-dummy-v1 ) node = yield create_client_from_config( config, - introducer_factory=MemoryIntroducerClient, + _introducer_factory=MemoryIntroducerClient, ) [introducer_client] = node.introducer_clients server_id = b"v0-abcdef" From 09acde41b90dfc0d753a7c05aca0e057122fca00 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 08:51:42 -0400 Subject: [PATCH 50/96] refactor test_ignored_non_enabled_plugin to support more tests --- src/allmydata/test/test_storage_client.py | 51 +++++++++++++---------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index 66ba4a1fa..e205a7f35 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -172,6 +172,7 @@ class PluginMatchedAnnouncement(SyncTestCase): Tests for handling by ``NativeStorageServer`` of storage server announcements that are handled by an ``IFoolscapStoragePlugin``. """ + @inlineCallbacks def setUp(self): super(PluginMatchedAnnouncement, self).setUp() tempdir = TempDir() @@ -180,13 +181,7 @@ class PluginMatchedAnnouncement(SyncTestCase): self.basedir.child(u"private").makedirs() self.useFixture(UseTestPlugins()) - @inlineCallbacks - def test_ignored_non_enabled_plugin(self): - """ - An announcement that could be matched by a plugin that is not enabled is - not matched. - """ - config = config_from_string( + self.config = config_from_string( self.basedir.asTextMode().path, u"tub.port", """ @@ -195,11 +190,32 @@ introducer.furl = pb://tubid@example.invalid/swissnum storage.plugins = tahoe-lafs-dummy-v1 """, ) - node = yield create_client_from_config( - config, + self.node = yield create_client_from_config( + self.config, _introducer_factory=MemoryIntroducerClient, ) - [introducer_client] = node.introducer_clients + [self.introducer_client] = self.node.introducer_clients + + def publish(self, server_id, announcement): + for subscription in self.introducer_client.subscribed_to: + if subscription.service_name == u"storage": + subscription.cb( + server_id, + announcement, + *subscription.args, + **subscription.kwargs + ) + + def get_storage(self, server_id, node): + storage_broker = node.get_storage_broker() + native_storage_server = storage_broker.servers[server_id] + return native_storage_server._storage + + def test_ignored_non_enabled_plugin(self): + """ + An announcement that could be matched by a plugin that is not enabled is + not matched. + """ server_id = b"v0-abcdef" ann = { u"service-name": u"storage", @@ -207,18 +223,9 @@ storage.plugins = tahoe-lafs-dummy-v1 # than the one that is enabled. u"name": u"tahoe-lafs-dummy-v2", } - for subscription in introducer_client.subscribed_to: - if subscription.service_name == u"storage": - subscription.cb( - server_id, - ann, - *subscription.args, - **subscription.kwargs - ) - - storage_broker = node.get_storage_broker() - native_storage_server = storage_broker.servers[server_id] - self.assertIsInstance(native_storage_server._storage, _NullStorage) + self.publish(server_id, ann) + storage = self.get_storage(server_id, self.node) + self.assertIsInstance(storage, _NullStorage) class TestStorageFarmBroker(unittest.TestCase): From f3218e6f6218d4a34fb40e5f6211004e69f2d3bb Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 08:52:17 -0400 Subject: [PATCH 51/96] basic positive path test --- src/allmydata/test/storage_plugin.py | 9 ++++++++- src/allmydata/test/test_storage_client.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/allmydata/test/storage_plugin.py b/src/allmydata/test/storage_plugin.py index 3091de2f7..ae390f177 100644 --- a/src/allmydata/test/storage_plugin.py +++ b/src/allmydata/test/storage_plugin.py @@ -19,6 +19,7 @@ from foolscap.api import ( from allmydata.interfaces import ( IFoolscapStoragePlugin, + IStorageServer, ) from allmydata.client import ( AnnounceableStorageServer, @@ -56,7 +57,7 @@ class DummyStorage(object): def get_storage_client(self, configuration, announcement): - pass + return DummyStorageClient() @@ -68,3 +69,9 @@ class DummyStorageServer(object): def remote_just_some_method(self): pass + + +@implementer(IStorageServer) +@attr.s +class DummyStorageClient(object): + pass diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index e205a7f35..f10d203ef 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -24,6 +24,9 @@ from .common import ( UseTestPlugins, MemoryIntroducerClient, ) +from .storage_plugin import ( + DummyStorageClient, +) from allmydata.util import base32, yamlutil from allmydata.client import ( config_from_string, @@ -227,6 +230,20 @@ storage.plugins = tahoe-lafs-dummy-v1 storage = self.get_storage(server_id, self.node) self.assertIsInstance(storage, _NullStorage) + def test_enabled_plugin(self): + """ + An announcement that could be matched by a plugin that is enabled is + matched and the plugin's storage client is used. + """ + server_id = b"v0-abcdef" + ann = { + u"service-name": u"storage", + u"name": u"tahoe-lafs-dummy-v1", + } + self.publish(server_id, ann) + storage = self.get_storage(server_id, self.node) + self.assertIsInstance(storage, DummyStorageClient) + class TestStorageFarmBroker(unittest.TestCase): From 6e3cd2d91c32c85c53743b5197b5d85a245d40e9 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 14:22:28 -0400 Subject: [PATCH 52/96] Reflect announcement changes from ticket:3119 --- src/allmydata/test/test_storage_client.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index f10d203ef..7bffc3e18 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -222,9 +222,11 @@ storage.plugins = tahoe-lafs-dummy-v1 server_id = b"v0-abcdef" ann = { u"service-name": u"storage", - # notice how the announcement is for a different storage plugin - # than the one that is enabled. - u"name": u"tahoe-lafs-dummy-v2", + u"storage-options": [{ + # notice how the announcement is for a different storage plugin + # than the one that is enabled. + u"name": u"tahoe-lafs-dummy-v2", + }], } self.publish(server_id, ann) storage = self.get_storage(server_id, self.node) @@ -238,7 +240,10 @@ storage.plugins = tahoe-lafs-dummy-v1 server_id = b"v0-abcdef" ann = { u"service-name": u"storage", - u"name": u"tahoe-lafs-dummy-v1", + u"storage-options": [{ + # and this announcement is for a plugin with a matching name + u"name": u"tahoe-lafs-dummy-v1", + }], } self.publish(server_id, ann) storage = self.get_storage(server_id, self.node) From b5a2c70a4a404208eba79ade834c0eb9f9b8b2b9 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 14:24:58 -0400 Subject: [PATCH 53/96] create a StorageClientConfig object Make it easier to pass more storage configuration down into StorageFarmBroker and beyond --- src/allmydata/client.py | 7 ++++--- src/allmydata/storage_client.py | 27 +++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 82e1be168..3ece6cc78 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -536,8 +536,9 @@ def create_storage_farm_broker(config, default_connection_handlers, foolscap_con :param list introducer_clients: IntroducerClient instances if we're connecting to any """ - ps = config.get_config("client", "peers.preferred", "").split(",") - preferred_peers = tuple([p.strip() for p in ps if p != ""]) + storage_client_config = storage_client.StorageClientConfig.from_node_config( + config, + ) def tub_creator(handler_overrides=None, **kwargs): return node.create_tub( @@ -551,7 +552,7 @@ def create_storage_farm_broker(config, default_connection_handlers, foolscap_con sb = storage_client.StorageFarmBroker( permute_peers=True, tub_maker=tub_creator, - preferred_peers=preferred_peers, + storage_client_config=storage_client_config, ) for ic in introducer_clients: sb.use_introducer(ic) diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 93ee5c97d..68eb10aaf 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -69,7 +69,17 @@ from allmydata.util.hashutil import permute_server_hash # look like? # don't pass signatures: only pass validated blessed-objects +@attr.s +class StorageClientConfig(object): + preferred_peers = attr.ib(default=()) + @classmethod + def from_node_config(cls, config): + ps = config.get_config("client", "peers.preferred", "").split(",") + preferred_peers = tuple([p.strip() for p in ps if p != ""]) + return cls( + preferred_peers, + ) @implementer(IStorageBroker) class StorageFarmBroker(service.MultiService): """I live on the client, and know about storage servers. For each server @@ -78,12 +88,25 @@ class StorageFarmBroker(service.MultiService): I'm also responsible for subscribing to the IntroducerClient to find out about new servers as they are announced by the Introducer. """ - def __init__(self, permute_peers, tub_maker, preferred_peers=()): + + @property + def preferred_peers(self): + return self.storage_client_config.preferred_peers + + def __init__( + self, + permute_peers, + tub_maker, + storage_client_config=None, + ): service.MultiService.__init__(self) assert permute_peers # False not implemented yet self.permute_peers = permute_peers self._tub_maker = tub_maker - self.preferred_peers = preferred_peers + + if storage_client_config is None: + storage_client_config = StorageClientConfig() + self.storage_client_config = storage_client_config # self.servers maps serverid -> IServer, and keeps track of all the # storage servers that we've heard about. Each descriptor manages its From bbd1c706e407cd17d9b70b837362394247e5b2ce Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 14:25:50 -0400 Subject: [PATCH 54/96] teach StorageConfigClient to load storage client plugins --- src/allmydata/storage_client.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 68eb10aaf..49c3db078 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -30,10 +30,16 @@ the foolscap-based server implemented in src/allmydata/storage/*.py . import re, time, hashlib +from ConfigParser import ( + NoSectionError, +) import attr from zope.interface import implementer from twisted.internet import defer from twisted.application import service +from twisted.plugin import ( + getPlugins, +) from eliot import ( log_call, ) @@ -46,6 +52,7 @@ from allmydata.interfaces import ( IDisplayableServer, IServer, IStorageServer, + IFoolscapStoragePlugin, ) from allmydata.util import log, base32, connection_status from allmydata.util.assertutil import precondition @@ -72,14 +79,38 @@ from allmydata.util.hashutil import permute_server_hash @attr.s class StorageClientConfig(object): preferred_peers = attr.ib(default=()) + storage_plugins = attr.ib(default=attr.Factory(dict)) + @classmethod def from_node_config(cls, config): ps = config.get_config("client", "peers.preferred", "").split(",") preferred_peers = tuple([p.strip() for p in ps if p != ""]) + enabled_storage_plugins = ( + name.strip() + for name + in config.get_config( + b"client", + b"storage.plugins", + b"", + ).decode("utf-8").split(u",") + if name.strip() + ) + + storage_plugins = {} + for plugin_name in enabled_storage_plugins: + try: + plugin_config = config.items(b"storageclient.plugins." + plugin_name) + except NoSectionError: + plugin_config = {} + storage_plugins[plugin_name] = plugin_config + return cls( preferred_peers, + storage_plugins, ) + + @implementer(IStorageBroker) class StorageFarmBroker(service.MultiService): """I live on the client, and know about storage servers. For each server From 2e0e9f0cadaf35c7091e9a2d6e8345f818b0e229 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 14:26:50 -0400 Subject: [PATCH 55/96] remove duplication of NativeStorageServer instantiation --- src/allmydata/storage_client.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 49c3db078..994a56060 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -193,7 +193,7 @@ class StorageFarmBroker(service.MultiService): # these two are used in unit tests def test_add_rref(self, serverid, rref, ann): - s = NativeStorageServer(serverid, ann.copy(), self._tub_maker, {}) + s = self._make_storage_server(serverid, {"ann": ann.copy()}) s._rref = rref s._is_connected = True self.servers[serverid] = s @@ -234,8 +234,10 @@ class StorageFarmBroker(service.MultiService): facility="tahoe.storage_broker", umid="AlxzqA", level=log.UNUSUAL) return - s = NativeStorageServer(server_id, ann, self._tub_maker, {}) - s.on_status_changed(lambda _: self._got_connection()) + s = self._make_storage_server( + server_id.decode("utf-8"), + {u"ann": ann}, + ) server_id = s.get_serverid() old = self.servers.get(server_id) if old: From 3c3ebc368a7382cd5b5fbeca0854953a62a49006 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 14:27:03 -0400 Subject: [PATCH 56/96] note about some logging we should do --- src/allmydata/storage_client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 994a56060..8f1efa14f 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -159,6 +159,12 @@ class StorageFarmBroker(service.MultiService): try: storage_server = self._make_storage_server(server_id, server) except Exception: + # TODO: The _make_storage_server failure is logged but maybe + # we should write a traceback here. Notably, tests don't + # automatically fail just because we hit this case. Well + # written tests will still fail if a surprising exception + # arrives here but they might be harder to debug without this + # information. pass else: self._static_server_ids.add(server_id) From 48b8bd6eb0f49f66c8d5b5e21ce314f372748bde Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 14:27:47 -0400 Subject: [PATCH 57/96] pass config down and use it to make the client plugin --- src/allmydata/storage_client.py | 58 +++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 8f1efa14f..020faa23a 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -181,8 +181,13 @@ class StorageFarmBroker(service.MultiService): assert isinstance(server_id, unicode) # from YAML server_id = server_id.encode("ascii") handler_overrides = server.get("connections", {}) - s = NativeStorageServer(server_id, server["ann"], - self._tub_maker, handler_overrides) + s = NativeStorageServer( + server_id, + server["ann"], + self._tub_maker, + handler_overrides, + self.storage_client_config, + ) s.on_status_changed(lambda _: self._got_connection()) return s @@ -451,6 +456,38 @@ class NonReconnector(object): _null_storage = _NullStorage() +class AnnouncementNotMatched(Exception): + """ + A storage server announcement wasn't matched by any of the locally enabled + plugins. + """ + + +def _storage_from_plugin(config, announcement): + """ + Construct an ``IStorageServer`` from the most locally-preferred plugin + that is offered in the given announcement. + """ + plugins = { + plugin.name: plugin + for plugin + in getPlugins(IFoolscapStoragePlugin) + } + storage_options = announcement.get(u"storage-options", []) + for plugin_name, plugin_config in config.storage_plugins.items(): + try: + plugin = plugins[plugin_name] + except KeyError: + raise ValueError("{} not installed".format(plugin_name)) + for option in storage_options: + if plugin_name == option[u"name"]: + return plugin.get_storage_client( + plugin_config, + option, + ) + raise AnnouncementNotMatched() + + @implementer(IServer) class NativeStorageServer(service.MultiService): """I hold information about a storage server that we want to connect to. @@ -479,7 +516,7 @@ class NativeStorageServer(service.MultiService): "application-version": "unknown: no get_version()", } - def __init__(self, server_id, ann, tub_maker, handler_overrides): + def __init__(self, server_id, ann, tub_maker, handler_overrides, config=StorageClientConfig()): service.MultiService.__init__(self) assert isinstance(server_id, str) self._server_id = server_id @@ -487,7 +524,7 @@ class NativeStorageServer(service.MultiService): self._tub_maker = tub_maker self._handler_overrides = handler_overrides - self._init_from_announcement(ann) + self._init_from_announcement(config, ann) self.last_connect_time = None self.last_loss_time = None @@ -498,10 +535,17 @@ class NativeStorageServer(service.MultiService): self._trigger_cb = None self._on_status_changed = ObserverList() - def _init_from_announcement(self, ann): + def _init_from_announcement(self, config, ann): + """ + :param StorageClientConfig config: Configuration specifying desired + storage client behavior. + """ storage = _null_storage - if "anonymous-storage-FURL" in ann: - storage = _AnonymousStorage.from_announcement(self._server_id, ann) + try: + storage = _storage_from_plugin(config, ann) + except AnnouncementNotMatched: + if "anonymous-storage-FURL" in ann: + storage = _AnonymousStorage.from_announcement(self._server_id, ann) self._storage = storage def get_permutation_seed(self): From c752fc76f14493a66c254f0b1721d92482d91e7e Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 14:28:22 -0400 Subject: [PATCH 58/96] pass the new config to StorageFarmBroker --- src/allmydata/test/test_client.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index 169efe229..eb73b72ef 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -41,7 +41,10 @@ import allmydata.util.log from allmydata.node import OldConfigError, OldConfigOptionError, UnescapedHashError, _Config, create_node_dir from allmydata.frontends.auth import NeedRootcapLookupScheme from allmydata import client -from allmydata.storage_client import StorageFarmBroker +from allmydata.storage_client import ( + StorageClientConfig, + StorageFarmBroker, +) from allmydata.util import ( base32, fileutil, @@ -518,7 +521,11 @@ class Basic(testutil.ReallyEqualMixin, testutil.NonASCIIPathMixin, unittest.Test self.failUnlessReallyEqual(self._permute(sb, "one"), []) def test_permute_with_preferred(self): - sb = StorageFarmBroker(True, None, preferred_peers=['1','4']) + sb = StorageFarmBroker( + True, + None, + StorageClientConfig(preferred_peers=['1','4']), + ) for k in ["%d" % i for i in range(5)]: ann = {"anonymous-storage-FURL": "pb://abcde@nowhere/fake", "permutation-seed-base32": base32.b2a(k) } From 166c5ab53f7efa51bd65218ba46bbdde89715f2b Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 14:28:32 -0400 Subject: [PATCH 59/96] there is only a NativeStorageServer though it is used by the client --- src/allmydata/storage_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 020faa23a..7e94a7520 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -218,7 +218,7 @@ class StorageFarmBroker(service.MultiService): ic.subscribe_to("storage", self._got_announcement) def _got_connection(self): - # this is called by NativeStorageClient when it is connected + # this is called by NativeStorageServer when it is connected self._check_connected_high_water_mark() def _check_connected_high_water_mark(self): From 11418a9f878e6bbea7dd65a9c572c4a06514ceb4 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 28 Jun 2019 14:50:46 -0400 Subject: [PATCH 60/96] Fix test_add_rref users by making them supply coherent values ... of the right type --- src/allmydata/storage_client.py | 5 ++++- src/allmydata/test/mutable/util.py | 8 +++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 7e94a7520..1e9b5fba8 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -204,7 +204,10 @@ class StorageFarmBroker(service.MultiService): # these two are used in unit tests def test_add_rref(self, serverid, rref, ann): - s = self._make_storage_server(serverid, {"ann": ann.copy()}) + s = self._make_storage_server( + serverid.decode("ascii"), + {"ann": ann.copy()}, + ) s._rref = rref s._is_connected = True self.servers[serverid] = s diff --git a/src/allmydata/test/mutable/util.py b/src/allmydata/test/mutable/util.py index 89d2269f3..115bc3b77 100644 --- a/src/allmydata/test/mutable/util.py +++ b/src/allmydata/test/mutable/util.py @@ -219,10 +219,12 @@ def make_peer(s, i): :rtype: ``Peer`` """ - peerid = tagged_hash("peerid", "%d" % i)[:20] + peerid = base32.b2a(tagged_hash("peerid", "%d" % i)[:20]) fss = FakeStorageServer(peerid, s) - ann = {"anonymous-storage-FURL": "pb://%s@nowhere/fake" % base32.b2a(peerid), - "permutation-seed-base32": base32.b2a(peerid) } + ann = { + "anonymous-storage-FURL": "pb://%s@nowhere/fake" % (peerid,), + "permutation-seed-base32": peerid, + } return Peer(peerid=peerid, storage_server=fss, announcement=ann) From bee3ee8ff155f52806efc12f00e2ded76a438614 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 2 Jul 2019 10:05:02 -0400 Subject: [PATCH 61/96] docstrings --- src/allmydata/storage_client.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 1e9b5fba8..e731bb5a1 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -78,11 +78,29 @@ from allmydata.util.hashutil import permute_server_hash @attr.s class StorageClientConfig(object): + """ + Configuration for a node acting as a storage client. + + :ivar preferred_peers: An iterable of the server-ids of the storage + servers where share placement is preferred, in order of decreasing + preference. See the *[client]peers.preferred* documentation for + details. + + :ivar dict[unicode, dict[bytes, bytes]] storage_plugins: A mapping from + names of ``IFoolscapStoragePlugin`` configured in *tahoe.cfg* to the + respective configuration. + """ preferred_peers = attr.ib(default=()) storage_plugins = attr.ib(default=attr.Factory(dict)) @classmethod def from_node_config(cls, config): + """ + Create a ``StorageClientConfig`` from a complete Tahoe-LAFS node + configuration. + + :param _Config config: The loaded Tahoe-LAFS node configuration. + """ ps = config.get_config("client", "peers.preferred", "").split(",") preferred_peers = tuple([p.strip() for p in ps if p != ""]) From 9743a1ab4e1de7f268b22cfdc5f16dc39828768d Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 2 Jul 2019 10:07:21 -0400 Subject: [PATCH 62/96] docstring --- src/allmydata/storage_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index e731bb5a1..4508816f7 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -136,6 +136,9 @@ class StorageFarmBroker(service.MultiService): remember enough information to establish a connection to it on demand. I'm also responsible for subscribing to the IntroducerClient to find out about new servers as they are announced by the Introducer. + + :ivar StorageClientConfig storage_client_config: Values from the node + configuration file relating to storage behavior. """ @property From 59546944ce08c4cfbba5f9c9fc88aa081a50146f Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 2 Jul 2019 14:27:19 -0400 Subject: [PATCH 63/96] Factor duplicate furl value out and add it where needed --- src/allmydata/test/test_storage_client.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index 7bffc3e18..372475de7 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -41,6 +41,7 @@ from allmydata.interfaces import ( IConnectionStatus, ) +SOME_FURL = b"pb://abcde@nowhere/fake" class NativeStorageServerWithVersion(NativeStorageServer): def __init__(self, version): @@ -187,11 +188,11 @@ class PluginMatchedAnnouncement(SyncTestCase): self.config = config_from_string( self.basedir.asTextMode().path, u"tub.port", -""" +b""" [client] -introducer.furl = pb://tubid@example.invalid/swissnum +introducer.furl = {furl} storage.plugins = tahoe-lafs-dummy-v1 -""", +""".format(furl=SOME_FURL), ) self.node = yield create_client_from_config( self.config, @@ -226,6 +227,7 @@ storage.plugins = tahoe-lafs-dummy-v1 # notice how the announcement is for a different storage plugin # than the one that is enabled. u"name": u"tahoe-lafs-dummy-v2", + u"storage-server-FURL": SOME_FURL.decode("ascii"), }], } self.publish(server_id, ann) @@ -243,6 +245,7 @@ storage.plugins = tahoe-lafs-dummy-v1 u"storage-options": [{ # and this announcement is for a plugin with a matching name u"name": u"tahoe-lafs-dummy-v1", + u"storage-server-FURL": SOME_FURL.decode("ascii"), }], } self.publish(server_id, ann) @@ -256,13 +259,13 @@ class TestStorageFarmBroker(unittest.TestCase): broker = StorageFarmBroker(True, lambda h: Mock()) key_s = 'v0-1234-1' - servers_yaml = """\ + servers_yaml = b"""\ storage: v0-1234-1: ann: - anonymous-storage-FURL: pb://ge@nowhere/fake + anonymous-storage-FURL: {furl} permutation-seed-base32: aaaaaaaaaaaaaaaaaaaaaaaa -""" +""".format(furl=SOME_FURL) servers = yamlutil.safe_load(servers_yaml) permseed = base32.a2b("aaaaaaaaaaaaaaaaaaaaaaaa") broker.set_static_servers(servers["storage"]) @@ -291,7 +294,7 @@ storage: server_id = "v0-4uazse3xb6uu5qpkb7tel2bm6bpea4jhuigdhqcuvvse7hugtsia" k = "4uazse3xb6uu5qpkb7tel2bm6bpea4jhuigdhqcuvvse7hugtsia" ann = { - "anonymous-storage-FURL": "pb://abcde@nowhere/fake", + "anonymous-storage-FURL": SOME_FURL, } broker.set_static_servers({server_id.decode("ascii"): {"ann": ann}}) s = broker.servers[server_id] @@ -302,7 +305,7 @@ storage: server_id = "v0-4uazse3xb6uu5qpkb7tel2bm6bpea4jhuigdhqcuvvse7hugtsia" k = "w5gl5igiexhwmftwzhai5jy2jixn7yx7" ann = { - "anonymous-storage-FURL": "pb://abcde@nowhere/fake", + "anonymous-storage-FURL": SOME_FURL, "permutation-seed-base32": k, } broker.set_static_servers({server_id.decode("ascii"): {"ann": ann}}) @@ -313,7 +316,7 @@ storage: broker = StorageFarmBroker(True, lambda h: Mock()) server_id = "unparseable" ann = { - "anonymous-storage-FURL": "pb://abcde@nowhere/fake", + "anonymous-storage-FURL": SOME_FURL, } broker.set_static_servers({server_id.decode("ascii"): {"ann": ann}}) s = broker.servers[server_id] From 2616c66a4947d67a4bf1bde28baa897f5caba0d9 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 2 Jul 2019 14:57:20 -0400 Subject: [PATCH 64/96] Fix confusion between IStorageServer and the thing above it IStorageServer is what uses a connection. You need a thing above it to _get_ a connection. --- src/allmydata/storage_client.py | 134 ++++++++++++++++++---- src/allmydata/test/test_storage_client.py | 46 +++++++- 2 files changed, 156 insertions(+), 24 deletions(-) diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 4508816f7..2a9cf62d7 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -34,7 +34,11 @@ from ConfigParser import ( NoSectionError, ) import attr -from zope.interface import implementer +from zope.interface import ( + Attribute, + Interface, + implementer, +) from twisted.internet import defer from twisted.application import service from twisted.plugin import ( @@ -369,19 +373,70 @@ class StubServer(object): return "?" -@attr.s(frozen=True) -class _AnonymousStorage(object): +class IFoolscapStorageServer(Interface): """ - Abstraction for connecting to an anonymous storage server. + An internal interface that mediates between ``NativeStorageServer`` and + Foolscap-based ``IStorageServer`` implementations. + """ + nickname = Attribute(""" + A name for this server for presentation to users. + """) + permutation_seed = Attribute(""" + A stable value associated with this server which a client can use as an + input to the server selection permutation ordering. + """) + tubid = Attribute(""" + The identifier for the Tub in which the server is run. + """) + storage_server = Attribute(""" + An IStorageServer provide which implements a concrete Foolscap-based + protocol for communicating with the server. + """) + name = Attribute(""" + Another name for this server for presentation to users. + """) + longname = Attribute(""" + *Another* name for this server for presentation to users. + """) + lease_seed = Attribute(""" + A stable value associated with this server which a client can use as an + input to a lease secret generation function. + """) + + def connect_to(tub, got_connection): + """ + Attempt to establish and maintain a connection to the server. + + :param Tub tub: A Foolscap Tub from which the connection is to + originate. + + :param got_connection: A one-argument callable which is called with a + Foolscap ``RemoteReference`` when a connection is established. + This may be called multiple times if the connection is lost and + then re-established. + + :return foolscap.reconnector.Reconnector: An object which manages the + connection and reconnection attempts. + """ + + +@implementer(IFoolscapStorageServer) +@attr.s(frozen=True) +class _FoolscapStorage(object): + """ + Abstraction for connecting to a storage server exposed via Foolscap. """ nickname = attr.ib() permutation_seed = attr.ib() tubid = attr.ib() + storage_server = attr.ib() + _furl = attr.ib() _short_description = attr.ib() _long_description = attr.ib() + @property def name(self): return self._short_description @@ -395,18 +450,16 @@ class _AnonymousStorage(object): return self.tubid @classmethod - def from_announcement(cls, server_id, ann): + def from_announcement(cls, server_id, furl, ann, storage_server): """ - Create an instance from an announcement like:: + Create an instance from a fURL and an announcement like:: - {"anonymous-storage-FURL": "pb://...@...", - "permutation-seed-base32": "...", + {"permutation-seed-base32": "...", "nickname": "...", } *nickname* is optional. """ - furl = str(ann["anonymous-storage-FURL"]) m = re.match(r'pb://(\w+)@', furl) assert m, furl tubid_s = m.group(1).lower() @@ -437,8 +490,9 @@ class _AnonymousStorage(object): return cls( nickname=nickname, permutation_seed=permutation_seed, - furl=furl, tubid=tubid, + storage_server=storage_server, + furl=furl, short_description=short_description, long_description=long_description, ) @@ -447,6 +501,7 @@ class _AnonymousStorage(object): return tub.connectTo(self._furl, got_connection) +@implementer(IFoolscapStorageServer) class _NullStorage(object): """ Abstraction for *not* communicating with a storage server of a type with @@ -455,6 +510,8 @@ class _NullStorage(object): nickname = "" permutation_seed = hashlib.sha256("").digest() tubid = hashlib.sha256("").digest() + storage_server = None + lease_seed = hashlib.sha256("").digest() name = "" @@ -505,7 +562,8 @@ def _storage_from_plugin(config, announcement): raise ValueError("{} not installed".format(plugin_name)) for option in storage_options: if plugin_name == option[u"name"]: - return plugin.get_storage_client( + furl = option[u"storage-server-FURL"] + return furl, plugin.get_storage_client( plugin_config, option, ) @@ -548,7 +606,7 @@ class NativeStorageServer(service.MultiService): self._tub_maker = tub_maker self._handler_overrides = handler_overrides - self._init_from_announcement(config, ann) + self._storage = self._make_storage_system(config, ann) self.last_connect_time = None self.last_loss_time = None @@ -559,18 +617,52 @@ class NativeStorageServer(service.MultiService): self._trigger_cb = None self._on_status_changed = ObserverList() - def _init_from_announcement(self, config, ann): + def _make_storage_system(self, config, ann): """ :param StorageClientConfig config: Configuration specifying desired storage client behavior. + + :param dict ann: The storage announcement from the storage server we + are meant to communicate with. + + :return IFoolscapStorageServer: An object enabling communication via + Foolscap with the server which generated the announcement. """ - storage = _null_storage + # Try to match the announcement against a plugin. try: - storage = _storage_from_plugin(config, ann) + furl, storage_server = _storage_from_plugin(config, ann) except AnnouncementNotMatched: - if "anonymous-storage-FURL" in ann: - storage = _AnonymousStorage.from_announcement(self._server_id, ann) - self._storage = storage + # Nope. + pass + else: + return _FoolscapStorage.from_announcement( + self._server_id, + furl.encode("utf-8"), + ann, + storage_server, + ) + + # Try to match the announcement against the anonymous access scheme. + try: + furl = ann[u"anonymous-storage-FURL"] + except KeyError: + # Nope + pass + else: + # Pass in an accessor for our _rref attribute. The value of the + # attribute may change over time as connections are lost and + # re-established. The _StorageServer should always be able to get + # the most up-to-date value. + storage_server = _StorageServer(get_rref=self.get_rref) + return _FoolscapStorage.from_announcement( + self._server_id, + furl.encode("utf-8"), + ann, + storage_server, + ) + + # Nothing matched so we can't talk to this server. + return _null_storage def get_permutation_seed(self): return self._storage.permutation_seed @@ -678,11 +770,7 @@ class NativeStorageServer(service.MultiService): """ if self._rref is None: return None - # Pass in an accessor for our _rref attribute. The value of the - # attribute may change over time as connections are lost and - # re-established. The _StorageServer should always be able to get the - # most up-to-date value. - return _StorageServer(get_rref=self.get_rref) + return self._storage.storage_server def _lost(self): log.msg(format="lost connection to %(name)s", name=self.get_name(), diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index 372475de7..0cb25270a 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -5,6 +5,10 @@ from fixtures import ( TempDir, ) +from zope.interface.verify import ( + verifyObject, +) + from twisted.application.service import ( Service, ) @@ -33,8 +37,10 @@ from allmydata.client import ( create_client_from_config, ) from allmydata.storage_client import ( + IFoolscapStorageServer, NativeStorageServer, StorageFarmBroker, + _FoolscapStorage, _NullStorage, ) from allmydata.interfaces import ( @@ -250,7 +256,45 @@ storage.plugins = tahoe-lafs-dummy-v1 } self.publish(server_id, ann) storage = self.get_storage(server_id, self.node) - self.assertIsInstance(storage, DummyStorageClient) + self.assertTrue( + verifyObject( + IFoolscapStorageServer, + storage, + ), + ) + self.assertIsInstance(storage.storage_server, DummyStorageClient) + + +class FoolscapStorageServers(unittest.TestCase): + """ + Tests for implementations of ``IFoolscapStorageServer``. + """ + def test_null_provider(self): + """ + Instances of ``_NullStorage`` provide ``IFoolscapStorageServer``. + """ + self.assertTrue( + verifyObject( + IFoolscapStorageServer, + _NullStorage(), + ), + ) + + def test_foolscap_provider(self): + """ + Instances of ``_FoolscapStorage`` provide ``IFoolscapStorageServer``. + """ + self.assertTrue( + verifyObject( + IFoolscapStorageServer, + _FoolscapStorage.from_announcement( + u"server-id", + SOME_FURL, + {u"permutation-seed-base32": base32.b2a(b"permutationseed")}, + object(), + ), + ), + ) class TestStorageFarmBroker(unittest.TestCase): From 57160f65c67a594cc66b81727c2f9f0a65c25a5a Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 2 Jul 2019 15:46:17 -0400 Subject: [PATCH 65/96] Pass get_rref in to get_storage_client plugins don't otherwise have a way to talk to the server. --- src/allmydata/interfaces.py | 7 ++++++- src/allmydata/storage_client.py | 19 +++++++++++++------ src/allmydata/test/storage_plugin.py | 2 +- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/allmydata/interfaces.py b/src/allmydata/interfaces.py index a1e465018..fa817bff7 100644 --- a/src/allmydata/interfaces.py +++ b/src/allmydata/interfaces.py @@ -3097,7 +3097,7 @@ class IFoolscapStoragePlugin(IPlugin): :rtype: ``Deferred`` firing with ``IAnnounceableStorageServer`` """ - def get_storage_client(configuration, announcement): + def get_storage_client(configuration, announcement, get_rref): """ Get an ``IStorageServer`` provider that implements the client side of the storage protocol. @@ -3109,6 +3109,11 @@ class IFoolscapStoragePlugin(IPlugin): server portion of this plugin received from a storage server which is offering it. + :param get_rref: A no-argument callable which returns a + ``foolscap.referenceable.RemoteReference`` which refers to the + server portion of this plugin on the currently active connection, + or ``None`` if no connection has been established yet. + :rtype: ``Deferred`` firing with ``IStorageServer`` """ diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 2a9cf62d7..b99ec1088 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -544,7 +544,7 @@ class AnnouncementNotMatched(Exception): """ -def _storage_from_plugin(config, announcement): +def _storage_from_foolscap_plugin(config, announcement, get_rref): """ Construct an ``IStorageServer`` from the most locally-preferred plugin that is offered in the given announcement. @@ -566,6 +566,7 @@ def _storage_from_plugin(config, announcement): return furl, plugin.get_storage_client( plugin_config, option, + get_rref, ) raise AnnouncementNotMatched() @@ -630,7 +631,15 @@ class NativeStorageServer(service.MultiService): """ # Try to match the announcement against a plugin. try: - furl, storage_server = _storage_from_plugin(config, ann) + furl, storage_server = _storage_from_foolscap_plugin( + config, + ann, + # Pass in an accessor for our _rref attribute. The value of + # the attribute may change over time as connections are lost + # and re-established. The _StorageServer should always be + # able to get the most up-to-date value. + self.get_rref, + ) except AnnouncementNotMatched: # Nope. pass @@ -649,10 +658,8 @@ class NativeStorageServer(service.MultiService): # Nope pass else: - # Pass in an accessor for our _rref attribute. The value of the - # attribute may change over time as connections are lost and - # re-established. The _StorageServer should always be able to get - # the most up-to-date value. + # See comment above for the _storage_from_foolscap_plugin case + # about passing in get_rref. storage_server = _StorageServer(get_rref=self.get_rref) return _FoolscapStorage.from_announcement( self._server_id, diff --git a/src/allmydata/test/storage_plugin.py b/src/allmydata/test/storage_plugin.py index ae390f177..ad7dca6c3 100644 --- a/src/allmydata/test/storage_plugin.py +++ b/src/allmydata/test/storage_plugin.py @@ -56,7 +56,7 @@ class DummyStorage(object): ) - def get_storage_client(self, configuration, announcement): + def get_storage_client(self, configuration, announcement, get_rref): return DummyStorageClient() From 7e685c4fd3b07ca48a3ed89d3fcbe7081ca9c94f Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 3 Jul 2019 11:01:31 -0400 Subject: [PATCH 66/96] this typo, so much design flaw in english --- newsfragments/3054.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/newsfragments/3054.feature b/newsfragments/3054.feature index 5042829b3..2193bffbb 100644 --- a/newsfragments/3054.feature +++ b/newsfragments/3054.feature @@ -1 +1 @@ -Storage clients can not be configured to load plugins for allmydata.interfaces.IFoolscapStoragePlugin and use them to negotiate with servers. +Storage clients can now be configured to load plugins for allmydata.interfaces.IFoolscapStoragePlugin and use them to negotiate with servers. From 311afa8a75f113e2ed52ccc0fbd51acb9084f9ef Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 3 Jul 2019 12:08:58 -0400 Subject: [PATCH 67/96] Test & fix supplying plugin configuration --- src/allmydata/client.py | 5 +- src/allmydata/storage_client.py | 4 +- src/allmydata/test/storage_plugin.py | 6 +- src/allmydata/test/test_storage_client.py | 137 ++++++++++++++++++++-- 4 files changed, 135 insertions(+), 17 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 3ece6cc78..1e866757a 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -65,7 +65,10 @@ def _is_valid_section(section_name): Currently considers all possible storage server plugin sections valid. """ - return section_name.startswith("storageserver.plugins.") + return ( + section_name.startswith(b"storageserver.plugins.") or + section_name.startswith(b"storageclient.plugins.") + ) _client_config = configutil.ValidConfiguration( diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index b99ec1088..01c3ab8f5 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -124,8 +124,8 @@ class StorageClientConfig(object): try: plugin_config = config.items(b"storageclient.plugins." + plugin_name) except NoSectionError: - plugin_config = {} - storage_plugins[plugin_name] = plugin_config + plugin_config = [] + storage_plugins[plugin_name] = dict(plugin_config) return cls( preferred_peers, diff --git a/src/allmydata/test/storage_plugin.py b/src/allmydata/test/storage_plugin.py index ad7dca6c3..df3706651 100644 --- a/src/allmydata/test/storage_plugin.py +++ b/src/allmydata/test/storage_plugin.py @@ -57,7 +57,7 @@ class DummyStorage(object): def get_storage_client(self, configuration, announcement, get_rref): - return DummyStorageClient() + return DummyStorageClient(get_rref, configuration, announcement) @@ -74,4 +74,6 @@ class DummyStorageServer(object): @implementer(IStorageServer) @attr.s class DummyStorageClient(object): - pass + get_rref = attr.ib() + configuration = attr.ib() + announcement = attr.ib() diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index 0cb25270a..88e5af568 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -4,6 +4,14 @@ from mock import Mock from fixtures import ( TempDir, ) +from testtools.matchers import ( + MatchesAll, + IsInstance, + MatchesStructure, + Equals, + Is, + AfterPreprocessing, +) from zope.interface.verify import ( verifyObject, @@ -183,22 +191,53 @@ class PluginMatchedAnnouncement(SyncTestCase): announcements that are handled by an ``IFoolscapStoragePlugin``. """ @inlineCallbacks - def setUp(self): - super(PluginMatchedAnnouncement, self).setUp() + def make_node(self, introducer_furl, storage_plugin, plugin_config): + """ + Create a client node with the given configuration. + + :param bytes introducer_furl: The introducer furl with which to + configure the client. + + :param bytes storage_plugin: The name of a storage plugin to enable. + + :param dict[bytes, bytes] plugin_config: Configuration to supply to + the enabled plugin. May also be ``None`` for no configuration + section (distinct from ``{}`` which creates an empty configuration + section). + """ tempdir = TempDir() self.useFixture(tempdir) self.basedir = FilePath(tempdir.path) self.basedir.child(u"private").makedirs() self.useFixture(UseTestPlugins()) + if plugin_config is None: + plugin_config_section = b"" + else: + plugin_config_section = b""" +[storageclient.plugins.{storage_plugin}] +{config} +""".format( + storage_plugin=storage_plugin, + config=b"\n".join( + b" = ".join((key, value)) + for (key, value) + in plugin_config.items() + )) + self.config = config_from_string( self.basedir.asTextMode().path, u"tub.port", b""" [client] introducer.furl = {furl} -storage.plugins = tahoe-lafs-dummy-v1 -""".format(furl=SOME_FURL), +storage.plugins = {storage_plugin} +{plugin_config_section} +""".format( + furl=introducer_furl, + storage_plugin=storage_plugin, + plugin_config_section=plugin_config_section, +) ) self.node = yield create_client_from_config( self.config, @@ -206,8 +245,8 @@ storage.plugins = tahoe-lafs-dummy-v1 ) [self.introducer_client] = self.node.introducer_clients - def publish(self, server_id, announcement): - for subscription in self.introducer_client.subscribed_to: + def publish(self, server_id, announcement, introducer_client): + for subscription in introducer_client.subscribed_to: if subscription.service_name == u"storage": subscription.cb( server_id, @@ -221,11 +260,22 @@ storage.plugins = tahoe-lafs-dummy-v1 native_storage_server = storage_broker.servers[server_id] return native_storage_server._storage + def set_rref(self, server_id, node, rref): + storage_broker = node.get_storage_broker() + native_storage_server = storage_broker.servers[server_id] + native_storage_server._rref = rref + + @inlineCallbacks def test_ignored_non_enabled_plugin(self): """ An announcement that could be matched by a plugin that is not enabled is not matched. """ + yield self.make_node( + introducer_furl=SOME_FURL, + storage_plugin=b"tahoe-lafs-dummy-v1", + plugin_config=None, + ) server_id = b"v0-abcdef" ann = { u"service-name": u"storage", @@ -236,25 +286,35 @@ storage.plugins = tahoe-lafs-dummy-v1 u"storage-server-FURL": SOME_FURL.decode("ascii"), }], } - self.publish(server_id, ann) + self.publish(server_id, ann, self.introducer_client) storage = self.get_storage(server_id, self.node) self.assertIsInstance(storage, _NullStorage) + @inlineCallbacks def test_enabled_plugin(self): """ - An announcement that could be matched by a plugin that is enabled is - matched and the plugin's storage client is used. + An announcement that could be matched by a plugin that is enabled with + configuration is matched and the plugin's storage client is used. """ + plugin_config = { + b"abc": b"xyz", + } + plugin_name = b"tahoe-lafs-dummy-v1" + yield self.make_node( + introducer_furl=SOME_FURL, + storage_plugin=plugin_name, + plugin_config=plugin_config, + ) server_id = b"v0-abcdef" ann = { u"service-name": u"storage", u"storage-options": [{ # and this announcement is for a plugin with a matching name - u"name": u"tahoe-lafs-dummy-v1", + u"name": plugin_name, u"storage-server-FURL": SOME_FURL.decode("ascii"), }], } - self.publish(server_id, ann) + self.publish(server_id, ann, self.introducer_client) storage = self.get_storage(server_id, self.node) self.assertTrue( verifyObject( @@ -262,7 +322,60 @@ storage.plugins = tahoe-lafs-dummy-v1 storage, ), ) - self.assertIsInstance(storage.storage_server, DummyStorageClient) + expected_rref = object() + # Can't easily establish a real Foolscap connection so fake the result + # of doing so... + self.set_rref(server_id, self.node, expected_rref) + self.expectThat( + storage.storage_server, + MatchesAll( + IsInstance(DummyStorageClient), + MatchesStructure( + get_rref=AfterPreprocessing( + lambda get_rref: get_rref(), + Is(expected_rref), + ), + configuration=Equals(plugin_config), + announcement=Equals({ + u'name': plugin_name, + u'storage-server-FURL': u'pb://abcde@nowhere/fake', + }), + ), + ), + ) + + @inlineCallbacks + def test_enabled_no_configuration_plugin(self): + """ + An announcement that could be matched by a plugin that is enabled with no + configuration is matched and the plugin's storage client is used. + """ + plugin_name = b"tahoe-lafs-dummy-v1" + yield self.make_node( + introducer_furl=SOME_FURL, + storage_plugin=plugin_name, + plugin_config=None, + ) + server_id = b"v0-abcdef" + ann = { + u"service-name": u"storage", + u"storage-options": [{ + # and this announcement is for a plugin with a matching name + u"name": plugin_name, + u"storage-server-FURL": SOME_FURL.decode("ascii"), + }], + } + self.publish(server_id, ann, self.introducer_client) + storage = self.get_storage(server_id, self.node) + self.expectThat( + storage.storage_server, + MatchesAll( + IsInstance(DummyStorageClient), + MatchesStructure( + configuration=Equals({}), + ), + ), + ) class FoolscapStorageServers(unittest.TestCase): From 95b2f6cfb539b861f5cfc6010994652142f95414 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 3 Jul 2019 13:57:55 -0400 Subject: [PATCH 68/96] news fragment --- newsfragments/3184.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/3184.feature diff --git a/newsfragments/3184.feature b/newsfragments/3184.feature new file mode 100644 index 000000000..92767a4f2 --- /dev/null +++ b/newsfragments/3184.feature @@ -0,0 +1 @@ +The [storage] configuration section now accepts a boolean *anonymous* item to enable or disable anonymous storage access. The default behavior remains unchanged. From b50e20b58c1862462d96df121fcb42b99ea24bf0 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 3 Jul 2019 13:58:00 -0400 Subject: [PATCH 69/96] document a new configuration option --- docs/configuration.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/configuration.rst b/docs/configuration.rst index 9b5d12097..02a97d2b8 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -739,6 +739,17 @@ Storage Server Configuration for clients who do not wish to provide storage service. The default value is ``True``. +``anonymous = (boolean, optional)`` + + If this is ``True``, the node will expose the storage server via Foolscap + without any additional authentication or authorization. The capability to + use all storage services is conferred by knowledge of the Foolscap fURL + for the storage server which will be included in the storage server's + announcement. If it is ``False``, the node will not expose this and + storage must be exposed using the storage server plugin system (see + `Storage Server Plugin Configuration`_ for details). The default value is + ``True``. + ``readonly = (boolean, optional)`` If ``True``, the node will run a storage server but will not accept any From 853cf62530e231ef0a3922c9759368aca3d49a48 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 3 Jul 2019 14:30:36 -0400 Subject: [PATCH 70/96] Allow [storage]anonymous through the validator And provide a helpful accessor for reading it --- src/allmydata/client.py | 28 +++++++++++++- src/allmydata/test/test_client.py | 63 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 1e866757a..fbe823204 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -97,6 +97,7 @@ _client_config = configutil.ValidConfiguration( "storage": ( "debug_discard", "enabled", + "anonymous", "expire.cutoff_date", "expire.enabled", "expire.immutable", @@ -619,6 +620,31 @@ def _add_to_announcement(information, announceable_storage_server): ) +def storage_enabled(config): + """ + Is storage enabled according to the given configuration object? + + :param _Config config: The configuration to inspect. + + :return bool: ``True`` if storage is enabled, ``False`` otherwise. + """ + return config.get_config(b"storage", b"enabled", True, boolean=True) + + +def anonymous_storage_enabled(config): + """ + Is anonymous access to storage enabled according to the given + configuration object? + + :param _Config config: The configuration to inspect. + + :return bool: ``True`` if storage is enabled, ``False`` otherwise. + """ + return ( + storage_enabled(config) and + config.get_config(b"storage", b"anonymous", True, boolean=True) + ) + @implementer(IStatsProducer) class _Client(node.Node, pollmixin.PollMixin): @@ -828,7 +854,7 @@ class _Client(node.Node, pollmixin.PollMixin): def init_storage(self, announceable_storage_servers): # should we run a storage server (and publish it for others to use)? - if not self.config.get_config("storage", "enabled", True, boolean=True): + if not storage_enabled(self.config): return if not self._is_tub_listening(): raise ValueError("config error: storage is enabled, but tub " diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index eb73b72ef..80de334b2 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -238,6 +238,69 @@ class Basic(testutil.ReallyEqualMixin, testutil.NonASCIIPathMixin, unittest.Test c = yield client.create_client(basedir) self.failUnless(c.get_long_nodeid().startswith("v0-")) + def test_storage_anonymous_enabled_by_default(self): + """ + Anonymous storage access is enabled if storage is enabled and *anonymous* + is not set. + """ + config = client.config_from_string( + b"test_storage_default_anonymous_enabled", + b"tub.port", + BASECONFIG + ( + b"[storage]\n" + b"enabled = true\n" + ) + ) + self.assertTrue(client.anonymous_storage_enabled(config)) + + def test_storage_anonymous_enabled_explicitly(self): + """ + Anonymous storage access is enabled if storage is enabled and *anonymous* + is set to true. + """ + config = client.config_from_string( + self.id(), + b"tub.port", + BASECONFIG + ( + b"[storage]\n" + b"enabled = true\n" + b"anonymous = true\n" + ) + ) + self.assertTrue(client.anonymous_storage_enabled(config)) + + def test_storage_anonymous_disabled_explicitly(self): + """ + Anonymous storage access is disabled if storage is enabled and *anonymous* + is set to false. + """ + config = client.config_from_string( + self.id(), + b"tub.port", + BASECONFIG + ( + b"[storage]\n" + b"enabled = true\n" + b"anonymous = false\n" + ) + ) + self.assertFalse(client.anonymous_storage_enabled(config)) + + def test_storage_anonymous_disabled_by_storage(self): + """ + Anonymous storage access is disabled if storage is disabled and *anonymous* + is set to true. + """ + config = client.config_from_string( + self.id(), + b"tub.port", + BASECONFIG + ( + b"[storage]\n" + b"enabled = false\n" + b"anonymous = true\n" + ) + ) + self.assertFalse(client.anonymous_storage_enabled(config)) + @defer.inlineCallbacks def test_reserved_1(self): """ From e0157ab1747e82e52335e9d08023878010f59116 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 3 Jul 2019 15:11:06 -0400 Subject: [PATCH 71/96] Give me a tool to match announcements w/o anonymous storage furl --- src/allmydata/test/matchers.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/allmydata/test/matchers.py b/src/allmydata/test/matchers.py index 5e96311a4..c0dc97e48 100644 --- a/src/allmydata/test/matchers.py +++ b/src/allmydata/test/matchers.py @@ -51,14 +51,30 @@ class MatchesNodePublicKey(object): return Mismatch("The signature did not verify.") -def matches_storage_announcement(basedir, options=None): +def matches_storage_announcement(basedir, anonymous=True, options=None): """ - Match an anonymous storage announcement. + Match a storage announcement. + + :param bytes basedir: The path to the node base directory which is + expected to emit the announcement. This is used to determine the key + which is meant to sign the announcement. + + :param bool anonymous: If True, matches a storage announcement containing + an anonymous access fURL. Otherwise, fails to match such an + announcement. + + :param list[matcher]|NoneType options: If a list, matches a storage + announcement containing a list of storage plugin options matching the + elements of the list. If None, fails to match an announcement with + storage plugin options. + + :return: A matcher with the requested behavior. """ announcement = { - u"anonymous-storage-FURL": matches_furl(), u"permutation-seed-base32": matches_base32(), } + if anonymous: + announcement[u"anonymous-storage-FURL"] = matches_furl() if options: announcement[u"storage-options"] = MatchesListwise(options) return MatchesStructure( From 6fd27097a99edbbef1f0906ccb1df6678db6ba6e Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 3 Jul 2019 15:11:28 -0400 Subject: [PATCH 72/96] Factor out some repetition of this dummy value --- src/allmydata/test/test_client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index 80de334b2..43cec13bb 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -67,6 +67,8 @@ from .matchers import ( matches_furl, ) +SOME_FURL = b"pb://abcde@nowhere/fake" + BASECONFIG = ("[client]\n" "introducer.furl = \n" ) @@ -574,7 +576,7 @@ class Basic(testutil.ReallyEqualMixin, testutil.NonASCIIPathMixin, unittest.Test def test_permute(self): sb = StorageFarmBroker(True, None) for k in ["%d" % i for i in range(5)]: - ann = {"anonymous-storage-FURL": "pb://abcde@nowhere/fake", + ann = {"anonymous-storage-FURL": SOME_FURL, "permutation-seed-base32": base32.b2a(k) } sb.test_add_rref(k, "rref", ann) @@ -590,7 +592,7 @@ class Basic(testutil.ReallyEqualMixin, testutil.NonASCIIPathMixin, unittest.Test StorageClientConfig(preferred_peers=['1','4']), ) for k in ["%d" % i for i in range(5)]: - ann = {"anonymous-storage-FURL": "pb://abcde@nowhere/fake", + ann = {"anonymous-storage-FURL": SOME_FURL, "permutation-seed-base32": base32.b2a(k) } sb.test_add_rref(k, "rref", ann) From 9842447a07c4674d7cdb810fb7a639b53b6c501a Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 3 Jul 2019 15:20:42 -0400 Subject: [PATCH 73/96] Don't start or announce anonymous access if config says not to --- src/allmydata/client.py | 19 +++-- src/allmydata/test/test_client.py | 125 ++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 10 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index fbe823204..b86778f72 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -861,33 +861,32 @@ class _Client(node.Node, pollmixin.PollMixin): "is not listening ('tub.port=' is empty)") ss = self.get_anonymous_storage_server() - furl_file = self.config.get_private_path("storage.furl").encode(get_filesystem_encoding()) - furl = self.tub.registerReference(ss, furlFile=furl_file) - - anonymous_announcement = { - "anonymous-storage-FURL": furl, + announcement = { "permutation-seed-base32": self._init_permutation_seed(ss), } + if anonymous_storage_enabled(self.config): + furl_file = self.config.get_private_path("storage.furl").encode(get_filesystem_encoding()) + furl = self.tub.registerReference(ss, furlFile=furl_file) + announcement["anonymous-storage-FURL"] = furl + enabled_storage_servers = self._enable_storage_servers( announceable_storage_servers, ) - plugins_announcement = {} storage_options = list( storage_server.announcement for storage_server in enabled_storage_servers ) + plugins_announcement = {} if storage_options: # Only add the new key if there are any plugins enabled. plugins_announcement[u"storage-options"] = storage_options - total_announcement = {} - total_announcement.update(anonymous_announcement) - total_announcement.update(plugins_announcement) + announcement.update(plugins_announcement) for ic in self.introducer_clients: - ic.publish("storage", total_announcement, self._node_private_key) + ic.publish("storage", announcement, self._node_private_key) def _enable_storage_servers(self, announceable_storage_servers): diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index 43cec13bb..6b03b278a 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -28,6 +28,8 @@ from testtools.matchers import ( MatchesListwise, MatchesDict, Always, + Is, + raises, ) from testtools.twistedsupport import ( succeeded, @@ -757,6 +759,129 @@ def flush_but_dont_ignore(res): return d +class AnonymousStorage(SyncTestCase): + """ + Tests for behaviors of the client object with respect to the anonymous + storage service. + """ + @defer.inlineCallbacks + def test_anonymous_storage_enabled(self): + """ + If anonymous storage access is enabled then the client announces it. + """ + basedir = self.id() + os.makedirs(basedir + b"/private") + config = client.config_from_string( + basedir, + b"tub.port", + BASECONFIG_I % (SOME_FURL,) + ( + b"[storage]\n" + b"enabled = true\n" + b"anonymous = true\n" + ) + ) + node = yield client.create_client_from_config( + config, + _introducer_factory=MemoryIntroducerClient, + ) + self.assertThat( + get_published_announcements(node), + MatchesListwise([ + matches_storage_announcement( + basedir, + anonymous=True, + ), + ]), + ) + + @defer.inlineCallbacks + def test_anonymous_storage_disabled(self): + """ + If anonymous storage access is disabled then the client does not announce + it nor does it write a fURL for it to beneath the node directory. + """ + basedir = self.id() + os.makedirs(basedir + b"/private") + config = client.config_from_string( + basedir, + b"tub.port", + BASECONFIG_I % (SOME_FURL,) + ( + b"[storage]\n" + b"enabled = true\n" + b"anonymous = false\n" + ) + ) + node = yield client.create_client_from_config( + config, + _introducer_factory=MemoryIntroducerClient, + ) + self.expectThat( + get_published_announcements(node), + MatchesListwise([ + matches_storage_announcement( + basedir, + anonymous=False, + ), + ]), + ) + self.expectThat( + config.get_private_config(b"storage.furl", default=None), + Is(None), + ) + + @defer.inlineCallbacks + def test_anonymous_storage_enabled_then_disabled(self): + """ + If a node is run with anonymous storage enabled and then later anonymous + storage is disabled in the configuration for that node, it is not + possible to reach the anonymous storage server via the originally + published fURL. + """ + basedir = self.id() + os.makedirs(basedir + b"/private") + enabled_config = client.config_from_string( + basedir, + b"tub.port", + BASECONFIG_I % (SOME_FURL,) + ( + b"[storage]\n" + b"enabled = true\n" + b"anonymous = true\n" + ) + ) + node = yield client.create_client_from_config( + enabled_config, + _introducer_factory=MemoryIntroducerClient, + ) + anonymous_storage_furl = enabled_config.get_private_config(b"storage.furl") + def check_furl(): + return node.tub.getReferenceForURL(anonymous_storage_furl) + # Perform a sanity check that our test code makes sense: is this a + # legit way to verify whether a fURL will refer to an object? + self.assertThat( + check_furl(), + # If it doesn't raise a KeyError we're in business. + Always(), + ) + + disabled_config = client.config_from_string( + basedir, + b"tub.port", + BASECONFIG_I % (SOME_FURL,) + ( + b"[storage]\n" + b"enabled = true\n" + b"anonymous = false\n" + ) + ) + node = yield client.create_client_from_config( + disabled_config, + _introducer_factory=MemoryIntroducerClient, + ) + self.assertThat( + check_furl, + raises(KeyError), + ) + + class IntroducerClients(unittest.TestCase): def test_invalid_introducer_furl(self): From 375f9176078e6ab9759ddc709dccfe96fb358f72 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 5 Jul 2019 08:48:14 -0400 Subject: [PATCH 74/96] Be explicit that we expect to be operating on bytes here --- src/allmydata/storage_client.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 01c3ab8f5..72d7cfd8a 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -85,10 +85,10 @@ class StorageClientConfig(object): """ Configuration for a node acting as a storage client. - :ivar preferred_peers: An iterable of the server-ids of the storage - servers where share placement is preferred, in order of decreasing - preference. See the *[client]peers.preferred* documentation for - details. + :ivar preferred_peers: An iterable of the server-ids (``bytes``) of the + storage servers where share placement is preferred, in order of + decreasing preference. See the *[client]peers.preferred* + documentation for details. :ivar dict[unicode, dict[bytes, bytes]] storage_plugins: A mapping from names of ``IFoolscapStoragePlugin`` configured in *tahoe.cfg* to the @@ -105,8 +105,8 @@ class StorageClientConfig(object): :param _Config config: The loaded Tahoe-LAFS node configuration. """ - ps = config.get_config("client", "peers.preferred", "").split(",") - preferred_peers = tuple([p.strip() for p in ps if p != ""]) + ps = config.get_config("client", "peers.preferred", b"").split(b",") + preferred_peers = tuple([p.strip() for p in ps if p != b""]) enabled_storage_plugins = ( name.strip() From 326e5829b03aeef32b2d93cb2b36fe511986adb8 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 24 Jul 2019 14:51:48 -0400 Subject: [PATCH 75/96] Add a basic test for the existence of any plugin-supplied resource --- src/allmydata/test/common.py | 70 ++++++++++++++ src/allmydata/test/test_storage_client.py | 110 ++++++++++++++-------- 2 files changed, 143 insertions(+), 37 deletions(-) diff --git a/src/allmydata/test/common.py b/src/allmydata/test/common.py index 0be9f45f7..33b2192cc 100644 --- a/src/allmydata/test/common.py +++ b/src/allmydata/test/common.py @@ -74,6 +74,10 @@ from allmydata.util.assertutil import precondition from allmydata.util.consumer import download_to_data import allmydata.test.common_util as testutil from allmydata.immutable.upload import Uploader +from allmydata.client import ( + config_from_string, + create_client_from_config, +) from ..crypto import ( ed25519, @@ -193,6 +197,72 @@ class UseTestPlugins(object): +@attr.s +class UseNode(object): + """ + A fixture which creates a client node. + """ + plugin_config = attr.ib() + storage_plugin = attr.ib() + basedir = attr.ib() + introducer_furl = attr.ib() + node_config = attr.ib(default=attr.Factory(dict)) + + config = attr.ib(default=None) + + def setUp(self): + def format_config_items(config): + return b"\n".join( + b" = ".join((key, value)) + for (key, value) + in config.items() + ) + + if self.plugin_config is None: + plugin_config_section = b"" + else: + plugin_config_section = b""" +[storageclient.plugins.{storage_plugin}] +{config} +""".format( + storage_plugin=self.storage_plugin, + config=format_config_items(self.plugin_config), +) + + self.config = config_from_string( + self.basedir.asTextMode().path, + u"tub.port", +b""" +[node] +{node_config} + +[client] +introducer.furl = {furl} +storage.plugins = {storage_plugin} +{plugin_config_section} +""".format( + furl=self.introducer_furl, + storage_plugin=self.storage_plugin, + node_config=format_config_items(self.node_config), + plugin_config_section=plugin_config_section, +) + ) + + def create_node(self): + return create_client_from_config( + self.config, + _introducer_factory=MemoryIntroducerClient, + ) + + def cleanUp(self): + pass + + + def getDetails(self): + return {} + + + @implementer(IPlugin, IStreamServerEndpointStringParser) class AdoptedServerPort(object): """ diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index 88e5af568..7fc10076d 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -33,17 +33,21 @@ from foolscap.api import ( from .common import ( SyncTestCase, + AsyncTestCase, UseTestPlugins, - MemoryIntroducerClient, + UseNode, + SameProcessStreamEndpointAssigner, +) +from .common_web import ( + do_http, ) from .storage_plugin import ( DummyStorageClient, ) -from allmydata.util import base32, yamlutil -from allmydata.client import ( - config_from_string, - create_client_from_config, +from allmydata.webish import ( + WebishServer, ) +from allmydata.util import base32, yamlutil from allmydata.storage_client import ( IFoolscapStorageServer, NativeStorageServer, @@ -185,6 +189,7 @@ class UnrecognizedAnnouncement(unittest.TestCase): server.get_nickname() + class PluginMatchedAnnouncement(SyncTestCase): """ Tests for handling by ``NativeStorageServer`` of storage server @@ -211,40 +216,17 @@ class PluginMatchedAnnouncement(SyncTestCase): self.basedir.child(u"private").makedirs() self.useFixture(UseTestPlugins()) - if plugin_config is None: - plugin_config_section = b"" - else: - plugin_config_section = b""" -[storageclient.plugins.{storage_plugin}] -{config} -""".format( - storage_plugin=storage_plugin, - config=b"\n".join( - b" = ".join((key, value)) - for (key, value) - in plugin_config.items() - )) - - self.config = config_from_string( - self.basedir.asTextMode().path, - u"tub.port", -b""" -[client] -introducer.furl = {furl} -storage.plugins = {storage_plugin} -{plugin_config_section} -""".format( - furl=introducer_furl, - storage_plugin=storage_plugin, - plugin_config_section=plugin_config_section, -) - ) - self.node = yield create_client_from_config( - self.config, - _introducer_factory=MemoryIntroducerClient, - ) + self.node_fixture = self.useFixture(UseNode( + plugin_config, + storage_plugin, + self.basedir, + introducer_furl, + )) + self.config = self.node_fixture.config + self.node = yield self.node_fixture.create_node() [self.introducer_client] = self.node.introducer_clients + def publish(self, server_id, announcement, introducer_client): for subscription in introducer_client.subscribed_to: if subscription.service_name == u"storage": @@ -410,6 +392,60 @@ class FoolscapStorageServers(unittest.TestCase): ) + +class StoragePluginWebPresence(AsyncTestCase): + """ + Tests for the web resources ``IFoolscapStorageServer`` plugins may expose. + """ + @inlineCallbacks + def setUp(self): + super(StoragePluginWebPresence, self).setUp() + + self.useFixture(UseTestPlugins()) + + self.port_assigner = SameProcessStreamEndpointAssigner() + self.port_assigner.setUp() + self.addCleanup(self.port_assigner.tearDown) + self.storage_plugin = b"tahoe-lafs-dummy-v1" + + from twisted.internet import reactor + _, port_endpoint = self.port_assigner.assign(reactor) + + tempdir = TempDir() + self.useFixture(tempdir) + self.basedir = FilePath(tempdir.path) + self.basedir.child(u"private").makedirs() + self.node_fixture = self.useFixture(UseNode( + plugin_config={ + b"web": b"1", + }, + node_config={ + b"tub.location": b"127.0.0.1:1", + b"web.port": port_endpoint, + }, + storage_plugin=self.storage_plugin, + basedir=self.basedir, + introducer_furl=SOME_FURL, + )) + self.node = yield self.node_fixture.create_node() + self.webish = self.node.getServiceNamed(WebishServer.name) + self.node.startService() + self.port = self.webish.getPortnum() + + @inlineCallbacks + def test_plugin_resource_path(self): + """ + The plugin's resource is published at */storage-plugins/*. + """ + url = "http://127.0.0.1:{port}/storage-plugins/{plugin_name}".format( + port=self.port, + plugin_name=self.storage_plugin, + ) + # As long as it doesn't raise an exception, the test is a success. + yield do_http(b"get", url) + + + class TestStorageFarmBroker(unittest.TestCase): def test_static_servers(self): From d69de156645f51e6ea8539ae22f5704be1d7a6a4 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 24 Jul 2019 15:37:24 -0400 Subject: [PATCH 76/96] implement the feature improve the test slightly, too, to verify the configuration supplied to the plugin is as expected. --- src/allmydata/client.py | 10 +++++++ src/allmydata/interfaces.py | 11 ++++++++ src/allmydata/storage_client.py | 18 ++++++++++++ src/allmydata/test/storage_plugin.py | 16 ++++++++++- src/allmydata/test/test_storage_client.py | 10 +++++-- src/allmydata/web/storage_plugins.py | 34 +++++++++++++++++++++++ src/allmydata/webish.py | 6 ++++ 7 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 src/allmydata/web/storage_plugins.py diff --git a/src/allmydata/client.py b/src/allmydata/client.py index b86778f72..1d0a8931d 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -889,6 +889,16 @@ class _Client(node.Node, pollmixin.PollMixin): ic.publish("storage", announcement, self._node_private_key) + def get_client_storage_plugin_web_resources(self): + """ + Get all of the client-side ``IResource`` implementations provided by + enabled storage plugins. + + :return dict[bytes, IResource provider]: The implementations. + """ + return self.storage_broker.get_client_storage_plugin_web_resources() + + def _enable_storage_servers(self, announceable_storage_servers): """ Register and announce the given storage servers. diff --git a/src/allmydata/interfaces.py b/src/allmydata/interfaces.py index fa817bff7..012eb53e2 100644 --- a/src/allmydata/interfaces.py +++ b/src/allmydata/interfaces.py @@ -3117,6 +3117,17 @@ class IFoolscapStoragePlugin(IPlugin): :rtype: ``Deferred`` firing with ``IStorageServer`` """ + def get_client_resource(configuration): + """ + Get an ``IResource`` that can be published in the Tahoe-LAFS web interface + to expose information related to this plugin. + + :param dict configuration: Any configuration given in the section for + this plugin in the node's configuration file. + + :rtype: ``IResource`` + """ + class IAnnounceableStorageServer(Interface): announcement = Attribute( diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 72d7cfd8a..43276a026 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -197,6 +197,24 @@ class StorageFarmBroker(service.MultiService): storage_server.setServiceParent(self) storage_server.start_connecting(self._trigger_connections) + + def get_client_storage_plugin_web_resources(self): + """ + Get all of the client-side ``IResource`` implementations provided by + enabled storage plugins. + """ + plugins = { + plugin.name: plugin + for plugin + in getPlugins(IFoolscapStoragePlugin) + } + return { + name: plugins[name].get_client_resource(config) + for (name, config) + in self.storage_client_config.storage_plugins.items() + } + + @log_call( action_type=u"storage-client:broker:make-storage-server", include_args=["server_id"], diff --git a/src/allmydata/test/storage_plugin.py b/src/allmydata/test/storage_plugin.py index df3706651..c1fc9d349 100644 --- a/src/allmydata/test/storage_plugin.py +++ b/src/allmydata/test/storage_plugin.py @@ -3,6 +3,10 @@ A storage server plugin the test suite can use to validate the functionality. """ +from json import ( + dumps, +) + import attr from zope.interface import ( @@ -12,7 +16,9 @@ from zope.interface import ( from twisted.internet.defer import ( succeed, ) - +from twisted.web.static import ( + Data, +) from foolscap.api import ( RemoteInterface, ) @@ -60,6 +66,14 @@ class DummyStorage(object): return DummyStorageClient(get_rref, configuration, announcement) + def get_client_resource(self, configuration): + """ + :return: A static data resource that produces the given configuration when + rendered, as an aid to testing. + """ + return Data(dumps(configuration), b"text/json") + + @implementer(RIDummy) @attr.s(cmp=True, hash=True) diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index 7fc10076d..c389451fe 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -1,6 +1,8 @@ import hashlib from mock import Mock - +from json import ( + dumps, +) from fixtures import ( TempDir, ) @@ -430,8 +432,10 @@ class StoragePluginWebPresence(AsyncTestCase): self.node = yield self.node_fixture.create_node() self.webish = self.node.getServiceNamed(WebishServer.name) self.node.startService() + self.addCleanup(self.node.stopService) self.port = self.webish.getPortnum() + @inlineCallbacks def test_plugin_resource_path(self): """ @@ -441,8 +445,8 @@ class StoragePluginWebPresence(AsyncTestCase): port=self.port, plugin_name=self.storage_plugin, ) - # As long as it doesn't raise an exception, the test is a success. - yield do_http(b"get", url) + result = yield do_http(b"get", url) + self.assertThat(result, Equals(dumps({b"web": b"1"}))) diff --git a/src/allmydata/web/storage_plugins.py b/src/allmydata/web/storage_plugins.py new file mode 100644 index 000000000..c9c0ec4b6 --- /dev/null +++ b/src/allmydata/web/storage_plugins.py @@ -0,0 +1,34 @@ +""" +This module implements a resource which has as children the web resources +of all enabled storage client plugins. +""" + +from twisted.web.resource import ( + Resource, + NoResource, +) + +class StoragePlugins(Resource): + """ + The parent resource of all enabled storage client plugins' web resources. + """ + def __init__(self, client): + """ + :param _Client client: The Tahoe-LAFS client node object which will be + used to find the storage plugin web resources. + """ + Resource.__init__(self) + self._client = client + + def getChild(self, segment, request): + """ + Get an ``IResource`` from the loaded, enabled plugin with a name that + equals ``segment``. + + :see: ``twisted.web.iweb.IResource.getChild`` + """ + resources = self._client.get_client_storage_plugin_web_resources() + try: + return resources[segment] + except KeyError: + return NoResource() diff --git a/src/allmydata/webish.py b/src/allmydata/webish.py index c661e1886..cbf66c5a9 100644 --- a/src/allmydata/webish.py +++ b/src/allmydata/webish.py @@ -12,6 +12,10 @@ from allmydata.util import log, fileutil from allmydata.web import introweb, root from allmydata.web.common import IOpHandleTable, MyExceptionHandler +from .web.storage_plugins import ( + StoragePlugins, +) + # we must override twisted.web.http.Request.requestReceived with a version # that doesn't use cgi.parse_multipart() . Since we actually use Nevow, we # override the nevow-specific subclass, nevow.appserver.NevowRequest . This @@ -168,6 +172,8 @@ class WebishServer(service.MultiService): self.site.remember(self.root.child_operations, IOpHandleTable) self.root.child_operations.setServiceParent(self) + self.root.putChild("storage-plugins", StoragePlugins(client)) + def buildServer(self, webport, nodeurl_path, staticdir): self.webport = webport self.site = site = appserver.NevowSite(self.root) From 3152a35618401b1e9a212d34d341a78f16d0723f Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 2 Aug 2019 11:22:45 -0600 Subject: [PATCH 77/96] Some additional documentation --- src/allmydata/test/common.py | 15 +++++++++++++++ src/allmydata/test/test_storage_client.py | 4 ++-- src/allmydata/webish.py | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/allmydata/test/common.py b/src/allmydata/test/common.py index 33b2192cc..2d1fe2ba0 100644 --- a/src/allmydata/test/common.py +++ b/src/allmydata/test/common.py @@ -201,6 +201,21 @@ class UseTestPlugins(object): class UseNode(object): """ A fixture which creates a client node. + + :ivar dict[bytes, bytes] plugin_config: Configuration items to put in the + node's configuration. + + :ivar bytes storage_plugin: The name of a storage plugin to enable. + + :ivar FilePath basedir: The base directory of the node. + + :ivar bytes introducer_furl: The introducer furl with which to + configure the client. + + :ivar dict[bytes, bytes] node_config: Configuration items for the *node* + section of the configuration. + + :ivar _Config config: The complete resulting configuration. """ plugin_config = attr.ib() storage_plugin = attr.ib() diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index c389451fe..77083620a 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -441,10 +441,10 @@ class StoragePluginWebPresence(AsyncTestCase): """ The plugin's resource is published at */storage-plugins/*. """ - url = "http://127.0.0.1:{port}/storage-plugins/{plugin_name}".format( + url = u"http://127.0.0.1:{port}/storage-plugins/{plugin_name}".format( port=self.port, plugin_name=self.storage_plugin, - ) + ).encode("utf-8") result = yield do_http(b"get", url) self.assertThat(result, Equals(dumps({b"web": b"1"}))) diff --git a/src/allmydata/webish.py b/src/allmydata/webish.py index cbf66c5a9..84bd89d4c 100644 --- a/src/allmydata/webish.py +++ b/src/allmydata/webish.py @@ -172,7 +172,7 @@ class WebishServer(service.MultiService): self.site.remember(self.root.child_operations, IOpHandleTable) self.root.child_operations.setServiceParent(self) - self.root.putChild("storage-plugins", StoragePlugins(client)) + self.root.putChild(b"storage-plugins", StoragePlugins(client)) def buildServer(self, webport, nodeurl_path, staticdir): self.webport = webport From 21d735ece99cc7f926ae292ec675ba723bd39288 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 2 Aug 2019 15:05:25 -0600 Subject: [PATCH 78/96] whitespace more conforming to PEP8 --- src/allmydata/client.py | 4 ---- src/allmydata/storage_client.py | 2 -- src/allmydata/test/common.py | 7 ------- src/allmydata/test/storage_plugin.py | 2 -- src/allmydata/test/test_storage_client.py | 3 --- 5 files changed, 18 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 1d0a8931d..1001b10c8 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -888,7 +888,6 @@ class _Client(node.Node, pollmixin.PollMixin): for ic in self.introducer_clients: ic.publish("storage", announcement, self._node_private_key) - def get_client_storage_plugin_web_resources(self): """ Get all of the client-side ``IResource`` implementations provided by @@ -898,7 +897,6 @@ class _Client(node.Node, pollmixin.PollMixin): """ return self.storage_broker.get_client_storage_plugin_web_resources() - def _enable_storage_servers(self, announceable_storage_servers): """ Register and announce the given storage servers. @@ -906,7 +904,6 @@ class _Client(node.Node, pollmixin.PollMixin): for announceable in announceable_storage_servers: yield self._enable_storage_server(announceable) - def _enable_storage_server(self, announceable_storage_server): """ Register a storage server. @@ -927,7 +924,6 @@ class _Client(node.Node, pollmixin.PollMixin): ) return announceable_storage_server - def init_client(self): helper_furl = self.config.get_config("client", "helper.furl", None) if helper_furl in ("None", ""): diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 43276a026..ef148a4c1 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -197,7 +197,6 @@ class StorageFarmBroker(service.MultiService): storage_server.setServiceParent(self) storage_server.start_connecting(self._trigger_connections) - def get_client_storage_plugin_web_resources(self): """ Get all of the client-side ``IResource`` implementations provided by @@ -214,7 +213,6 @@ class StorageFarmBroker(service.MultiService): in self.storage_client_config.storage_plugins.items() } - @log_call( action_type=u"storage-client:broker:make-storage-server", include_args=["server_id"], diff --git a/src/allmydata/test/common.py b/src/allmydata/test/common.py index 2d1fe2ba0..ef1f95303 100644 --- a/src/allmydata/test/common.py +++ b/src/allmydata/test/common.py @@ -124,7 +124,6 @@ class MemoryIntroducerClient(object): )) - @attr.s class Subscription(object): """ @@ -136,7 +135,6 @@ class Subscription(object): kwargs = attr.ib() - @attr.s class Announcement(object): """ @@ -151,7 +149,6 @@ class Announcement(object): return ed25519.signing_keypair_from_string(self.signing_key_bytes)[0] - def get_published_announcements(client): """ Get a flattened list of all announcements sent using all introducer @@ -166,7 +163,6 @@ def get_published_announcements(client): ) - class UseTestPlugins(object): """ A fixture which enables loading Twisted plugins from the Tahoe-LAFS test @@ -181,7 +177,6 @@ class UseTestPlugins(object): testplugins = FilePath(__file__).sibling("plugins") twisted.plugins.__path__.insert(0, testplugins.path) - def cleanUp(self): """ Remove the testing package ``plugins`` directory from the @@ -191,12 +186,10 @@ class UseTestPlugins(object): testplugins = FilePath(__file__).sibling("plugins") twisted.plugins.__path__.remove(testplugins.path) - def getDetails(self): return {} - @attr.s class UseNode(object): """ diff --git a/src/allmydata/test/storage_plugin.py b/src/allmydata/test/storage_plugin.py index c1fc9d349..ef10ebb30 100644 --- a/src/allmydata/test/storage_plugin.py +++ b/src/allmydata/test/storage_plugin.py @@ -61,11 +61,9 @@ class DummyStorage(object): ), ) - def get_storage_client(self, configuration, announcement, get_rref): return DummyStorageClient(get_rref, configuration, announcement) - def get_client_resource(self, configuration): """ :return: A static data resource that produces the given configuration when diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index 77083620a..b59b93347 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -394,7 +394,6 @@ class FoolscapStorageServers(unittest.TestCase): ) - class StoragePluginWebPresence(AsyncTestCase): """ Tests for the web resources ``IFoolscapStorageServer`` plugins may expose. @@ -435,7 +434,6 @@ class StoragePluginWebPresence(AsyncTestCase): self.addCleanup(self.node.stopService) self.port = self.webish.getPortnum() - @inlineCallbacks def test_plugin_resource_path(self): """ @@ -449,7 +447,6 @@ class StoragePluginWebPresence(AsyncTestCase): self.assertThat(result, Equals(dumps({b"web": b"1"}))) - class TestStorageFarmBroker(unittest.TestCase): def test_static_servers(self): From e66ffacc9e45c8421d5af0305af4f75db334c1de Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Sat, 3 Aug 2019 06:28:38 -0400 Subject: [PATCH 79/96] a docstring for the matcher's match method --- src/allmydata/test/matchers.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/allmydata/test/matchers.py b/src/allmydata/test/matchers.py index c0dc97e48..eaf7d13a5 100644 --- a/src/allmydata/test/matchers.py +++ b/src/allmydata/test/matchers.py @@ -40,6 +40,16 @@ class MatchesNodePublicKey(object): basedir = attr.ib() def match(self, other): + """ + Match a private key which is the same as the private key in the node at + ``self.basedir``. + + :param other: A signing key (aka "private key") from + ``allmydata.crypto.ed25519``. This is the key to check against + the node's key. + + :return Mismatch: If the keys don't match. + """ config = read_config(self.basedir, u"tub.port") privkey_bytes = config.get_private_config("node.privkey") private_key = ed25519.signing_keypair_from_string(privkey_bytes)[0] From 724acede4d5ec9116e7b99bf01eb21f60d09e68e Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 19 Aug 2019 11:20:43 -0400 Subject: [PATCH 80/96] news fragment --- newsfragments/3242.minor | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 newsfragments/3242.minor diff --git a/newsfragments/3242.minor b/newsfragments/3242.minor new file mode 100644 index 000000000..e69de29bb From a47463e0325966c8c008b04d5e0624a2c1f3a384 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 19 Aug 2019 11:21:03 -0400 Subject: [PATCH 81/96] Pass _Config instead of a smaller dict to get_client_resource --- src/allmydata/client.py | 4 +++- src/allmydata/interfaces.py | 4 ++-- src/allmydata/node.py | 9 +++++++-- src/allmydata/storage_client.py | 10 ++++++++-- src/allmydata/test/storage_plugin.py | 11 +++++++++-- 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 1001b10c8..7739ea42c 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -895,7 +895,9 @@ class _Client(node.Node, pollmixin.PollMixin): :return dict[bytes, IResource provider]: The implementations. """ - return self.storage_broker.get_client_storage_plugin_web_resources() + return self.storage_broker.get_client_storage_plugin_web_resources( + self.config, + ) def _enable_storage_servers(self, announceable_storage_servers): """ diff --git a/src/allmydata/interfaces.py b/src/allmydata/interfaces.py index 012eb53e2..35a88fe08 100644 --- a/src/allmydata/interfaces.py +++ b/src/allmydata/interfaces.py @@ -3122,8 +3122,8 @@ class IFoolscapStoragePlugin(IPlugin): Get an ``IResource`` that can be published in the Tahoe-LAFS web interface to expose information related to this plugin. - :param dict configuration: Any configuration given in the section for - this plugin in the node's configuration file. + :param allmydata.node._Config configuration: A representation of the + configuration for the node into which this plugin has been loaded. :rtype: ``IResource`` """ diff --git a/src/allmydata/node.py b/src/allmydata/node.py index a480d5926..6b3911d95 100644 --- a/src/allmydata/node.py +++ b/src/allmydata/node.py @@ -292,8 +292,13 @@ class _Config(object): "Unable to write config file '{}'".format(fn), ) - def items(self, section): - return self.config.items(section) + def items(self, section, default=_None): + try: + return self.config.items(section) + except ConfigParser.NoSectionError: + if default is _None: + raise + return default def get_config(self, section, option, default=_None, boolean=False): try: diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index ef148a4c1..84d96cec0 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -197,10 +197,16 @@ class StorageFarmBroker(service.MultiService): storage_server.setServiceParent(self) storage_server.start_connecting(self._trigger_connections) - def get_client_storage_plugin_web_resources(self): + def get_client_storage_plugin_web_resources(self, node_config): """ Get all of the client-side ``IResource`` implementations provided by enabled storage plugins. + + :param allmydata.node._Config node_config: The complete node + configuration for the node from which these web resources will be + served. + + :return dict[unicode, IResource]: Resources for all of the plugins. """ plugins = { plugin.name: plugin @@ -208,7 +214,7 @@ class StorageFarmBroker(service.MultiService): in getPlugins(IFoolscapStoragePlugin) } return { - name: plugins[name].get_client_resource(config) + name: plugins[name].get_client_resource(node_config) for (name, config) in self.storage_client_config.storage_plugins.items() } diff --git a/src/allmydata/test/storage_plugin.py b/src/allmydata/test/storage_plugin.py index ef10ebb30..d78a4d9ec 100644 --- a/src/allmydata/test/storage_plugin.py +++ b/src/allmydata/test/storage_plugin.py @@ -48,6 +48,10 @@ class RIDummy(RemoteInterface): class DummyStorage(object): name = attr.ib() + @property + def _client_section_name(self): + return u"storageclient.plugins.{}".format(self.name) + def get_storage_server(self, configuration, get_anonymous_storage_server): if u"invalid" in configuration: raise Exception("The plugin is unhappy.") @@ -69,8 +73,11 @@ class DummyStorage(object): :return: A static data resource that produces the given configuration when rendered, as an aid to testing. """ - return Data(dumps(configuration), b"text/json") - + items = configuration.items(self._client_section_name, []) + return Data( + dumps(dict(items)), + b"text/json", + ) @implementer(RIDummy) From 972c1c79767b4e3095cd354fd9e737bd6025a862 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 19 Aug 2019 11:26:24 -0400 Subject: [PATCH 82/96] news fragment --- newsfragments/3243.minor | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 newsfragments/3243.minor diff --git a/newsfragments/3243.minor b/newsfragments/3243.minor new file mode 100644 index 000000000..e69de29bb From 4053b6c56f78ea5b64e5efd5d4004382350900a8 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 19 Aug 2019 11:26:32 -0400 Subject: [PATCH 83/96] make it new-style --- src/allmydata/web/storage_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/allmydata/web/storage_plugins.py b/src/allmydata/web/storage_plugins.py index c9c0ec4b6..27562cec5 100644 --- a/src/allmydata/web/storage_plugins.py +++ b/src/allmydata/web/storage_plugins.py @@ -8,7 +8,7 @@ from twisted.web.resource import ( NoResource, ) -class StoragePlugins(Resource): +class StoragePlugins(Resource, object): """ The parent resource of all enabled storage client plugins' web resources. """ From 06d9e34828c9b8365a04a9cffe68a4dfea3dfa99 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 19 Aug 2019 15:09:30 -0400 Subject: [PATCH 84/96] news fragment --- newsfragments/3248.minor | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 newsfragments/3248.minor diff --git a/newsfragments/3248.minor b/newsfragments/3248.minor new file mode 100644 index 000000000..e69de29bb From 64197f4ba4226cdfa6d63736392c9a784d5ccce2 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 19 Aug 2019 15:09:34 -0400 Subject: [PATCH 85/96] Change the interface --- src/allmydata/interfaces.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/allmydata/interfaces.py b/src/allmydata/interfaces.py index 35a88fe08..d4222d85c 100644 --- a/src/allmydata/interfaces.py +++ b/src/allmydata/interfaces.py @@ -3102,8 +3102,8 @@ class IFoolscapStoragePlugin(IPlugin): Get an ``IStorageServer`` provider that implements the client side of the storage protocol. - :param dict configuration: Any configuration given in the section for - this plugin in the node's configuration file. + :param allmydata.node._Config configuration: A representation of the + configuration for the node into which this plugin has been loaded. :param dict announcement: The announcement for the corresponding server portion of this plugin received from a storage server which From 6a9f1ac1f1d96f2daa169353d9a8d18087b1bce8 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 19 Aug 2019 15:11:13 -0400 Subject: [PATCH 86/96] Update test plugin to reflect interface change --- src/allmydata/test/storage_plugin.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/allmydata/test/storage_plugin.py b/src/allmydata/test/storage_plugin.py index d78a4d9ec..9190c7331 100644 --- a/src/allmydata/test/storage_plugin.py +++ b/src/allmydata/test/storage_plugin.py @@ -66,7 +66,11 @@ class DummyStorage(object): ) def get_storage_client(self, configuration, announcement, get_rref): - return DummyStorageClient(get_rref, configuration, announcement) + return DummyStorageClient( + get_rref, + dict(configuration.items(self._client_section_name, [])), + announcement, + ) def get_client_resource(self, configuration): """ From 05be6f2ef11c5dcb894d0241d0c72579fac5cf25 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 19 Aug 2019 15:58:26 -0400 Subject: [PATCH 87/96] news fragment --- newsfragments/3284.minor | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 newsfragments/3284.minor diff --git a/newsfragments/3284.minor b/newsfragments/3284.minor new file mode 100644 index 000000000..e69de29bb From 9940beaae1bdfcc36938792e0031529514c6125b Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Mon, 19 Aug 2019 16:09:26 -0400 Subject: [PATCH 88/96] Thread a _Config all the way down --- src/allmydata/client.py | 1 + src/allmydata/storage_client.py | 21 ++++++++++++++++----- src/allmydata/test/common.py | 6 ++++++ src/allmydata/test/mutable/util.py | 7 +++++-- src/allmydata/test/test_checker.py | 8 ++++++-- src/allmydata/test/test_client.py | 4 +++- src/allmydata/test/test_helper.py | 10 +++++++++- src/allmydata/test/test_storage_client.py | 23 ++++++++++++++++------- src/allmydata/test/test_upload.py | 10 +++++++++- src/allmydata/test/web/test_root.py | 6 +++++- src/allmydata/test/web/test_web.py | 18 ++++++++++++++---- 11 files changed, 90 insertions(+), 24 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 7739ea42c..3e9905821 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -556,6 +556,7 @@ def create_storage_farm_broker(config, default_connection_handlers, foolscap_con sb = storage_client.StorageFarmBroker( permute_peers=True, tub_maker=tub_creator, + node_config=config, storage_client_config=storage_client_config, ) for ic in introducer_clients: diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index 84d96cec0..fc03ae9ae 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -153,6 +153,7 @@ class StorageFarmBroker(service.MultiService): self, permute_peers, tub_maker, + node_config, storage_client_config=None, ): service.MultiService.__init__(self) @@ -160,6 +161,8 @@ class StorageFarmBroker(service.MultiService): self.permute_peers = permute_peers self._tub_maker = tub_maker + self.node_config = node_config + if storage_client_config is None: storage_client_config = StorageClientConfig() self.storage_client_config = storage_client_config @@ -233,6 +236,7 @@ class StorageFarmBroker(service.MultiService): server["ann"], self._tub_maker, handler_overrides, + self.node_config, self.storage_client_config, ) s.on_status_changed(lambda _: self._got_connection()) @@ -566,10 +570,13 @@ class AnnouncementNotMatched(Exception): """ -def _storage_from_foolscap_plugin(config, announcement, get_rref): +def _storage_from_foolscap_plugin(node_config, config, announcement, get_rref): """ Construct an ``IStorageServer`` from the most locally-preferred plugin that is offered in the given announcement. + + :param allmydata.node._Config node_config: The node configuration to + pass to the plugin. """ plugins = { plugin.name: plugin @@ -586,7 +593,7 @@ def _storage_from_foolscap_plugin(config, announcement, get_rref): if plugin_name == option[u"name"]: furl = option[u"storage-server-FURL"] return furl, plugin.get_storage_client( - plugin_config, + node_config, option, get_rref, ) @@ -621,7 +628,7 @@ class NativeStorageServer(service.MultiService): "application-version": "unknown: no get_version()", } - def __init__(self, server_id, ann, tub_maker, handler_overrides, config=StorageClientConfig()): + def __init__(self, server_id, ann, tub_maker, handler_overrides, node_config, config=StorageClientConfig()): service.MultiService.__init__(self) assert isinstance(server_id, str) self._server_id = server_id @@ -629,7 +636,7 @@ class NativeStorageServer(service.MultiService): self._tub_maker = tub_maker self._handler_overrides = handler_overrides - self._storage = self._make_storage_system(config, ann) + self._storage = self._make_storage_system(node_config, config, ann) self.last_connect_time = None self.last_loss_time = None @@ -640,8 +647,11 @@ class NativeStorageServer(service.MultiService): self._trigger_cb = None self._on_status_changed = ObserverList() - def _make_storage_system(self, config, ann): + def _make_storage_system(self, node_config, config, ann): """ + :param allmydata.node._Config node_config: The node configuration to pass + to any configured storage plugins. + :param StorageClientConfig config: Configuration specifying desired storage client behavior. @@ -654,6 +664,7 @@ class NativeStorageServer(service.MultiService): # Try to match the announcement against a plugin. try: furl, storage_server = _storage_from_foolscap_plugin( + node_config, config, ann, # Pass in an accessor for our _rref attribute. The value of diff --git a/src/allmydata/test/common.py b/src/allmydata/test/common.py index ef1f95303..ff31ea939 100644 --- a/src/allmydata/test/common.py +++ b/src/allmydata/test/common.py @@ -89,6 +89,12 @@ from .eliotutil import ( TEST_RSA_KEY_SIZE = 522 +EMPTY_CLIENT_CONFIG = config_from_string( + b"/dev/null", + b"tub.port", + b"" +) + @attr.s class MemoryIntroducerClient(object): diff --git a/src/allmydata/test/mutable/util.py b/src/allmydata/test/mutable/util.py index 115bc3b77..a664c1e08 100644 --- a/src/allmydata/test/mutable/util.py +++ b/src/allmydata/test/mutable/util.py @@ -10,7 +10,10 @@ from allmydata.util.hashutil import tagged_hash from allmydata.storage_client import StorageFarmBroker from allmydata.mutable.layout import MDMFSlotReadProxy from allmydata.mutable.publish import MutableData -from ..common import TEST_RSA_KEY_SIZE +from ..common import ( + TEST_RSA_KEY_SIZE, + EMPTY_CLIENT_CONFIG, +) def eventuaaaaaly(res=None): d = fireEventually(res) @@ -254,7 +257,7 @@ def make_storagebroker_with_peers(peers): :param list peers: The storage servers to associate with the storage broker. """ - storage_broker = StorageFarmBroker(True, None) + storage_broker = StorageFarmBroker(True, None, EMPTY_CLIENT_CONFIG) for peer in peers: storage_broker.test_add_rref( peer.peerid, diff --git a/src/allmydata/test/test_checker.py b/src/allmydata/test/test_checker.py index e1af90940..5eed6f21f 100644 --- a/src/allmydata/test/test_checker.py +++ b/src/allmydata/test/test_checker.py @@ -15,6 +15,10 @@ from allmydata.immutable.upload import Data from allmydata.test.common_web import WebRenderingMixin from allmydata.mutable.publish import MutableData +from .common import ( + EMPTY_CLIENT_CONFIG, +) + class FakeClient(object): def get_storage_broker(self): return self.storage_broker @@ -22,7 +26,7 @@ class FakeClient(object): class WebResultsRendering(unittest.TestCase, WebRenderingMixin): def create_fake_client(self): - sb = StorageFarmBroker(True, None) + sb = StorageFarmBroker(True, None, EMPTY_CLIENT_CONFIG) # s.get_name() (the "short description") will be "v0-00000000". # s.get_longname() will include the -long suffix. servers = [("v0-00000000-long", "\x00"*20, "peer-0"), @@ -41,7 +45,7 @@ class WebResultsRendering(unittest.TestCase, WebRenderingMixin): "my-version": "ver", "oldest-supported": "oldest", } - s = NativeStorageServer(server_id, ann, None, None) + s = NativeStorageServer(server_id, ann, None, None, None) sb.test_add_server(server_id, s) c = FakeClient() c.storage_broker = sb diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index 1e397e802..098ab461b 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -61,6 +61,7 @@ from allmydata.interfaces import IFilesystemNode, IFileNode, \ from foolscap.api import flushEventualQueue import allmydata.test.common_util as testutil from .common import ( + EMPTY_CLIENT_CONFIG, SyncTestCase, UseTestPlugins, MemoryIntroducerClient, @@ -579,7 +580,7 @@ class Basic(testutil.ReallyEqualMixin, testutil.NonASCIIPathMixin, unittest.Test return [ s.get_longname() for s in sb.get_servers_for_psi(key) ] def test_permute(self): - sb = StorageFarmBroker(True, None) + sb = StorageFarmBroker(True, None, EMPTY_CLIENT_CONFIG) for k in ["%d" % i for i in range(5)]: ann = {"anonymous-storage-FURL": SOME_FURL, "permutation-seed-base32": base32.b2a(k) } @@ -594,6 +595,7 @@ class Basic(testutil.ReallyEqualMixin, testutil.NonASCIIPathMixin, unittest.Test sb = StorageFarmBroker( True, None, + EMPTY_CLIENT_CONFIG, StorageClientConfig(preferred_peers=['1','4']), ) for k in ["%d" % i for i in range(5)]: diff --git a/src/allmydata/test/test_helper.py b/src/allmydata/test/test_helper.py index 4b07f58ae..3774704c6 100644 --- a/src/allmydata/test/test_helper.py +++ b/src/allmydata/test/test_helper.py @@ -12,6 +12,10 @@ from allmydata.immutable import offloaded, upload from allmydata import uri, client from allmydata.util import hashutil, fileutil, mathutil +from .common import ( + EMPTY_CLIENT_CONFIG, +) + MiB = 1024*1024 DATA = "I need help\n" * 1000 @@ -118,7 +122,11 @@ class AssistedUpload(unittest.TestCase): self.tub = t = Tub() t.setOption("expose-remote-exception-types", False) self.s = FakeClient() - self.s.storage_broker = StorageFarmBroker(True, lambda h: self.tub) + self.s.storage_broker = StorageFarmBroker( + True, + lambda h: self.tub, + EMPTY_CLIENT_CONFIG, + ) self.s.secret_holder = client.SecretHolder("lease secret", "converge") self.s.startService() diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index b59b93347..1c7bcc3bc 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -6,6 +6,9 @@ from json import ( from fixtures import ( TempDir, ) +from testtools.content import ( + text_content, +) from testtools.matchers import ( MatchesAll, IsInstance, @@ -34,6 +37,7 @@ from foolscap.api import ( ) from .common import ( + EMPTY_CLIENT_CONFIG, SyncTestCase, AsyncTestCase, UseTestPlugins, @@ -94,7 +98,7 @@ class TestNativeStorageServer(unittest.TestCase): ann = {"anonymous-storage-FURL": "pb://w2hqnbaa25yw4qgcvghl5psa3srpfgw3@tcp:127.0.0.1:51309/vucto2z4fxment3vfxbqecblbf6zyp6x", "permutation-seed-base32": "w2hqnbaa25yw4qgcvghl5psa3srpfgw3", } - nss = NativeStorageServer("server_id", ann, None, {}) + nss = NativeStorageServer("server_id", ann, None, {}, EMPTY_CLIENT_CONFIG) self.assertEqual(nss.get_nickname(), "") @@ -110,7 +114,7 @@ class GetConnectionStatus(unittest.TestCase): """ # Pretty hard to recognize anything from an empty announcement. ann = {} - nss = NativeStorageServer("server_id", ann, Tub, {}) + nss = NativeStorageServer("server_id", ann, Tub, {}, EMPTY_CLIENT_CONFIG) nss.start_connecting(lambda: None) connection_status = nss.get_connection_status() self.assertTrue(IConnectionStatus.providedBy(connection_status)) @@ -144,6 +148,7 @@ class UnrecognizedAnnouncement(unittest.TestCase): self.ann, self._tub_maker, {}, + EMPTY_CLIENT_CONFIG, ) def test_no_exceptions(self): @@ -351,6 +356,7 @@ class PluginMatchedAnnouncement(SyncTestCase): } self.publish(server_id, ann, self.introducer_client) storage = self.get_storage(server_id, self.node) + self.addDetail("storage", text_content(str(storage))) self.expectThat( storage.storage_server, MatchesAll( @@ -449,8 +455,11 @@ class StoragePluginWebPresence(AsyncTestCase): class TestStorageFarmBroker(unittest.TestCase): + def make_broker(self, tub_maker=lambda h: Mock()): + return StorageFarmBroker(True, tub_maker, EMPTY_CLIENT_CONFIG) + def test_static_servers(self): - broker = StorageFarmBroker(True, lambda h: Mock()) + broker = self.make_broker() key_s = 'v0-1234-1' servers_yaml = b"""\ @@ -484,7 +493,7 @@ storage: self.assertEqual(s2.get_permutation_seed(), permseed) def test_static_permutation_seed_pubkey(self): - broker = StorageFarmBroker(True, lambda h: Mock()) + broker = self.make_broker() server_id = "v0-4uazse3xb6uu5qpkb7tel2bm6bpea4jhuigdhqcuvvse7hugtsia" k = "4uazse3xb6uu5qpkb7tel2bm6bpea4jhuigdhqcuvvse7hugtsia" ann = { @@ -495,7 +504,7 @@ storage: self.assertEqual(s.get_permutation_seed(), base32.a2b(k)) def test_static_permutation_seed_explicit(self): - broker = StorageFarmBroker(True, lambda h: Mock()) + broker = self.make_broker() server_id = "v0-4uazse3xb6uu5qpkb7tel2bm6bpea4jhuigdhqcuvvse7hugtsia" k = "w5gl5igiexhwmftwzhai5jy2jixn7yx7" ann = { @@ -507,7 +516,7 @@ storage: self.assertEqual(s.get_permutation_seed(), base32.a2b(k)) def test_static_permutation_seed_hashed(self): - broker = StorageFarmBroker(True, lambda h: Mock()) + broker = self.make_broker() server_id = "unparseable" ann = { "anonymous-storage-FURL": SOME_FURL, @@ -523,7 +532,7 @@ storage: new_tubs = [] def make_tub(*args, **kwargs): return new_tubs.pop() - broker = StorageFarmBroker(True, make_tub) + broker = self.make_broker(make_tub) done = broker.when_connected_enough(5) broker.use_introducer(introducer) # subscribes to "storage" to learn of new storage nodes diff --git a/src/allmydata/test/test_upload.py b/src/allmydata/test/test_upload.py index a7459c3a2..165ca17d7 100644 --- a/src/allmydata/test/test_upload.py +++ b/src/allmydata/test/test_upload.py @@ -22,6 +22,10 @@ from allmydata.storage_client import StorageFarmBroker from allmydata.storage.server import storage_index_to_dir from allmydata.client import _Client +from .common import ( + EMPTY_CLIENT_CONFIG, +) + MiB = 1024*1024 def extract_uri(results): @@ -217,7 +221,11 @@ class FakeClient(object): ("%20d" % fakeid, FakeStorageServer(mode[fakeid], reactor=reactor)) for fakeid in range(self.num_servers) ] - self.storage_broker = StorageFarmBroker(permute_peers=True, tub_maker=None) + self.storage_broker = StorageFarmBroker( + permute_peers=True, + tub_maker=None, + node_config=EMPTY_CLIENT_CONFIG, + ) for (serverid, rref) in servers: ann = {"anonymous-storage-FURL": "pb://%s@nowhere/fake" % base32.b2a(serverid), "permutation-seed-base32": base32.b2a(serverid) } diff --git a/src/allmydata/test/web/test_root.py b/src/allmydata/test/web/test_root.py index 1e324c398..f9db6f145 100644 --- a/src/allmydata/test/web/test_root.py +++ b/src/allmydata/test/web/test_root.py @@ -4,6 +4,10 @@ from ...storage_client import NativeStorageServer from ...web.root import Root from ...util.connection_status import ConnectionStatus +from ..common import ( + EMPTY_CLIENT_CONFIG, +) + class FakeRoot(Root): def __init__(self): pass @@ -26,7 +30,7 @@ class RenderServiceRow(unittest.TestCase): ann = {"anonymous-storage-FURL": "pb://w2hqnbaa25yw4qgcvghl5psa3srpfgw3@tcp:127.0.0.1:51309/vucto2z4fxment3vfxbqecblbf6zyp6x", "permutation-seed-base32": "w2hqnbaa25yw4qgcvghl5psa3srpfgw3", } - s = NativeStorageServer("server_id", ann, None, {}) + s = NativeStorageServer("server_id", ann, None, {}, EMPTY_CLIENT_CONFIG) cs = ConnectionStatus(False, "summary", {}, 0, 0) s.get_connection_status = lambda: cs diff --git a/src/allmydata/test/web/test_web.py b/src/allmydata/test/web/test_web.py index 0b41f9b5a..4c01f6822 100644 --- a/src/allmydata/test/web/test_web.py +++ b/src/allmydata/test/web/test_web.py @@ -40,9 +40,15 @@ from allmydata.util import fileutil, base32, hashutil from allmydata.util.consumer import download_to_data from allmydata.util.encodingutil import to_str from ...util.connection_status import ConnectionStatus -from ..common import FakeCHKFileNode, FakeMutableFileNode, \ - create_chk_filenode, WebErrorMixin, \ - make_mutable_file_uri, create_mutable_filenode +from ..common import ( + EMPTY_CLIENT_CONFIG, + FakeCHKFileNode, + FakeMutableFileNode, + create_chk_filenode, + WebErrorMixin, + make_mutable_file_uri, + create_mutable_filenode, +) from allmydata.interfaces import IMutableFileNode, SDMF_VERSION, MDMF_VERSION from allmydata.mutable import servermap, publish, retrieve from .. import common_util as testutil @@ -280,7 +286,11 @@ class FakeClient(_Client): self._secret_holder = SecretHolder("lease secret", "convergence secret") self.helper = None self.convergence = "some random string" - self.storage_broker = StorageFarmBroker(permute_peers=True, tub_maker=None) + self.storage_broker = StorageFarmBroker( + permute_peers=True, + tub_maker=None, + node_config=EMPTY_CLIENT_CONFIG, + ) # fake knowledge of another server self.storage_broker.test_add_server("other_nodeid", FakeDisplayableServer( From b1c894ca7ba4c3c1a92b9455ef2bf47cdeba2cf1 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 20 Aug 2019 09:18:11 -0400 Subject: [PATCH 89/96] incorrect news fragment --- newsfragments/3284.minor | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 newsfragments/3284.minor diff --git a/newsfragments/3284.minor b/newsfragments/3284.minor deleted file mode 100644 index e69de29bb..000000000 From e62d2a5a275c2150bc4f0bd00a69f60640e59e58 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 20 Aug 2019 09:28:05 -0400 Subject: [PATCH 90/96] In reality we cannot handle a Deferred here ... yet? This code is invoked from an `__init__` where async is always tricky. Maybe we can invert the relationship someday. --- src/allmydata/interfaces.py | 2 +- src/allmydata/storage_client.py | 2 +- src/allmydata/test/test_storage_client.py | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/allmydata/interfaces.py b/src/allmydata/interfaces.py index 012eb53e2..0d9b3b885 100644 --- a/src/allmydata/interfaces.py +++ b/src/allmydata/interfaces.py @@ -3114,7 +3114,7 @@ class IFoolscapStoragePlugin(IPlugin): server portion of this plugin on the currently active connection, or ``None`` if no connection has been established yet. - :rtype: ``Deferred`` firing with ``IStorageServer`` + :rtype: ``IStorageServer`` """ def get_client_resource(configuration): diff --git a/src/allmydata/storage_client.py b/src/allmydata/storage_client.py index ef148a4c1..5f258d934 100644 --- a/src/allmydata/storage_client.py +++ b/src/allmydata/storage_client.py @@ -446,7 +446,7 @@ class _FoolscapStorage(object): permutation_seed = attr.ib() tubid = attr.ib() - storage_server = attr.ib() + storage_server = attr.ib(validator=attr.validators.provides(IStorageServer)) _furl = attr.ib() _short_description = attr.ib() diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index b59b93347..fb56f5ecb 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -15,6 +15,9 @@ from testtools.matchers import ( AfterPreprocessing, ) +from zope.interface import ( + implementer, +) from zope.interface.verify import ( verifyObject, ) @@ -59,6 +62,7 @@ from allmydata.storage_client import ( ) from allmydata.interfaces import ( IConnectionStatus, + IStorageServer, ) SOME_FURL = b"pb://abcde@nowhere/fake" @@ -381,6 +385,9 @@ class FoolscapStorageServers(unittest.TestCase): """ Instances of ``_FoolscapStorage`` provide ``IFoolscapStorageServer``. """ + @implementer(IStorageServer) + class NotStorageServer(object): + pass self.assertTrue( verifyObject( IFoolscapStorageServer, @@ -388,7 +395,7 @@ class FoolscapStorageServers(unittest.TestCase): u"server-id", SOME_FURL, {u"permutation-seed-base32": base32.b2a(b"permutationseed")}, - object(), + NotStorageServer(), ), ), ) From 06e9d93d972befcc68fe4b0d92957203211a8ed7 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Tue, 20 Aug 2019 09:30:40 -0400 Subject: [PATCH 91/96] news fragment --- newsfragments/3250.minor | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 newsfragments/3250.minor diff --git a/newsfragments/3250.minor b/newsfragments/3250.minor new file mode 100644 index 000000000..e69de29bb From 8faf2838f3b6c3271b1393c7c2ca238d504c9680 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 23 Aug 2019 08:14:01 -0400 Subject: [PATCH 92/96] Pull `make_broker` up to module scope since it does not need the TestCase --- src/allmydata/test/test_storage_client.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/allmydata/test/test_storage_client.py b/src/allmydata/test/test_storage_client.py index 1c7bcc3bc..55e647072 100644 --- a/src/allmydata/test/test_storage_client.py +++ b/src/allmydata/test/test_storage_client.py @@ -453,13 +453,18 @@ class StoragePluginWebPresence(AsyncTestCase): self.assertThat(result, Equals(dumps({b"web": b"1"}))) +def make_broker(tub_maker=lambda h: Mock()): + """ + Create a ``StorageFarmBroker`` with the given tub maker and an empty + client configuration. + """ + return StorageFarmBroker(True, tub_maker, EMPTY_CLIENT_CONFIG) + + class TestStorageFarmBroker(unittest.TestCase): - def make_broker(self, tub_maker=lambda h: Mock()): - return StorageFarmBroker(True, tub_maker, EMPTY_CLIENT_CONFIG) - def test_static_servers(self): - broker = self.make_broker() + broker = make_broker() key_s = 'v0-1234-1' servers_yaml = b"""\ @@ -493,7 +498,7 @@ storage: self.assertEqual(s2.get_permutation_seed(), permseed) def test_static_permutation_seed_pubkey(self): - broker = self.make_broker() + broker = make_broker() server_id = "v0-4uazse3xb6uu5qpkb7tel2bm6bpea4jhuigdhqcuvvse7hugtsia" k = "4uazse3xb6uu5qpkb7tel2bm6bpea4jhuigdhqcuvvse7hugtsia" ann = { @@ -504,7 +509,7 @@ storage: self.assertEqual(s.get_permutation_seed(), base32.a2b(k)) def test_static_permutation_seed_explicit(self): - broker = self.make_broker() + broker = make_broker() server_id = "v0-4uazse3xb6uu5qpkb7tel2bm6bpea4jhuigdhqcuvvse7hugtsia" k = "w5gl5igiexhwmftwzhai5jy2jixn7yx7" ann = { @@ -516,7 +521,7 @@ storage: self.assertEqual(s.get_permutation_seed(), base32.a2b(k)) def test_static_permutation_seed_hashed(self): - broker = self.make_broker() + broker = make_broker() server_id = "unparseable" ann = { "anonymous-storage-FURL": SOME_FURL, @@ -532,7 +537,7 @@ storage: new_tubs = [] def make_tub(*args, **kwargs): return new_tubs.pop() - broker = self.make_broker(make_tub) + broker = make_broker(make_tub) done = broker.when_connected_enough(5) broker.use_introducer(introducer) # subscribes to "storage" to learn of new storage nodes From a55719cdc4538696eb3d231ea501e8527f64bbb4 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 16 Oct 2019 19:56:29 -0400 Subject: [PATCH 93/96] Raise UnknownConfigError when a server is configured with an unknown storage plugin --- src/allmydata/client.py | 11 ++++++++--- src/allmydata/test/test_client.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 3fca70b03..e45666049 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -355,8 +355,13 @@ class _StoragePlugins(object): """ storage_plugin_names = cls._get_enabled_storage_plugin_names(config) plugins = list(cls._collect_storage_plugins(storage_plugin_names)) - # TODO Handle missing plugins - # https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3118 + unknown_plugin_names = storage_plugin_names - {plugin.name for plugin in plugins} + if unknown_plugin_names: + raise configutil.UnknownConfigError( + "Storage plugins {} are enabled but not known on this system.".format( + unknown_plugin_names, + ), + ) announceable_storage_servers = yield cls._create_plugin_storage_servers( get_anonymous_storage_server, config, @@ -375,7 +380,7 @@ class _StoragePlugins(object): config.get_config( "storage", "plugins", b"" ).decode("ascii").split(u",") - ) + ) - {u""} @classmethod def _collect_storage_plugins(cls, storage_plugin_names): diff --git a/src/allmydata/test/test_client.py b/src/allmydata/test/test_client.py index 098ab461b..824db82fe 100644 --- a/src/allmydata/test/test_client.py +++ b/src/allmydata/test/test_client.py @@ -54,6 +54,7 @@ from allmydata.util import ( base32, fileutil, encodingutil, + configutil, ) from allmydata.util.fileutil import abspath_expanduser_unicode from allmydata.interfaces import IFilesystemNode, IFileNode, \ @@ -1496,3 +1497,30 @@ introducer.furl = pb://abcde@nowhere/fake ), failed(Always()), ) + + def test_storage_plugin_not_found(self): + """ + ``client.create_client_from_config`` raises ``UnknownConfigError`` when + called with a configuration which enables a storage plugin that is not + available on the system. + """ + config = client.config_from_string( + self.basedir, + u"tub.port", + self.get_config( + storage_enabled=True, + more_storage=b"plugins=tahoe-lafs-dummy-vX", + ), + ) + self.assertThat( + client.create_client_from_config( + config, + _introducer_factory=MemoryIntroducerClient, + ), + failed( + AfterPreprocessing( + lambda f: f.type, + Equals(configutil.UnknownConfigError), + ), + ), + ) From 351c7bad452e40e44c843ec93a31a807a08d36cc Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Wed, 16 Oct 2019 19:58:15 -0400 Subject: [PATCH 94/96] news fragment --- newsfragments/3118.minor | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 newsfragments/3118.minor diff --git a/newsfragments/3118.minor b/newsfragments/3118.minor new file mode 100644 index 000000000..e69de29bb From 0dd7b27b569a3b6501d60ad5571b822c978a857f Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 8 Nov 2019 14:12:08 -0500 Subject: [PATCH 95/96] news fragment --- newsfragments/3264.minor | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 newsfragments/3264.minor diff --git a/newsfragments/3264.minor b/newsfragments/3264.minor new file mode 100644 index 000000000..e69de29bb From c80c753e5dab71f4d803c69dec422b95fc9e5105 Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone Date: Fri, 8 Nov 2019 14:12:38 -0500 Subject: [PATCH 96/96] Late bind storage so init_storage can run after init_web --- src/allmydata/web/root.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/allmydata/web/root.py b/src/allmydata/web/root.py index 0e241cfc3..4ededb8c9 100644 --- a/src/allmydata/web/root.py +++ b/src/allmydata/web/root.py @@ -196,12 +196,6 @@ class Root(MultiFormatPage): rend.Page.__init__(self, client) self.client = client self.now_fn = now_fn - try: - s = client.getServiceNamed("storage") - except KeyError: - s = None - - self.putChild("storage", storage.StorageStatus(s, self.client.nickname)) self.putChild("uri", URIHandler(client)) self.putChild("cap", URIHandler(client)) @@ -237,6 +231,16 @@ class Root(MultiFormatPage): # the Helper isn't attached until after the Tub starts, so this child # needs to created on each request return status.HelperStatus(self.client.helper) + if path == "storage": + # Storage isn't initialized until after the web hierarchy is + # constructed so this child needs to be created later than + # `__init__`. + try: + storage_server = self.client.getServiceNamed("storage") + except KeyError: + storage_server = None + return storage.StorageStatus(storage_server, self.client.nickname) + # FIXME: This code is duplicated in root.py and introweb.py. def data_rendered_at(self, ctx, data):