From aab73e9e220b188478eb4bcd8d35e229b9d04d1a Mon Sep 17 00:00:00 2001 From: Jean-Paul Calderone <exarkun@twistedmatrix.com> Date: Tue, 22 Feb 2022 16:21:14 -0500 Subject: [PATCH] implement the real replication configuration function --- src/_zkapauthorizer/config.py | 4 ++ src/_zkapauthorizer/recover.py | 41 ++++++++++- src/_zkapauthorizer/tahoe.py | 11 +++ src/_zkapauthorizer/tests/matchers.py | 20 ++++++ src/_zkapauthorizer/tests/test_recover.py | 83 ++++++++++++++++++++++- 5 files changed, 155 insertions(+), 4 deletions(-) diff --git a/src/_zkapauthorizer/config.py b/src/_zkapauthorizer/config.py index 42ddae0..4c2df1a 100644 --- a/src/_zkapauthorizer/config.py +++ b/src/_zkapauthorizer/config.py @@ -26,6 +26,10 @@ from twisted.python.filepath import FilePath from . import NAME from .lease_maintenance import LeaseMaintenanceConfig +# The basename of the replica read-write capability file in the node's private +# directory, if replication is configured. +REPLICA_RWCAP_BASENAME = NAME + ".replica-rwcap" + class _EmptyConfig(object): """ diff --git a/src/_zkapauthorizer/recover.py b/src/_zkapauthorizer/recover.py index d5bfd04..cd2d06c 100644 --- a/src/_zkapauthorizer/recover.py +++ b/src/_zkapauthorizer/recover.py @@ -20,8 +20,10 @@ from sqlite3 import Cursor from typing import BinaryIO, Callable, Dict, Iterator, Optional from attrs import define +from twisted.python.lockfile import FilesystemLock -from .tahoe import Tahoe +from .config import REPLICA_RWCAP_BASENAME +from .tahoe import Tahoe, attenuate_writecap class SnapshotMissing(Exception): @@ -237,3 +239,40 @@ async def fail_setup_replication(): A replication setup function that always fails. """ raise Exception("Test not set up for replication") + + +async def setup_tahoe_lafs_replication(client: Tahoe) -> Awaitable: + """ + Configure the ZKAPAuthorizer plugin that lives in the Tahoe-LAFS node with + the given configuration to replicate its state onto Tahoe-LAFS storage + servers using that Tahoe-LAFS node. + """ + # Find the configuration path for this node's replica. + config_path = client.get_private_path(REPLICA_RWCAP_BASENAME) + + # Take an advisory lock on the configuration path to avoid concurrency + # shennanigans. + config_lock = FilesystemLock(config_path.path + ".lock") + config_lock.lock() + try: + + # Check to see if there is already configuration. + if config_path.exists(): + raise ReplicationAlreadySetup() + + # Create a directory with it + rw_cap = await client.make_directory() + + # Store the resulting write-cap in the node's private directory + config_path.setContent(rw_cap.encode("ascii")) + + finally: + # On success and failure, release the lock since we're done with the + # file for now. + config_lock.unlock() + + # Attenuate it to a read-cap + rocap = attenuate_writecap(rw_cap) + + # Return the read-cap + return rocap diff --git a/src/_zkapauthorizer/tahoe.py b/src/_zkapauthorizer/tahoe.py index 1b185ac..23b0f4e 100644 --- a/src/_zkapauthorizer/tahoe.py +++ b/src/_zkapauthorizer/tahoe.py @@ -317,6 +317,9 @@ class _MemoryTahoe: def _nodedir_default(self): return FilePath(mkdtemp(suffix=".memory-tahoe")) + def __attrs_post_init__(self): + self._nodedir.child("private").makedirs() + def get_private_path(self, name: str) -> FilePath: """ Get the path to a file in a private directory dedicated to this instance @@ -333,3 +336,11 @@ class _MemoryTahoe: async def make_directory(self): return self._grid.make_directory() + + +def attenuate_writecap(rw_cap: str) -> str: + """ + Get a read-only capability corresponding to the same data as the given + read-write capability. + """ + return capability_from_string(rw_cap).get_readonly().to_string().decode("ascii") diff --git a/src/_zkapauthorizer/tests/matchers.py b/src/_zkapauthorizer/tests/matchers.py index a5b05f0..3493f17 100644 --- a/src/_zkapauthorizer/tests/matchers.py +++ b/src/_zkapauthorizer/tests/matchers.py @@ -247,3 +247,23 @@ def matches_json(matcher=Always()): return matcher.match(value) return Matcher() + + +def matches_capability(type_matcher): + """ + Return a matcher for a unicode string representing a Tahoe-LAFS capability + that has a type matched by ``type_matcher``. + """ + + def get_cap_type(cap: str) -> str: + if not isinstance(cap, str): + raise Exception(f"expected str cap, got {cap!r}") + pieces = cap.split(":") + if len(pieces) > 1 and pieces[0] == "URI": + return pieces[1] + return None + + return AfterPreprocessing( + get_cap_type, + type_matcher, + ) diff --git a/src/_zkapauthorizer/tests/test_recover.py b/src/_zkapauthorizer/tests/test_recover.py index 799551f..89832c9 100644 --- a/src/_zkapauthorizer/tests/test_recover.py +++ b/src/_zkapauthorizer/tests/test_recover.py @@ -2,12 +2,13 @@ Tests for ``_zkapauthorizer.recover``, the replication recovery system. """ +from asyncio import run from sqlite3 import Connection, connect from typing import Dict, Iterator from allmydata.client import read_config from fixtures import TempDir -from hypothesis import assume, note, settings +from hypothesis import assume, given, note, settings from hypothesis.stateful import ( RuleBasedStateMachine, invariant, @@ -29,21 +30,35 @@ from testtools.twistedsupport import AsynchronousDeferredRunTest, failed, succee from twisted.internet.defer import Deferred, inlineCallbacks from twisted.python.filepath import FilePath +from ..config import REPLICA_RWCAP_BASENAME from ..recover import ( AlreadyRecovering, RecoveryStages, + ReplicationAlreadySetup, StatefulRecoverer, + attenuate_writecap, get_tahoe_lafs_downloader, make_canned_downloader, make_fail_downloader, noop_downloader, recover, + setup_tahoe_lafs_replication, ) -from ..tahoe import Tahoe, link, make_directory, upload +from ..tahoe import MemoryGrid, Tahoe, link, make_directory, upload from .fixtures import Treq +from .matchers import matches_capability from .resources import client_manager from .sql import Table, create_table -from .strategies import deletes, inserts, sql_identifiers, tables, updates +from .strategies import ( + api_auth_tokens, + deletes, + inserts, + sql_identifiers, + tables, + tahoe_configs, + updates, +) +from .test_client_resource import get_config_with_api_token def snapshot(connection: Connection) -> Iterator[str]: @@ -327,3 +342,65 @@ class TahoeLAFSDownloaderTests(TestCase): downloaded_snapshot_path.getContent(), Equals(snapshot_path.getContent()), ) + + +class SetupTahoeLAFSReplicationTests(TestCase): + """ + Tests for ``setup_tahoe_lafs_replication``. + """ + + @given( + tahoe_configs(), + api_auth_tokens(), + ) + def test_already_setup(self, get_config, api_auth_token): + """ + If replication is already set up, ``setup_tahoe_lafs_replication`` signals + failure with ``ReplicationAlreadySetup``. + """ + grid = MemoryGrid() + client = grid.client() + client.get_private_path(REPLICA_RWCAP_BASENAME).setContent(b"URI:DIR2:stuff") + self.assertThat( + Deferred.fromCoroutine(setup_tahoe_lafs_replication(client)), + failed( + AfterPreprocessing( + lambda f: f.value, + IsInstance(ReplicationAlreadySetup), + ), + ), + ) + + @given( + tahoe_configs(), + api_auth_tokens(), + ) + def test_setup(self, get_config, api_auth_token): + """ + If replication was not previously set up then + ``setup_tahoe_lafs_replication`` signals success with a read-only + directory capability string that it has just created and written to + the node private directory. + """ + grid = MemoryGrid() + client = grid.client() + + ro_cap = run(setup_tahoe_lafs_replication(client)) + self.assertThat(ro_cap, matches_capability(Equals("DIR2-RO"))) + + # Memory grid lets us download directory cap as a dict. Kind of bogus + # but use it for now. + self.assertThat( + grid.download(ro_cap), + Equals({}), + ) + + # Peek inside the node private state to make sure the capability was + # written. + self.assertThat( + client.get_private_path(REPLICA_RWCAP_BASENAME).getContent(), + AfterPreprocessing( + attenuate_writecap, + Equals(ro_cap), + ), + ) -- GitLab