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