diff options
113 files changed, 1964 insertions, 1640 deletions
| @@ -18,3 +18,6 @@ MANIFEST  _trial_temp  .DS_Store  scripts/profiling/sync/profiles + +testing/htmlcov +testing/.coverage diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 647cc43c..dd4e4605 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,3 +1,36 @@ -trial: +stages: +  - code-check +  - tests +  - benchmark + +# Cache tox envs between builds +cache: +  paths: +  - testing/.tox/ + +code-check: +  stage: code-check    script: -  - cd testing; tox +    - cd testing +    - tox -e code-check + +tests: +  stage: tests +  image: leapcode/soledad:latest +  services: +    - couchdb +  script: +    - cd testing +    - tox -- --couch-url http://couchdb:5984 + +benchmark: +  stage: benchmark +  image: leapcode/soledad:latest +  services: +    - couchdb +  script: +    - cd testing +    - tox -e perf -- --couch-url http://couchdb:5984 +  tags: +    - docker +    - benchmark diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 24c20641..ded2cac9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,41 @@ +0.9.0 - 11 November, 2016 ++++++++++++++++++++++++++ + +Main features +~~~~~~~~~~~~~ + +- Server-side changes in couch backend schema. +- Use of tox and pytest to run tests. +- Performance tests. + +Server +====== + +*** Attention: Migration needed! *** + +This version of soledad uses a different database schema in the server couch +backend. The difference from the old schema is that the use of design documents +for storing and accessing soledad db metadata was removed because incurred in +too much memory and time overhead for passing data to the javascript +interpreter. + +Because of that, you need to run a migration script on your database. Check the +`scripts/migration/0.9.0/` diretctory for instructions on how to run the +migration script on your database. Don't forget to backup before running the +script! + +Bugfixes +~~~~~~~~ +- Fix order of multipart serialization when writing to couch. + +Features +~~~~~~~~ +- Log to syslog. +- Remove usage of design documents in couch backend. +- Use _local couch docs for metadata storage. +- Other small improvements in couch backend. + +  0.8.1 - 14 July, 2016  +++++++++++++++++++++ diff --git a/client/pkg/requirements-latest.pip b/client/pkg/requirements-latest.pip index 46a7ccba..d32e1ffa 100644 --- a/client/pkg/requirements-latest.pip +++ b/client/pkg/requirements-latest.pip @@ -1,5 +1,5 @@  --index-url https://pypi.python.org/simple/ --e 'git+https://github.com/pixelated-project/leap_pycommon.git@develop#egg=leap.common' +-e 'git+https://github.com/leapcode/leap_pycommon.git@develop#egg=leap.common'  -e '../common'  -e . diff --git a/client/pkg/requirements-leap.pip b/client/pkg/requirements-leap.pip index 52d1263b..920d4123 100644 --- a/client/pkg/requirements-leap.pip +++ b/client/pkg/requirements-leap.pip @@ -1,2 +1,2 @@  leap.common>=0.4.3 -leap.soledad.common>=0.7.0 +leap.soledad.common>=0.9.0 diff --git a/client/pkg/requirements-testing.pip b/client/pkg/requirements-testing.pip deleted file mode 100644 index 94ab6e8e..00000000 --- a/client/pkg/requirements-testing.pip +++ /dev/null @@ -1 +0,0 @@ -pep8 diff --git a/client/setup.py b/client/setup.py index 90986dde..235e731c 100644 --- a/client/setup.py +++ b/client/setup.py @@ -114,13 +114,13 @@ requirements = utils.parse_requirements()  if utils.is_develop_mode():      print -    print ("[WARNING] Skipping leap-specific dependencies " -           "because development mode is detected.") -    print ("[WARNING] You can install " -           "the latest published versions with " -           "'pip install -r pkg/requirements-leap.pip'") -    print ("[WARNING] Or you can instead do 'python setup.py develop' " -           "from the parent folder of each one of them.") +    print("[WARNING] Skipping leap-specific dependencies " +          "because development mode is detected.") +    print("[WARNING] You can install " +          "the latest published versions with " +          "'pip install -r pkg/requirements-leap.pip'") +    print("[WARNING] Or you can instead do 'python setup.py develop' " +          "from the parent folder of each one of them.")      print  else:      requirements += utils.parse_requirements( diff --git a/client/src/leap/soledad/client/adbapi.py b/client/src/leap/soledad/client/adbapi.py index ef0f9066..ce9bec05 100644 --- a/client/src/leap/soledad/client/adbapi.py +++ b/client/src/leap/soledad/client/adbapi.py @@ -19,31 +19,25 @@ An asyncrhonous interface to soledad using sqlcipher backend.  It uses twisted.enterprise.adbapi.  """  import re -import os  import sys -import logging  from functools import partial  from twisted.enterprise import adbapi  from twisted.internet.defer import DeferredSemaphore -from twisted.python import log  from zope.proxy import ProxyBase, setProxiedObject  from pysqlcipher import dbapi2 +from leap.soledad.common.log import getLogger  from leap.soledad.common.errors import DatabaseAccessError  from leap.soledad.client import sqlcipher as soledad_sqlcipher  from leap.soledad.client.pragmas import set_init_pragmas -logger = logging.getLogger(name=__name__) +logger = getLogger(__name__) -DEBUG_SQL = os.environ.get("LEAP_DEBUG_SQL") -if DEBUG_SQL: -    log.startLogging(sys.stdout) -  """  How long the SQLCipher connection should wait for the lock to go away until  raising an exception. @@ -221,13 +215,12 @@ class U1DBConnectionPool(adbapi.ConnectionPool):          def _errback(failure):              failure.trap(dbapi2.OperationalError)              if failure.getErrorMessage() == "database is locked": -                logger.warning("Database operation timed out.") +                logger.warn("database operation timed out")                  should_retry = semaphore.acquire()                  if should_retry: -                    logger.warning( -                        "Database operation timed out while waiting for " -                        "lock, trying again...") +                    logger.warn("trying again...")                      return _run_interaction() +                logger.warn("giving up!")              return failure          d = _run_interaction() @@ -286,7 +279,7 @@ class U1DBConnectionPool(adbapi.ConnectionPool):              try:                  conn.rollback()              except: -                log.err(None, "Rollback failed") +                logger.error(None, "Rollback failed")              raise excType, excValue, excTraceback      def finalClose(self): diff --git a/client/src/leap/soledad/client/api.py b/client/src/leap/soledad/client/api.py index 1bfbed8a..6870d5ba 100644 --- a/client/src/leap/soledad/client/api.py +++ b/client/src/leap/soledad/client/api.py @@ -28,7 +28,6 @@ remote storage in the server side.  import binascii  import errno  import httplib -import logging  import os  import socket  import ssl @@ -49,6 +48,7 @@ from leap.common.plugins import collect_plugins  from leap.soledad.common import SHARED_DB_NAME  from leap.soledad.common import soledad_assert  from leap.soledad.common import soledad_assert_type +from leap.soledad.common.log import getLogger  from leap.soledad.common.l2db.remote import http_client  from leap.soledad.common.l2db.remote.ssl_match_hostname import match_hostname  from leap.soledad.common.errors import DatabaseAccessError @@ -62,7 +62,7 @@ from leap.soledad.client.shared_db import SoledadSharedDatabase  from leap.soledad.client import sqlcipher  from leap.soledad.client import encdecpool -logger = logging.getLogger(name=__name__) +logger = getLogger(__name__)  # we may want to collect statistics from the sync process @@ -337,7 +337,7 @@ class Soledad(object):          """          Close underlying U1DB database.          """ -        logger.debug("Closing soledad") +        logger.debug("closing soledad")          self._dbpool.close()          if getattr(self, '_dbsyncer', None):              self._dbsyncer.close() @@ -736,6 +736,8 @@ class Soledad(object):          :rtype: twisted.internet.defer.Deferred          """          sync_url = urlparse.urljoin(self._server_url, 'user-%s' % self.uuid) +        if not self._dbsyncer: +            return          d = self._dbsyncer.sync(              sync_url,              creds=self._creds, @@ -761,7 +763,7 @@ class Soledad(object):          def _sync_errback(failure):              s = StringIO()              failure.printDetailedTraceback(file=s) -            msg = "Soledad exception when syncing!\n" + s.getvalue() +            msg = "got exception when syncing!\n" + s.getvalue()              logger.error(msg)              return failure @@ -1003,7 +1005,7 @@ class Soledad(object):  def create_path_if_not_exists(path):      try:          if not os.path.isdir(path): -            logger.info('Creating directory: %s.' % path) +            logger.info('creating directory: %s.' % path)          os.makedirs(path)      except OSError as exc:          if exc.errno == errno.EEXIST and os.path.isdir(path): diff --git a/client/src/leap/soledad/client/crypto.py b/client/src/leap/soledad/client/crypto.py index f7d92372..d81c883b 100644 --- a/client/src/leap/soledad/client/crypto.py +++ b/client/src/leap/soledad/client/crypto.py @@ -22,7 +22,6 @@ import binascii  import hmac  import hashlib  import json -import logging  from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes  from cryptography.hazmat.backends.multibackend import MultiBackend @@ -32,9 +31,10 @@ from cryptography.hazmat.backends.openssl.backend \  from leap.soledad.common import soledad_assert  from leap.soledad.common import soledad_assert_type  from leap.soledad.common import crypto +from leap.soledad.common.log import getLogger -logger = logging.getLogger(__name__) +logger = getLogger(__name__)  MAC_KEY_LENGTH = 64 @@ -300,7 +300,7 @@ def encrypt_docstr(docstr, doc_id, doc_rev, key, secret):      # convert binary data to hexadecimal representation so the JSON      # serialization does not complain about what it tries to serialize.      hex_ciphertext = binascii.b2a_hex(ciphertext) -    logger.debug("Encrypting doc: %s" % doc_id) +    logger.debug("encrypting doc: %s" % doc_id)      return json.dumps({          crypto.ENC_JSON_KEY: hex_ciphertext,          crypto.ENC_SCHEME_KEY: enc_scheme, @@ -356,7 +356,7 @@ def _verify_doc_mac(doc_id, doc_rev, ciphertext, enc_scheme, enc_method,      calculated_mac_hash = hashlib.sha256(calculated_mac).digest()      if doc_mac_hash != calculated_mac_hash: -        logger.warning("Wrong MAC while decrypting doc...") +        logger.warn("wrong MAC while decrypting doc...")          raise crypto.WrongMacError("Could not authenticate document's "                                     "contents.") diff --git a/client/src/leap/soledad/client/encdecpool.py b/client/src/leap/soledad/client/encdecpool.py index a6d49b21..056b012f 100644 --- a/client/src/leap/soledad/client/encdecpool.py +++ b/client/src/leap/soledad/client/encdecpool.py @@ -23,22 +23,21 @@ during synchronization.  import json -import logging  from uuid import uuid4  from twisted.internet.task import LoopingCall  from twisted.internet import threads  from twisted.internet import defer -from twisted.python import log  from leap.soledad.common.document import SoledadDocument  from leap.soledad.common import soledad_assert +from leap.soledad.common.log import getLogger  from leap.soledad.client.crypto import encrypt_docstr  from leap.soledad.client.crypto import decrypt_doc_dict -logger = logging.getLogger(__name__) +logger = getLogger(__name__)  # @@ -155,7 +154,7 @@ class SyncEncrypterPool(SyncEncryptDecryptPool):          Start the encrypter pool.          """          SyncEncryptDecryptPool.start(self) -        logger.debug("Starting the encryption loop...") +        logger.debug("starting the encryption loop...")      def stop(self):          """ @@ -230,10 +229,10 @@ class SyncEncrypterPool(SyncEncryptDecryptPool):                  % self.TABLE_NAME          result = yield self._runQuery(query, (doc_id, doc_rev))          if result: -            logger.debug("Found doc on sync db: %s" % doc_id) +            logger.debug("found doc on sync db: %s" % doc_id)              val = result.pop()              defer.returnValue(val[0]) -        logger.debug("Did not find doc on sync db: %s" % doc_id) +        logger.debug("did not find doc on sync db: %s" % doc_id)          defer.returnValue(None)      def delete_encrypted_doc(self, doc_id, doc_rev): @@ -344,6 +343,9 @@ class SyncDecrypterPool(SyncEncryptDecryptPool):          self._loop = LoopingCall(self._decrypt_and_recurse) +    def _start_pool(self, period): +        self._loop.start(period) +      def start(self, docs_to_process):          """          Set the number of documents we expect to process. @@ -360,7 +362,7 @@ class SyncDecrypterPool(SyncEncryptDecryptPool):          self._docs_to_process = docs_to_process          self._deferred = defer.Deferred()          d = self._init_db() -        d.addCallback(lambda _: self._loop.start(self.DECRYPT_LOOP_PERIOD)) +        d.addCallback(lambda _: self._start_pool(self.DECRYPT_LOOP_PERIOD))          return d      def stop(self): @@ -390,7 +392,7 @@ class SyncDecrypterPool(SyncEncryptDecryptPool):          return d      def _errback(self, failure): -        log.err(failure) +        logger.error(failure)          self._deferred.errback(failure)          self._processed_docs = 0          self._last_inserted_idx = 0 @@ -503,7 +505,7 @@ class SyncDecrypterPool(SyncEncryptDecryptPool):          :rtype: twisted.internet.defer.Deferred          """          doc_id, rev, content, gen, trans_id, idx = result -        logger.debug("Sync decrypter pool: decrypted doc %s: %s %s %s" +        logger.debug("sync decrypter pool: decrypted doc %s: %s %s %s"                       % (doc_id, rev, gen, trans_id))          return self.insert_received_doc(              doc_id, rev, content, gen, trans_id, idx) @@ -553,6 +555,12 @@ class SyncDecrypterPool(SyncEncryptDecryptPool):          while next_index in self._decrypted_docs_indexes:              sequence.append(str(next_index))              next_index += 1 +            if len(sequence) > 900: +                # 999 is the default value of SQLITE_MAX_VARIABLE_NUMBER +                # if we try to query more, SQLite will refuse +                # we need to find a way to improve this +                # being researched in #7669 +                break          # Then fetch all the ones ready for insertion.          if sequence:              insertable_docs = yield self._get_docs(encrypted=False, @@ -602,7 +610,7 @@ class SyncDecrypterPool(SyncEncryptDecryptPool):          :type trans_id: str          """          # could pass source_replica in params for callback chain -        logger.debug("Sync decrypter pool: inserting doc in local db: " +        logger.debug("sync decrypter pool: inserting doc in local db: "                       "%s:%s %s" % (doc_id, doc_rev, gen))          # convert deleted documents to avoid error on document creation diff --git a/client/src/leap/soledad/client/http_target/__init__.py b/client/src/leap/soledad/client/http_target/__init__.py index b7e54aa4..62e8bcf0 100644 --- a/client/src/leap/soledad/client/http_target/__init__.py +++ b/client/src/leap/soledad/client/http_target/__init__.py @@ -23,15 +23,15 @@ after receiving.  import os -import logging +from leap.soledad.common.log import getLogger  from leap.common.http import HTTPClient  from leap.soledad.client.http_target.send import HTTPDocSender  from leap.soledad.client.http_target.api import SyncTargetAPI  from leap.soledad.client.http_target.fetch import HTTPDocFetcher -logger = logging.getLogger(__name__) +logger = getLogger(__name__)  # we may want to collect statistics from the sync process diff --git a/client/src/leap/soledad/client/http_target/api.py b/client/src/leap/soledad/client/http_target/api.py index f8de9a15..3c8e3764 100644 --- a/client/src/leap/soledad/client/http_target/api.py +++ b/client/src/leap/soledad/client/http_target/api.py @@ -15,7 +15,6 @@  # You should have received a copy of the GNU General Public License  # along with this program. If not, see <http://www.gnu.org/licenses/>.  import os -import time  import json  import base64 diff --git a/client/src/leap/soledad/client/http_target/fetch.py b/client/src/leap/soledad/client/http_target/fetch.py index a3f70b02..184c5883 100644 --- a/client/src/leap/soledad/client/http_target/fetch.py +++ b/client/src/leap/soledad/client/http_target/fetch.py @@ -14,7 +14,6 @@  #  # You should have received a copy of the GNU General Public License  # along with this program. If not, see <http://www.gnu.org/licenses/>. -import logging  import json  from twisted.internet import defer @@ -24,11 +23,12 @@ from leap.soledad.client.events import emit_async  from leap.soledad.client.crypto import is_symmetrically_encrypted  from leap.soledad.client.encdecpool import SyncDecrypterPool  from leap.soledad.client.http_target.support import RequestBody +from leap.soledad.common.log import getLogger  from leap.soledad.common.document import SoledadDocument  from leap.soledad.common.l2db import errors  from leap.soledad.common.l2db.remote import utils -logger = logging.getLogger(__name__) +logger = getLogger(__name__)  class HTTPDocFetcher(object): diff --git a/client/src/leap/soledad/client/http_target/send.py b/client/src/leap/soledad/client/http_target/send.py index 13218acf..c7bd057e 100644 --- a/client/src/leap/soledad/client/http_target/send.py +++ b/client/src/leap/soledad/client/http_target/send.py @@ -15,15 +15,15 @@  # You should have received a copy of the GNU General Public License  # along with this program. If not, see <http://www.gnu.org/licenses/>.  import json -import logging  from twisted.internet import defer +from leap.soledad.common.log import getLogger  from leap.soledad.client.events import emit_async  from leap.soledad.client.events import SOLEDAD_SYNC_SEND_STATUS  from leap.soledad.client.http_target.support import RequestBody -logger = logging.getLogger(__name__) +logger = getLogger(__name__)  class HTTPDocSender(object): @@ -82,7 +82,6 @@ class HTTPDocSender(object):          if self._defer_encryption:              self._delete_sent(sent) -        user_data = {'uuid': self.uuid, 'userid': self.userid}          _emit_send_status(self.uuid, body.consumed, total)          defer.returnValue(result) diff --git a/client/src/leap/soledad/client/pragmas.py b/client/src/leap/soledad/client/pragmas.py index 55397d10..870ed63e 100644 --- a/client/src/leap/soledad/client/pragmas.py +++ b/client/src/leap/soledad/client/pragmas.py @@ -17,15 +17,15 @@  """  Different pragmas used in the initialization of the SQLCipher database.  """ -import logging  import string  import threading  import os  from leap.soledad.common import soledad_assert +from leap.soledad.common.log import getLogger -logger = logging.getLogger(__name__) +logger = getLogger(__name__)  _db_init_lock = threading.Lock() @@ -321,7 +321,7 @@ def set_synchronous_off(db_handle):      """      Change the setting of the "synchronous" flag to OFF.      """ -    logger.debug("SQLCIPHER: SETTING SYNCHRONOUS OFF") +    logger.debug("sqlcipher: setting synchronous off")      db_handle.cursor().execute('PRAGMA synchronous=OFF') @@ -329,7 +329,7 @@ def set_synchronous_normal(db_handle):      """      Change the setting of the "synchronous" flag to NORMAL.      """ -    logger.debug("SQLCIPHER: SETTING SYNCHRONOUS NORMAL") +    logger.debug("sqlcipher: setting synchronous normal")      db_handle.cursor().execute('PRAGMA synchronous=NORMAL') @@ -337,7 +337,7 @@ def set_mem_temp_store(db_handle):      """      Use a in-memory store for temporary tables.      """ -    logger.debug("SQLCIPHER: SETTING TEMP_STORE MEMORY") +    logger.debug("sqlcipher: setting temp_store memory")      db_handle.cursor().execute('PRAGMA temp_store=MEMORY') @@ -362,7 +362,7 @@ def set_write_ahead_logging(db_handle):      requirements of the application. The default strategy is to run a      checkpoint once the WAL reaches 1000 pages"      """ -    logger.debug("SQLCIPHER: SETTING WRITE-AHEAD LOGGING") +    logger.debug("sqlcipher: setting write-ahead logging")      db_handle.cursor().execute('PRAGMA journal_mode=WAL')      # The optimum value can still use a little bit of tuning, but we favor diff --git a/client/src/leap/soledad/client/secrets.py b/client/src/leap/soledad/client/secrets.py index 3547a711..1eb6f31d 100644 --- a/client/src/leap/soledad/client/secrets.py +++ b/client/src/leap/soledad/client/secrets.py @@ -23,7 +23,6 @@ Soledad secrets handling.  import os  import scrypt -import logging  import binascii  import errno  import json @@ -33,11 +32,12 @@ from hashlib import sha256  from leap.soledad.common import soledad_assert  from leap.soledad.common import soledad_assert_type  from leap.soledad.common import document +from leap.soledad.common.log import getLogger  from leap.soledad.client import events  from leap.soledad.client.crypto import encrypt_sym, decrypt_sym -logger = logging.getLogger(name=__name__) +logger = getLogger(__name__)  # @@ -193,42 +193,42 @@ class SoledadSecrets(object):          """          # STAGE 1 - verify if secrets exist locally          try: -            logger.info("Trying to load secrets from local storage...") +            logger.info("trying to load secrets from local storage...")              version = self._load_secrets_from_local_file()              # eventually migrate local and remote stored documents from old              # format version              if version < self.RECOVERY_DOC_VERSION:                  self._store_secrets()                  self._upload_crypto_secrets() -            logger.info("Found secrets in local storage.") +            logger.info("found secrets in local storage")              return          except NoStorageSecret: -            logger.info("Could not find secrets in local storage.") +            logger.info("could not find secrets in local storage")          # STAGE 2 - there are no secrets in local storage and this is the          #           first time we are running soledad with the specified          #           secrets_path. Try to fetch encrypted secrets from          #           server.          try: -            logger.info('Trying to fetch secrets from remote storage...') +            logger.info('trying to fetch secrets from remote storage...')              version = self._download_crypto_secrets()              self._store_secrets()              # eventually migrate remote stored document from old format              # version              if version < self.RECOVERY_DOC_VERSION:                  self._upload_crypto_secrets() -            logger.info('Found secrets in remote storage.') +            logger.info('found secrets in remote storage.')              return          except NoStorageSecret: -            logger.info("Could not find secrets in remote storage.") +            logger.info("could not find secrets in remote storage.")          # STAGE 3 - there are no secrets in server also, so we want to          #           generate the secrets and store them in the remote          #           db. -        logger.info("Generating secrets...") +        logger.info("generating secrets...")          self._gen_crypto_secrets() -        logger.info("Uploading secrets...") +        logger.info("uploading secrets...")          self._upload_crypto_secrets()      def _has_secret(self): @@ -298,7 +298,7 @@ class SoledadSecrets(object):          """          Generate the crypto secrets.          """ -        logger.info('No cryptographic secrets found, creating new secrets...') +        logger.info('no cryptographic secrets found, creating new secrets...')          secret_id = self._gen_secret()          self.set_secret_id(secret_id) @@ -445,7 +445,7 @@ class SoledadSecrets(object):                              encrypted_secret)                      secret_count += 1                  except SecretsException as e: -                    logger.error("Failed to decrypt storage secret: %s" +                    logger.error("failed to decrypt storage secret: %s"                                   % str(e))          return secret_count, active_secret @@ -461,7 +461,7 @@ class SoledadSecrets(object):          events.emit_async(events.SOLEDAD_DOWNLOADING_KEYS, user_data)          db = self._shared_db          if not db: -            logger.warning('No shared db found') +            logger.warn('no shared db found')              return          doc = db.get_doc(self._shared_db_doc_id())          user_data = {'userid': self._userid, 'uuid': self._uuid} @@ -492,7 +492,7 @@ class SoledadSecrets(object):          events.emit_async(events.SOLEDAD_UPLOADING_KEYS, user_data)          db = self._shared_db          if not db: -            logger.warning('No shared db found') +            logger.warn('no shared db found')              return          db.put_doc(doc)          events.emit_async(events.SOLEDAD_DONE_UPLOADING_KEYS, user_data) diff --git a/client/src/leap/soledad/client/sqlcipher.py b/client/src/leap/soledad/client/sqlcipher.py index 166c0783..3921c323 100644 --- a/client/src/leap/soledad/client/sqlcipher.py +++ b/client/src/leap/soledad/client/sqlcipher.py @@ -41,7 +41,6 @@ So, as the statements above were introduced for backwards compatibility with  SQLCipher 1.1 databases, we do not implement them as all SQLCipher databases  handled by Soledad should be created by SQLCipher >= 2.0.  """ -import logging  import os  import json @@ -55,8 +54,9 @@ from twisted.internet import defer  from twisted.enterprise import adbapi  from leap.soledad.common.document import SoledadDocument -from leap.soledad.common import l2db +from leap.soledad.common.log import getLogger  from leap.soledad.common.l2db import errors as u1db_errors +from leap.soledad.common.l2db import Document  from leap.soledad.common.l2db.backends import sqlite_backend  from leap.soledad.common.errors import DatabaseAccessError @@ -65,7 +65,7 @@ from leap.soledad.client.sync import SoledadSynchronizer  from leap.soledad.client import pragmas -logger = logging.getLogger(__name__) +logger = getLogger(__name__)  # Monkey-patch u1db.backends.sqlite_backend with pysqlcipher.dbapi2 @@ -448,7 +448,6 @@ class SQLCipherU1DBSync(SQLCipherDatabase):          self.received_docs = []          self.running = False -        self.shutdownID = None          self._db_handle = None          # initialize the main db before scheduling a start @@ -465,8 +464,6 @@ class SQLCipherU1DBSync(SQLCipherDatabase):      def _start(self):          if not self.running: -            self.shutdownID = self._reactor.addSystemEventTrigger( -                'during', 'shutdown', self.finalClose)              self.running = True      def _initialize_main_db(self): @@ -561,13 +558,6 @@ class SQLCipherU1DBSync(SQLCipherDatabase):          # XXX this SHOULD BE a callback          return self._get_generation() -    def finalClose(self): -        """ -        This should only be called by the shutdown trigger. -        """ -        self.shutdownID = None -        self.running = False -      def close(self):          """          Close the syncer and syncdb orderly @@ -578,6 +568,7 @@ class SQLCipherU1DBSync(SQLCipherDatabase):              _, syncer = self._syncers[url]              syncer.close()              del self._syncers[url] +        self.running = False  class U1DBSQLiteBackend(sqlite_backend.SQLitePartialExpandDatabase): @@ -595,7 +586,7 @@ class U1DBSQLiteBackend(sqlite_backend.SQLitePartialExpandDatabase):          self._db_handle = conn          self._real_replica_uid = None          self._ensure_schema() -        self._factory = l2db.Document +        self._factory = Document  class SoledadSQLCipherWrapper(SQLCipherDatabase): diff --git a/client/src/leap/soledad/client/sync.py b/client/src/leap/soledad/client/sync.py index 2656a150..7ed5f693 100644 --- a/client/src/leap/soledad/client/sync.py +++ b/client/src/leap/soledad/client/sync.py @@ -18,17 +18,16 @@  Soledad synchronization utilities.  """  import os -import time -import logging  from twisted.internet import defer +from leap.soledad.common.log import getLogger  from leap.soledad.common.l2db import errors  from leap.soledad.common.l2db.sync import Synchronizer  from leap.soledad.common.errors import BackendNotReadyError -logger = logging.getLogger(__name__) +logger = getLogger(__name__)  # we may want to collect statistics from the sync process @@ -97,21 +96,17 @@ class SoledadSynchronizer(Synchronizer):                  sync_target.get_sync_info(self.source._replica_uid)          except (errors.DatabaseDoesNotExist, BackendNotReadyError) as e:              logger.debug("Database isn't ready on server. Will be created.") -            logger.debug("Reason: %s", e.__class__) +            logger.debug("Reason: %s" % e.__class__)              self.target_replica_uid = None              target_gen, target_trans_id = 0, ''              target_my_gen, target_my_trans_id = 0, '' -        logger.debug( -            "Soledad target sync info:\n" -            "  target replica uid: %s\n" -            "  target generation: %d\n" -            "  target trans id: %s\n" -            "  target my gen: %d\n" -            "  target my trans_id: %s\n" -            "  source replica_uid: %s\n" -            % (self.target_replica_uid, target_gen, target_trans_id, -               target_my_gen, target_my_trans_id, self.source._replica_uid)) +        logger.debug("target replica uid: %s" % self.target_replica_uid) +        logger.debug("target generation: %d" % target_gen) +        logger.debug("target trans id: %s" % target_trans_id) +        logger.debug("target my gen: %d" % target_my_gen) +        logger.debug("target my trans_id: %s" % target_my_trans_id) +        logger.debug("source replica_uid: %s" % self.source._replica_uid)          # make sure we'll have access to target replica uid once it exists          if self.target_replica_uid is None: @@ -134,8 +129,7 @@ class SoledadSynchronizer(Synchronizer):          # what's changed since that generation and this current gen          my_gen, _, changes = self.source.whats_changed(target_my_gen) -        logger.debug("Soledad sync: there are %d documents to send." -                     % len(changes)) +        logger.debug("there are %d documents to send" % len(changes))          # get source last-seen database generation for the target          if self.target_replica_uid is None: @@ -144,11 +138,10 @@ class SoledadSynchronizer(Synchronizer):              target_last_known_gen, target_last_known_trans_id = \                  self.source._get_replica_gen_and_trans_id(                      self.target_replica_uid) -        logger.debug( -            "Soledad source sync info:\n" -            "  last target gen known to source: %d\n" -            "  last target trans_id known to source: %s" -            % (target_last_known_gen, target_last_known_trans_id)) +            logger.debug( +                "last known target gen: %d" % target_last_known_gen) +            logger.debug( +                "last known target trans_id: %s" % target_last_known_trans_id)          # validate transaction ids          if not changes and target_last_known_gen == target_gen: @@ -181,11 +174,8 @@ class SoledadSynchronizer(Synchronizer):              target_last_known_gen, target_last_known_trans_id,              self._insert_doc_from_target, ensure_callback=ensure_callback,              defer_decryption=defer_decryption) -        logger.debug( -            "Soledad source sync info after sync exchange:\n" -            "  source known target gen: %d\n" -            "  source known target trans_id: %s" -            % (new_gen, new_trans_id)) +        logger.debug("target gen after sync: %d" % new_gen) +        logger.debug("target trans_id after sync: %s" % new_trans_id)          info = {              "target_replica_uid": self.target_replica_uid,              "new_gen": new_gen, @@ -224,7 +214,7 @@ class SoledadSynchronizer(Synchronizer):          :return: A deferred which will fire when the sync has been completed.          :rtype: twisted.internet.defer.Deferred          """ -        logger.debug("Completing deferred last step in SYNC...") +        logger.debug("completing deferred last step in sync...")          # record target synced-up-to generation including applying what we          # sent diff --git a/common/pkg/requirements-latest.pip b/common/pkg/requirements-latest.pip index 396d77f1..852f2433 100644 --- a/common/pkg/requirements-latest.pip +++ b/common/pkg/requirements-latest.pip @@ -1,4 +1,4 @@  --index-url https://pypi.python.org/simple/ --e 'git+https://github.com/pixelated-project/leap_pycommon.git@develop#egg=leap.common' +-e 'git+https://github.com/leapcode/leap_pycommon.git@develop#egg=leap.common'  -e . diff --git a/common/pkg/requirements-testing.pip b/common/pkg/requirements-testing.pip deleted file mode 100644 index 526b7101..00000000 --- a/common/pkg/requirements-testing.pip +++ /dev/null @@ -1,14 +0,0 @@ -mock -testscenarios -setuptools-trial -pep8 - -#---------------------------------------------------------------------- -#Right now, common tests also depend on having the other soledad -#modules installed. Commenting to avoid versioning problem, you should -#know what you are testing against :) -#---------------------------------------------------------------------- - -#leap.common>=0.4.0 -#leap.soledad.server>=0.7.0 -#leap.soledad.client>=0.7.0 diff --git a/common/setup.py b/common/setup.py index 7191fa00..bb70d587 100644 --- a/common/setup.py +++ b/common/setup.py @@ -17,13 +17,8 @@  """  setup file for leap.soledad.common  """ -import binascii -import json -from os import listdir -from os.path import realpath, dirname, isdir, join, isfile, basename  import re -from distutils.command.build import build as _build  from setuptools import setup  from setuptools import find_packages  from setuptools import Command @@ -110,117 +105,6 @@ def get_versions():          with open(versioneer_cfg.versionfile_source, 'w') as f:              f.write(subst_template) -cmdclass = versioneer.get_cmdclass() - -# -# Couch backend design docs file generation. -# - -old_cmd_sdist = cmdclass["sdist"] - - -def build_ddocs_py(basedir=None, with_src=True): -    """ -    Build `ddocs.py` file. - -    For ease of development, couch backend design documents are stored as -    `.js` files in  subdirectories of `src/leap/soledad/common/ddocs`. This -    function scans that directory for javascript files, builds the design -    documents structure, and encode those structures in the `ddocs.py` file. - -    This function is used when installing in develop mode, building or -    generating source distributions (see the next classes and the `cmdclass` -    setuptools parameter. - -    This funciton uses the following conventions to generate design documents: - -      - Design documents are represented by directories in the form -        `<prefix>/<ddoc>`, there prefix is the `src/leap/soledad/common/ddocs` -        directory. -      - Design document directories might contain `views`, `lists` and -        `updates` subdirectories. -      - Views subdirectories must contain a `map.js` file and may contain a -        `reduce.js` file. -      - List and updates subdirectories may contain any number of javascript -        files (i.e. ending in `.js`) whose names will be mapped to the -        corresponding list or update function name. -    """ -    cur_pwd = dirname(realpath(__file__)) -    common_path = ('src', 'leap', 'soledad', 'common') -    dest_common_path = common_path -    if not with_src: -        dest_common_path = common_path[1:] -    prefix = join(cur_pwd, *common_path) - -    dest_prefix = prefix -    if basedir is not None: -        # we're bulding a sdist -        dest_prefix = join(basedir, *dest_common_path) - -    ddocs_prefix = join(prefix, 'ddocs') - -    if not isdir(ddocs_prefix): -        print "No ddocs/ folder, bailing out..." -        return - -    ddocs = {} - -    # design docs are represented by subdirectories of `ddocs_prefix` -    for ddoc in [f for f in listdir(ddocs_prefix) -                 if isdir(join(ddocs_prefix, f))]: - -        ddocs[ddoc] = {'_id': '_design/%s' % ddoc} - -        for t in ['views', 'lists', 'updates']: -            tdir = join(ddocs_prefix, ddoc, t) -            if isdir(tdir): - -                ddocs[ddoc][t] = {} - -                if t == 'views':  # handle views (with map/reduce functions) -                    for view in [f for f in listdir(tdir) -                                 if isdir(join(tdir, f))]: -                        # look for map.js and reduce.js -                        mapfile = join(tdir, view, 'map.js') -                        reducefile = join(tdir, view, 'reduce.js') -                        mapfun = None -                        reducefun = None -                        try: -                            with open(mapfile) as f: -                                mapfun = f.read() -                        except IOError: -                            pass -                        try: -                            with open(reducefile) as f: -                                reducefun = f.read() -                        except IOError: -                            pass -                        ddocs[ddoc]['views'][view] = {} - -                        if mapfun is not None: -                            ddocs[ddoc]['views'][view]['map'] = mapfun -                        if reducefun is not None: -                            ddocs[ddoc]['views'][view]['reduce'] = reducefun - -                else:  # handle lists, updates, etc -                    for fun in [f for f in listdir(tdir) -                                if isfile(join(tdir, f))]: -                        funfile = join(tdir, fun) -                        funname = basename(funfile).replace('.js', '') -                        try: -                            with open(funfile) as f: -                                ddocs[ddoc][t][funname] = f.read() -                        except IOError: -                            pass -    # write file containing design docs strings -    ddoc_filename = "ddocs.py" -    with open(join(dest_prefix, ddoc_filename), 'w') as f: -        for ddoc in ddocs: -            f.write( -                "%s = '%s'\n" % -                (ddoc, binascii.b2a_base64(json.dumps(ddocs[ddoc]))[:-1])) -    print "Wrote design docs in %s" % (dest_prefix + '/' + ddoc_filename,) -  class cmd_develop(_cmd_develop):      def run(self): @@ -230,17 +114,10 @@ class cmd_develop(_cmd_develop):          # unless we update this, the command will keep using the old version          self.distribution.metadata.version = versions["version"]          _cmd_develop.run(self) -        build_ddocs_py() - - -class cmd_build(_build): -    def run(self): -        _build.run(self) -        build_ddocs_py(basedir=self.build_lib, with_src=False) +cmdclass = versioneer.get_cmdclass()  cmdclass["freeze_debianver"] = freeze_debianver -cmdclass["build"] = cmd_build  cmdclass["develop"] = cmd_develop @@ -250,13 +127,13 @@ requirements = utils.parse_requirements()  if utils.is_develop_mode():      print -    print ("[WARNING] Skipping leap-specific dependencies " -           "because development mode is detected.") -    print ("[WARNING] You can install " -           "the latest published versions with " -           "'pip install -r pkg/requirements-leap.pip'") -    print ("[WARNING] Or you can instead do 'python setup.py develop' " -           "from the parent folder of each one of them.") +    print("[WARNING] Skipping leap-specific dependencies " +          "because development mode is detected.") +    print("[WARNING] You can install " +          "the latest published versions with " +          "'pip install -r pkg/requirements-leap.pip'") +    print("[WARNING] Or you can instead do 'python setup.py develop' " +          "from the parent folder of each one of them.")      print  else:      requirements += utils.parse_requirements( @@ -287,6 +164,4 @@ setup(      package_data={'': ["*.sql"]},      test_suite='leap.soledad.common.tests',      install_requires=requirements, -    tests_require=utils.parse_requirements( -        reqfiles=['pkg/requirements-testing.pip']),  ) diff --git a/common/src/leap/soledad/common/.gitignore b/common/src/leap/soledad/common/.gitignore deleted file mode 100644 index 3378c78a..00000000 --- a/common/src/leap/soledad/common/.gitignore +++ /dev/null @@ -1 +0,0 @@ -ddocs.py diff --git a/common/src/leap/soledad/common/README.txt b/common/src/leap/soledad/common/README.txt index 38b9858e..0a252650 100644 --- a/common/src/leap/soledad/common/README.txt +++ b/common/src/leap/soledad/common/README.txt @@ -60,15 +60,6 @@ implemented in a way that all changes will be pushed with just one operation.      * delete_index      * create_index -Couch views and update functions are used in order to achieve atomicity on the -Couch backend. Transactions are stored in the `u1db_transactions` field of the -couch document. Document's content and conflicted versions are stored as couch -document attachments with names, respectivelly, `u1db_content` and -`u1db_conflicts`. - -A map of methods and couch query URI can be found on the `./ddocs/README.txt` -document. -  Notes:    * Currently, the couch backend does not implement indexing, so what is diff --git a/common/src/leap/soledad/common/couch/__init__.py b/common/src/leap/soledad/common/couch/__init__.py index 523a50a0..0f4102db 100644 --- a/common/src/leap/soledad/common/couch/__init__.py +++ b/common/src/leap/soledad/common/couch/__init__.py @@ -23,21 +23,17 @@ import json  import re  import uuid  import binascii -import time -import functools  from StringIO import StringIO  from urlparse import urljoin  from contextlib import contextmanager -from multiprocessing.pool import ThreadPool  from couchdb.client import Server, Database  from couchdb.http import (      ResourceConflict,      ResourceNotFound, -    ServerError,      Session,      urljoin as couch_urljoin,      Resource, @@ -50,9 +46,6 @@ from leap.soledad.common.l2db.errors import (  from leap.soledad.common.l2db.remote import http_app -from leap.soledad.common import ddocs -from .errors import raise_server_error -from .errors import raise_missing_design_doc_error  from .support import MultipartWriter  from leap.soledad.common.errors import InvalidURLError  from leap.soledad.common.document import ServerDocument @@ -100,7 +93,19 @@ def couch_server(url):      yield server -THREAD_POOL = ThreadPool(20) +def _get_gen_doc_id(gen): +    return 'gen-%s' % str(gen).zfill(10) + + +GENERATION_KEY = 'gen' +TRANSACTION_ID_KEY = 'trans_id' +REPLICA_UID_KEY = 'replica_uid' +DOC_ID_KEY = 'doc_id' +SCHEMA_VERSION_KEY = 'schema_version' + +CONFIG_DOC_ID = '_local/config' +SYNC_DOC_ID_PREFIX = '_local/sync_' +SCHEMA_VERSION = 1  class CouchDatabase(object): @@ -111,7 +116,7 @@ class CouchDatabase(object):      """      @classmethod -    def open_database(cls, url, create, ensure_ddocs=False, replica_uid=None, +    def open_database(cls, url, create, replica_uid=None,                        database_security=None):          """          Open a U1DB database using CouchDB as backend. @@ -122,8 +127,6 @@ class CouchDatabase(object):          :type create: bool          :param replica_uid: an optional unique replica identifier          :type replica_uid: str -        :param ensure_ddocs: Ensure that the design docs exist on server. -        :type ensure_ddocs: bool          :param database_security: security rules as CouchDB security doc          :type database_security: dict @@ -144,21 +147,20 @@ class CouchDatabase(object):                      server.create(dbname)                  else:                      raise DatabaseDoesNotExist() -        db = cls(url, -                 dbname, ensure_ddocs=ensure_ddocs, +        db = cls(url, dbname, ensure_security=create,                   database_security=database_security)          return SoledadBackend(              db, replica_uid=replica_uid) -    def __init__(self, url, dbname, ensure_ddocs=True, +    def __init__(self, url, dbname, ensure_security=False,                   database_security=None):          """          :param url: Couch server URL with necessary credentials          :type url: string          :param dbname: Couch database name          :type dbname: string -        :param ensure_ddocs: Ensure that the design docs exist on server. -        :type ensure_ddocs: bool +        :param ensure_security: will PUT a _security ddoc if set +        :type ensure_security: bool          :param database_security: security rules as CouchDB security doc          :type database_security: dict          """ @@ -169,8 +171,7 @@ class CouchDatabase(object):          self.batching = False          self.batch_generation = None          self.batch_docs = {} -        if ensure_ddocs: -            self.ensure_ddocs_on_db() +        if ensure_security:              self.ensure_security_ddoc(database_security)      def batch_start(self): @@ -205,22 +206,6 @@ class CouchDatabase(object):          except ResourceNotFound:              raise DatabaseDoesNotExist() -    def ensure_ddocs_on_db(self): -        """ -        Ensure that the design documents used by the backend exist on the -        couch database. -        """ -        for ddoc_name in ['docs', 'syncs', 'transactions']: -            try: -                self.json_from_resource(['_design'] + -                                        ddoc_name.split('/') + ['_info'], -                                        check_missing_ddoc=False) -            except ResourceNotFound: -                ddoc = json.loads( -                    binascii.a2b_base64( -                        getattr(ddocs, ddoc_name))) -                self._database.save(ddoc) -      def ensure_security_ddoc(self, security_config=None):          """          Make sure that only soledad user is able to access this database as @@ -261,13 +246,14 @@ class CouchDatabase(object):          """          try:              # set on existent config document -            doc = self._database['u1db_config'] -            doc['replica_uid'] = replica_uid +            doc = self._database[CONFIG_DOC_ID] +            doc[REPLICA_UID_KEY] = replica_uid          except ResourceNotFound:              # or create the config document              doc = { -                '_id': 'u1db_config', -                'replica_uid': replica_uid, +                '_id': CONFIG_DOC_ID, +                REPLICA_UID_KEY: replica_uid, +                SCHEMA_VERSION_KEY: SCHEMA_VERSION,              }          self._database.save(doc) @@ -280,8 +266,8 @@ class CouchDatabase(object):          """          try:              # grab replica_uid from server -            doc = self._database['u1db_config'] -            replica_uid = doc['replica_uid'] +            doc = self._database[CONFIG_DOC_ID] +            replica_uid = doc[REPLICA_UID_KEY]              return replica_uid          except ResourceNotFound:              # create a unique replica_uid @@ -308,8 +294,8 @@ class CouchDatabase(object):          """          generation, _ = self.get_generation_info() -        results = list(self.get_docs(self._database, -                                     include_deleted=include_deleted)) +        results = list( +            self._get_docs(None, True, include_deleted))          return (generation, results)      def get_docs(self, doc_ids, check_for_conflicts=True, @@ -330,24 +316,37 @@ class CouchDatabase(object):                   in matching doc_ids order.          :rtype: iterable          """ -        # Workaround for: -        # -        #   http://bugs.python.org/issue7980 -        #   https://leap.se/code/issues/5449 -        # -        # python-couchdb uses time.strptime, which is not thread safe. In -        # order to avoid the problem described on the issues above, we preload -        # strptime here by evaluating the conversion of an arbitrary date. -        # This will not be needed when/if we switch from python-couchdb to -        # paisley. -        time.strptime('Mar 8 1917', '%b %d %Y') -        get_one = functools.partial( -            self.get_doc, check_for_conflicts=check_for_conflicts) -        docs = [THREAD_POOL.apply_async(get_one, [doc_id]) -                for doc_id in doc_ids] -        for doc in docs: -            doc = doc.get() -            if not doc or not include_deleted and doc.is_tombstone(): +        return self._get_docs(doc_ids, check_for_conflicts, include_deleted) + +    def _get_docs(self, doc_ids, check_for_conflicts, include_deleted): +        """ +        Use couch's `_all_docs` view to get the documents indicated in +        `doc_ids`, + +        :param doc_ids: A list of document identifiers or None for all. +        :type doc_ids: list +        :param check_for_conflicts: If set to False, then the conflict check +                                    will be skipped, and 'None' will be +                                    returned instead of True/False. +        :type check_for_conflicts: bool +        :param include_deleted: If set to True, deleted documents will be +                                returned with empty content. Otherwise deleted +                                documents will not be included in the results. + +        :return: iterable giving the Document object for each document id +                 in matching doc_ids order. +        :rtype: iterable +        """ +        params = {'include_docs': 'true', 'attachments': 'true'} +        if doc_ids is not None: +            params['keys'] = doc_ids +        view = self._database.view("_all_docs", **params) +        for row in view.rows: +            result = row['doc'] +            doc = self.__parse_doc_from_couch( +                result, result['_id'], check_for_conflicts=check_for_conflicts) +            # filter out non-u1db or deleted documents +            if not doc or (not include_deleted and doc.is_tombstone()):                  continue              yield doc @@ -434,8 +433,6 @@ class CouchDatabase(object):                          result['_attachments']['u1db_conflicts']['data']))))          # store couch revision          doc.couch_rev = result['_rev'] -        # store transactions -        doc.transactions = result['u1db_transactions']          return doc      def _build_conflicts(self, doc_id, attached_conflicts): @@ -471,14 +468,11 @@ class CouchDatabase(object):          """          if generation == 0:              return '' -        # query a couch list function -        ddoc_path = [ -            '_design', 'transactions', '_list', 'trans_id_for_gen', 'log' -        ] -        response = self.json_from_resource(ddoc_path, gen=generation) -        if response == {}: +        log = self._get_transaction_log(start=generation, end=generation) +        if not log:              raise InvalidGeneration -        return response['transaction_id'] +        _, _, trans_id = log[0] +        return trans_id      def get_replica_gen_and_trans_id(self, other_replica_uid):          """ @@ -499,18 +493,19 @@ class CouchDatabase(object):                   synchronized with the replica, this is (0, '').          :rtype: (int, str)          """ -        doc_id = 'u1db_sync_%s' % other_replica_uid +        doc_id = '%s%s' % (SYNC_DOC_ID_PREFIX, other_replica_uid)          try:              doc = self._database[doc_id]          except ResourceNotFound:              doc = {                  '_id': doc_id, -                'generation': 0, -                'transaction_id': '', +                GENERATION_KEY: 0, +                REPLICA_UID_KEY: str(other_replica_uid), +                TRANSACTION_ID_KEY: '',              }              self._database.save(doc) -        result = doc['generation'], doc['transaction_id'] -        return result +        gen, trans_id = doc[GENERATION_KEY], doc[TRANSACTION_ID_KEY] +        return gen, trans_id      def get_doc_conflicts(self, doc_id, couch_rev=None):          """ @@ -537,7 +532,6 @@ class CouchDatabase(object):          try:              response = self.json_from_resource([doc_id, 'u1db_conflicts'], -                                               check_missing_ddoc=False,                                                 **params)              return conflicts + self._build_conflicts(                  doc_id, json.loads(response.read())) @@ -562,13 +556,13 @@ class CouchDatabase(object):                                       generation.          :type other_transaction_id: str          """ -        doc_id = 'u1db_sync_%s' % other_replica_uid +        doc_id = '%s%s' % (SYNC_DOC_ID_PREFIX, other_replica_uid)          try:              doc = self._database[doc_id]          except ResourceNotFound:              doc = {'_id': doc_id} -        doc['generation'] = other_generation -        doc['transaction_id'] = other_transaction_id +        doc[GENERATION_KEY] = other_generation +        doc[TRANSACTION_ID_KEY] = other_transaction_id          self._database.save(doc)      def get_transaction_log(self): @@ -578,12 +572,35 @@ class CouchDatabase(object):          :return: The complete transaction log.          :rtype: [(str, str)]          """ -        # query a couch view -        ddoc_path = ['_design', 'transactions', '_view', 'log'] -        response = self.json_from_resource(ddoc_path) -        return map( -            lambda row: (row['id'], row['value']), -            response['rows']) +        log = self._get_transaction_log() +        return map(lambda i: (i[1], i[2]), log) + +    def _get_gen_docs( +            self, start=0, end=9999999999, descending=None, limit=None): +        params = {} +        if descending: +            params['descending'] = 'true' +            # honor couch way of traversing the view tree in reverse order +            start, end = end, start +        params['startkey'] = _get_gen_doc_id(start) +        params['endkey'] = _get_gen_doc_id(end) +        params['include_docs'] = 'true' +        if limit: +            params['limit'] = limit +        view = self._database.view("_all_docs", **params) +        return view.rows + +    def _get_transaction_log(self, start=0, end=9999999999): +        # get current gen and trans_id +        rows = self._get_gen_docs(start=start, end=end) +        log = [] +        for row in rows: +            doc = row['doc'] +            log.append(( +                doc[GENERATION_KEY], +                doc[DOC_ID_KEY], +                doc[TRANSACTION_ID_KEY])) +        return log      def whats_changed(self, old_generation=0):          """ @@ -602,32 +619,16 @@ class CouchDatabase(object):                   changes first)          :rtype: (int, str, [(str, int, str)])          """ -        # query a couch list function -        ddoc_path = [ -            '_design', 'transactions', '_list', 'whats_changed', 'log' -        ] -        response = self.json_from_resource(ddoc_path, old_gen=old_generation) -        results = map( -            lambda row: -                (row['generation'], row['doc_id'], row['transaction_id']), -            response['transactions']) -        results.reverse() -        cur_gen = old_generation -        seen = set()          changes = [] -        newest_trans_id = '' -        for generation, doc_id, trans_id in results: +        cur_generation, last_trans_id = self.get_generation_info() +        relevant_tail = self._get_transaction_log(start=old_generation + 1) +        seen = set() +        for generation, doc_id, trans_id in reversed(relevant_tail):              if doc_id not in seen:                  changes.append((doc_id, generation, trans_id))                  seen.add(doc_id) -        if changes: -            cur_gen = changes[0][1]  # max generation -            newest_trans_id = changes[0][2] -            changes.reverse() -        else: -            cur_gen, newest_trans_id = self.get_generation_info() - -        return cur_gen, newest_trans_id, changes +        changes.reverse() +        return (cur_generation, last_trans_id, changes)      def get_generation_info(self):          """ @@ -638,53 +639,74 @@ class CouchDatabase(object):          """          if self.batching and self.batch_generation:              return self.batch_generation -        # query a couch list function -        ddoc_path = ['_design', 'transactions', '_list', 'generation', 'log'] -        info = self.json_from_resource(ddoc_path) -        return (info['generation'], info['transaction_id']) +        rows = self._get_gen_docs(descending=True, limit=1) +        if not rows: +            return 0, '' +        gen_doc = rows.pop()['doc'] +        return gen_doc[GENERATION_KEY], gen_doc[TRANSACTION_ID_KEY] -    def json_from_resource(self, ddoc_path, check_missing_ddoc=True, -                           **kwargs): +    def json_from_resource(self, doc_path, **kwargs):          """          Get a resource from it's path and gets a doc's JSON using provided -        parameters, also checking for missing design docs by default. +        parameters. -        :param ddoc_path: The path to resource. -        :type ddoc_path: [str] -        :param check_missing_ddoc: Raises info on what design doc is missing. -        :type check_missin_ddoc: bool +        :param doc_path: The path to resource. +        :type doc_path: [str]          :return: The request's data parsed from JSON to a dict.          :rtype: dict - -        :raise MissingDesignDocError: Raised when tried to access a missing -                                      design document. -        :raise MissingDesignDocListFunctionError: Raised when trying to access -                                                  a missing list function on a -                                                  design document. -        :raise MissingDesignDocNamedViewError: Raised when trying to access a -                                               missing named view on a design -                                               document. -        :raise MissingDesignDocDeletedError: Raised when trying to access a -                                             deleted design document. -        :raise MissingDesignDocUnknownError: Raised when failed to access a -                                             design document for an yet -                                             unknown reason. -        """ -        if ddoc_path is not None: -            resource = self._database.resource(*ddoc_path) +        """ +        if doc_path is not None: +            resource = self._database.resource(*doc_path)          else:              resource = self._database.resource() -        try: -            _, _, data = resource.get_json(**kwargs) -            return data -        except ResourceNotFound as e: -            if check_missing_ddoc: -                raise_missing_design_doc_error(e, ddoc_path) -            else: -                raise e -        except ServerError as e: -            raise_server_error(e, ddoc_path) +        _, _, data = resource.get_json(**kwargs) +        return data + +    def _allocate_new_generation(self, doc_id, transaction_id): +        """ +        Allocate a new generation number for a document modification. + +        We need to allocate a new generation to this document modification by +        creating a new gen doc. In order to avoid concurrent database updates +        from allocating the same new generation, we will try to create the +        document until we succeed, meaning that no other piece of code holds +        the same generation number as ours. + +        The loop below would only be executed more than once if: + +          1. there's more than one thread trying to modify the user's database, +             and + +          2. the execution of getting the current generation and saving the gen +             doc different threads get interleaved (one of them will succeed +             and the others will fail and try again). + +        Number 1 only happens when more than one user device is syncing at the +        same time. Number 2 depends on not-so-frequent coincidence of +        code execution. + +        Also, in the race between threads for a generation number there's +        always one thread that wins. so if there are N threads in the race, the +        expected number of repetitions of the loop for each thread would be +        N/2. If N is equal to the number of devices that the user has, the +        number of possible repetitions of the loop should always be low. +        """ +        while True: +            try: +                # add the gen document +                gen, _ = self.get_generation_info() +                new_gen = gen + 1 +                gen_doc = { +                    '_id': _get_gen_doc_id(new_gen), +                    GENERATION_KEY: new_gen, +                    DOC_ID_KEY: doc_id, +                    TRANSACTION_ID_KEY: transaction_id, +                } +                self._database.save(gen_doc) +                break  # succeeded allocating a new generation, proceed +            except ResourceConflict: +                pass  # try again!      def save_document(self, old_doc, doc, transaction_id):          """ @@ -701,19 +723,6 @@ class CouchDatabase(object):          :raise RevisionConflict: Raised when trying to update a document but                                   couch revisions mismatch. -        :raise MissingDesignDocError: Raised when tried to access a missing -                                      design document. -        :raise MissingDesignDocListFunctionError: Raised when trying to access -                                                  a missing list function on a -                                                  design document. -        :raise MissingDesignDocNamedViewError: Raised when trying to access a -                                               missing named view on a design -                                               document. -        :raise MissingDesignDocDeletedError: Raised when trying to access a -                                             deleted design document. -        :raise MissingDesignDocUnknownError: Raised when failed to access a -                                             design document for an yet -                                             unknown reason.          """          attachments = {}  # we save content and conflicts as attachments          parts = []  # and we put it using couch's multipart PUT @@ -726,6 +735,7 @@ class CouchDatabase(object):                  'length': len(content),              }              parts.append(content) +          # save conflicts as attachment          if doc.has_conflicts is True:              conflicts = json.dumps( @@ -737,21 +747,11 @@ class CouchDatabase(object):                  'length': len(conflicts),              }              parts.append(conflicts) -        # store old transactions, if any -        transactions = old_doc.transactions[:] if old_doc is not None else [] -        # create a new transaction id and timestamp it so the transaction log -        # is consistent when querying the database. -        transactions.append( -            # here we store milliseconds to keep consistent with javascript -            # Date.prototype.getTime() which was used before inside a couchdb -            # update handler. -            (int(time.time() * 1000), -             transaction_id)) +          # build the couch document          couch_doc = {              '_id': doc.doc_id,              'u1db_rev': doc.rev, -            'u1db_transactions': transactions,              '_attachments': attachments,          }          # if we are updating a doc we have to add the couch doc revision @@ -761,7 +761,19 @@ class CouchDatabase(object):          if not self.batching:              buf = StringIO()              envelope = MultipartWriter(buf) -            envelope.add('application/json', json.dumps(couch_doc)) +            # the order in which attachments are described inside the +            # serialization of the couch document must match the order in +            # which they are actually written in the multipart structure. +            # Because of that, we use `sorted_keys=True` in the json +            # serialization (so "u1db_conflicts" comes before +            # "u1db_content" on the couch document attachments +            # description), and also reverse the order of the parts before +            # writing them, so the "conflict" part is written before the +            # "content" part. +            envelope.add( +                'application/json', +                json.dumps(couch_doc, sort_keys=True)) +            parts.reverse()              for part in parts:                  envelope.add('application/octet-stream', part)              envelope.close() @@ -778,12 +790,14 @@ class CouchDatabase(object):                  del attachment['follows']                  del attachment['length']                  index = 0 if name is 'u1db_content' else 1 -                attachment['data'] = binascii.b2a_base64(parts[index]).strip() +                attachment['data'] = binascii.b2a_base64( +                    parts[index]).strip()              couch_doc['_attachments'] = attachments              self.batch_docs[doc.doc_id] = couch_doc              last_gen, last_trans_id = self.batch_generation              self.batch_generation = (last_gen + 1, transaction_id) -        return transactions[-1][1] + +        self._allocate_new_generation(doc.doc_id, transaction_id)      def _new_resource(self, *path):          """ diff --git a/common/src/leap/soledad/common/couch/errors.py b/common/src/leap/soledad/common/couch/errors.py deleted file mode 100644 index 9b287c76..00000000 --- a/common/src/leap/soledad/common/couch/errors.py +++ /dev/null @@ -1,144 +0,0 @@ -# -*- coding: utf-8 -*- -# errors.py -# Copyright (C) 2015 LEAP -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. -from leap.soledad.common.errors import SoledadError, BackendNotReadyError -from leap.soledad.common.errors import register_exception - -""" -Specific errors that can be raised by CouchDatabase. -""" - - -@register_exception -class MissingDesignDocError(BackendNotReadyError): - -    """ -    Raised when trying to access a missing couch design document. -    """ - -    wire_description = "missing design document" -    status = 500 - - -@register_exception -class MissingDesignDocNamedViewError(SoledadError): - -    """ -    Raised when trying to access a missing named view on a couch design -    document. -    """ - -    wire_description = "missing design document named function" -    status = 500 - - -@register_exception -class MissingDesignDocListFunctionError(SoledadError): - -    """ -    Raised when trying to access a missing list function on a couch design -    document. -    """ - -    wire_description = "missing design document list function" -    status = 500 - - -@register_exception -class MissingDesignDocDeletedError(SoledadError): - -    """ -    Raised when trying to access a deleted couch design document. -    """ - -    wire_description = "design document was deleted" -    status = 500 - - -@register_exception -class DesignDocUnknownError(SoledadError): - -    """ -    Raised when trying to access a couch design document and getting an -    unknown error. -    """ - -    wire_description = "missing design document unknown error" -    status = 500 - - -def raise_missing_design_doc_error(exc, ddoc_path): -    """ -    Raise an appropriate exception when catching a ResourceNotFound when -    accessing a design document. - -    :param exc: The exception cought. -    :type exc: ResourceNotFound -    :param ddoc_path: A list representing the requested path. -    :type ddoc_path: list - -    :raise MissingDesignDocError: Raised when tried to access a missing design -                                  document. -    :raise MissingDesignDocListFunctionError: Raised when trying to access a -                                              missing list function on a -                                              design document. -    :raise MissingDesignDocNamedViewError: Raised when trying to access a -                                           missing named view on a design -                                           document. -    :raise MissingDesignDocDeletedError: Raised when trying to access a -                                         deleted design document. -    :raise MissingDesignDocUnknownError: Raised when failed to access a design -                                         document for an yet unknown reason. -    """ -    path = "".join(ddoc_path) -    if exc.message[1] == 'missing': -        raise MissingDesignDocError(path) -    elif exc.message[1] == 'missing function' or \ -            exc.message[1].startswith('missing lists function'): -        raise MissingDesignDocListFunctionError(path) -    elif exc.message[1] == 'missing_named_view': -        raise MissingDesignDocNamedViewError(path) -    elif exc.message[1] == 'deleted': -        raise MissingDesignDocDeletedError(path) -    # other errors are unknown for now -    raise DesignDocUnknownError("%s: %s" % (path, str(exc.message))) - - -def raise_server_error(exc, ddoc_path): -    """ -    Raise an appropriate exception when catching a ServerError when -    accessing a design document. - -    :param exc: The exception cought. -    :type exc: ResourceNotFound -    :param ddoc_path: A list representing the requested path. -    :type ddoc_path: list - -    :raise MissingDesignDocListFunctionError: Raised when trying to access a -                                              missing list function on a -                                              design document. -    :raise MissingDesignDocUnknownError: Raised when failed to access a design -                                         document for an yet unknown reason. -    """ -    path = "".join(ddoc_path) -    msg = exc.message[1][0] -    if msg == 'unnamed_error': -        raise MissingDesignDocListFunctionError(path) -    elif msg == 'TypeError': -        if 'point is undefined' in exc.message[1][1]: -            raise MissingDesignDocListFunctionError -    # other errors are unknown for now -    raise DesignDocUnknownError("%s: %s" % (path, str(exc.message))) diff --git a/common/src/leap/soledad/common/couch/state.py b/common/src/leap/soledad/common/couch/state.py index 9b40a264..523ac0b0 100644 --- a/common/src/leap/soledad/common/couch/state.py +++ b/common/src/leap/soledad/common/couch/state.py @@ -17,20 +17,26 @@  """  Server state using CouchDatabase as backend.  """ -import logging +import couchdb  import re  import time  from urlparse import urljoin  from hashlib import sha512 +from leap.soledad.common.log import getLogger  from leap.soledad.common.couch import CouchDatabase  from leap.soledad.common.couch import couch_server +from leap.soledad.common.couch import CONFIG_DOC_ID +from leap.soledad.common.couch import SCHEMA_VERSION +from leap.soledad.common.couch import SCHEMA_VERSION_KEY  from leap.soledad.common.command import exec_validated_cmd  from leap.soledad.common.l2db.remote.server_state import ServerState  from leap.soledad.common.l2db.errors import Unauthorized +from leap.soledad.common.errors import WrongCouchSchemaVersionError +from leap.soledad.common.errors import MissingCouchConfigDocumentError -logger = logging.getLogger(__name__) +logger = getLogger(__name__)  def is_db_name_valid(name): @@ -59,15 +65,47 @@ class CouchServerState(ServerState):      TOKENS_TYPE_DEF = "Token"      TOKENS_USER_ID_KEY = "user_id" -    def __init__(self, couch_url, create_cmd=None): +    def __init__(self, couch_url, create_cmd=None, +                 check_schema_versions=False):          """          Initialize the couch server state.          :param couch_url: The URL for the couch database.          :type couch_url: str +        :param create_cmd: Command to be executed for user db creation. It will +                           receive a properly sanitized parameter with user db +                           name and should access CouchDB with necessary +                           privileges, which server lacks for security reasons. +        :type create_cmd: str +        :param check_schema_versions: Whether to check couch schema version of +                                      user dbs. Set to False as this is only +                                      intended to run once during start-up. +        :type check_schema_versions: bool          """          self.couch_url = couch_url          self.create_cmd = create_cmd +        if check_schema_versions: +            self._check_schema_versions() + +    def _check_schema_versions(self): +        """ +        Check that all user databases use the correct couch schema. +        """ +        server = couchdb.client.Server(self.couch_url) +        for dbname in server: +            if not dbname.startswith('user-'): +                continue +            db = server[dbname] + +            # if there are documents, ensure that a config doc exists +            config_doc = db.get(CONFIG_DOC_ID) +            if config_doc: +                if config_doc[SCHEMA_VERSION_KEY] != SCHEMA_VERSION: +                    raise WrongCouchSchemaVersionError(dbname) +            else: +                result = db.view('_all_docs', limit=1) +                if result.total_rows != 0: +                    raise MissingCouchConfigDocumentError(dbname)      def open_database(self, dbname):          """ @@ -80,7 +118,7 @@ class CouchServerState(ServerState):          :rtype: SoledadBackend          """          url = urljoin(self.couch_url, dbname) -        db = CouchDatabase.open_database(url, create=False, ensure_ddocs=False) +        db = CouchDatabase.open_database(url, create=False)          return db      def ensure_database(self, dbname): diff --git a/common/src/leap/soledad/common/ddocs/README.txt b/common/src/leap/soledad/common/ddocs/README.txt deleted file mode 100644 index 5569d929..00000000 --- a/common/src/leap/soledad/common/ddocs/README.txt +++ /dev/null @@ -1,34 +0,0 @@ -This directory holds a folder structure containing javascript files that -represent the design documents needed by the CouchDB U1DB backend. These files -are compiled into the `../ddocs.py` file by setuptools when creating the -source distribution. - -The following table depicts the U1DB CouchDB backend method and the URI that -is queried to obtain/update data from/to the server. - -   +----------------------------------+------------------------------------------------------------------+ -   | u1db backend method              | URI                                                              | -   |----------------------------------+------------------------------------------------------------------| -   | _get_generation                  | _design/transactions/_list/generation/log                        | -   | _get_generation_info             | _design/transactions/_list/generation/log                        | -   | _get_trans_id_for_gen            | _design/transactions/_list/trans_id_for_gen/log                  | -   | _get_transaction_log             | _design/transactions/_view/log                                   | -   | _get_doc (*)                     | _design/docs/_view/get?key=<doc_id>                              | -   | _has_conflicts                   | _design/docs/_view/get?key=<doc_id>                              | -   | get_all_docs                     | _design/docs/_view/get                                           | -   | _put_doc                         | _design/docs/_update/put/<doc_id>                                | -   | _whats_changed                   | _design/transactions/_list/whats_changed/log?old_gen=<gen>       | -   | _get_conflicts (*)               | _design/docs/_view/conflicts?key=<doc_id>                        | -   | _get_replica_gen_and_trans_id    | _design/syncs/_view/log?other_replica_uid=<uid>                  | -   | _do_set_replica_gen_and_trans_id | _design/syncs/_update/put/u1db_sync_log                          | -   | _add_conflict                    | _design/docs/_update/add_conflict/<doc_id>                       | -   | _delete_conflicts                | _design/docs/_update/delete_conflicts/<doc_id>?doc_rev=<doc_rev> | -   | list_indexes                     | not implemented                                                  | -   | _get_index_definition            | not implemented                                                  | -   | delete_index                     | not implemented                                                  | -   | _get_indexed_fields              | not implemented                                                  | -   | _put_and_update_indexes          | not implemented                                                  | -   +----------------------------------+------------------------------------------------------------------+ - -(*) These methods also request CouchDB document attachments that store U1DB -    document contents. diff --git a/common/src/leap/soledad/common/ddocs/docs/views/get/map.js b/common/src/leap/soledad/common/ddocs/docs/views/get/map.js deleted file mode 100644 index ae08d9e9..00000000 --- a/common/src/leap/soledad/common/ddocs/docs/views/get/map.js +++ /dev/null @@ -1,20 +0,0 @@ -function(doc) { -    if (doc.u1db_rev) { -        var is_tombstone = true; -        var has_conflicts = false; -        if (doc._attachments) { -            if (doc._attachments.u1db_content) -                is_tombstone = false; -            if (doc._attachments.u1db_conflicts) -                has_conflicts = true; -        } -        emit(doc._id, -            { -                "couch_rev": doc._rev, -                "u1db_rev": doc.u1db_rev, -                "is_tombstone": is_tombstone, -                "has_conflicts": has_conflicts, -            } -        ); -    } -} diff --git a/common/src/leap/soledad/common/ddocs/syncs/updates/state.js b/common/src/leap/soledad/common/ddocs/syncs/updates/state.js deleted file mode 100644 index d62aeb40..00000000 --- a/common/src/leap/soledad/common/ddocs/syncs/updates/state.js +++ /dev/null @@ -1,105 +0,0 @@ -/** - * This update handler stores information about ongoing synchronization - * attempts from distinct source replicas. - * - * Normally, u1db synchronization occurs during one POST request. In order to - * split that into many serial POST requests, we store the state of each sync - * in the server, using a document with id 'u1db_sync_state'.  To identify - * each sync attempt, we use a sync_id sent by the client. If we ever receive - * a new sync_id, we trash current data for that source replica and start - * over. - * - * We expect the following in the document body: - * - * { - *     'source_replica_uid': '<source_replica_uid>', - *     'sync_id': '<sync_id>', - *     'seen_ids': [['<doc_id>', <at_gen>], ...],     // optional - *     'changes_to_return': [                         // optional - *         'gen': <gen>, - *         'trans_id': '<trans_id>', - *         'changes_to_return': [[<doc_id>', <gen>, '<trans_id>'], ...] - *     ], - * } - * - * The format of the final document stored on server is: - * - * { - *     '_id': '<str>', - *     '_rev' '<str>', - *     'ongoing_syncs': { - *         '<source_replica_uid>': { - *             'sync_id': '<sync_id>', - *             'seen_ids': [['<doc_id>', <at_gen>[, ...], - *             'changes_to_return': { - *                  'gen': <gen>, - *                  'trans_id': '<trans_id>', - *                  'changes_to_return': [ - *                          ['<doc_id>', <gen>, '<trans_id>'], - *                          ..., - *                  ], - *             }, - *         }, - *         ... // info about other source replicas here - *     } - * } - */ -function(doc, req) { - -    // prevent updates to alien documents -    if (doc != null && doc['_id'] != 'u1db_sync_state') -        return [null, 'invalid data']; - -    // create the document if it doesn't exist -    if (!doc) -        doc = { -            '_id': 'u1db_sync_state', -            'ongoing_syncs': {}, -        }; - -    // parse and validate incoming data -    var body = JSON.parse(req.body); -    if (body['source_replica_uid'] == null) -        return [null, 'invalid data']; -    var source_replica_uid = body['source_replica_uid']; - -    if (body['sync_id'] == null) -        return [null, 'invalid data']; -    var sync_id = body['sync_id']; - -    // trash outdated sync data for that replica if that exists -    if (doc['ongoing_syncs'][source_replica_uid] != null && -            doc['ongoing_syncs'][source_replica_uid]['sync_id'] != sync_id) -        delete doc['ongoing_syncs'][source_replica_uid]; - -    // create an entry for that source replica -    if (doc['ongoing_syncs'][source_replica_uid] == null) -        doc['ongoing_syncs'][source_replica_uid] = { -            'sync_id': sync_id, -            'seen_ids': {}, -            'changes_to_return': null, -        }; - -    // incoming meta-data values should be exclusive, so we count how many -    // arrived and deny to accomplish the transaction if the count is high. -    var incoming_values = 0; -    var info = doc['ongoing_syncs'][source_replica_uid] - -    // add incoming seen id -    if ('seen_id' in body) { -        info['seen_ids'][body['seen_id'][0]] = body['seen_id'][1]; -        incoming_values += 1; -    } - -    // add incoming changes_to_return -    if ('changes_to_return' in body) { -        info['changes_to_return'] = body['changes_to_return']; -        incoming_values += 1; -    } - -    if (incoming_values != 1) -        return [null, 'invalid data']; - -    return [doc, 'ok']; -} - diff --git a/common/src/leap/soledad/common/ddocs/syncs/views/changes_to_return/map.js b/common/src/leap/soledad/common/ddocs/syncs/views/changes_to_return/map.js deleted file mode 100644 index 94b7e767..00000000 --- a/common/src/leap/soledad/common/ddocs/syncs/views/changes_to_return/map.js +++ /dev/null @@ -1,20 +0,0 @@ -function(doc) { -  if (doc['_id'] == 'u1db_sync_state' && doc['ongoing_syncs'] != null) -    for (var source_replica_uid in doc['ongoing_syncs']) { -      var changes = doc['ongoing_syncs'][source_replica_uid]['changes_to_return']; -      var sync_id = doc['ongoing_syncs'][source_replica_uid]['sync_id']; -      if (changes == null) -        emit([source_replica_uid, sync_id, 0], null); -      else if (changes.length == 0) -        emit([source_replica_uid, sync_id, 0], []); -      else -        for (var i = 0; i < changes['changes_to_return'].length; i++) -          emit( -            [source_replica_uid, sync_id, i], -            { -              'gen': changes['gen'], -              'trans_id': changes['trans_id'], -              'next_change_to_return': changes['changes_to_return'][i], -            }); -    } -} diff --git a/common/src/leap/soledad/common/ddocs/syncs/views/seen_ids/map.js b/common/src/leap/soledad/common/ddocs/syncs/views/seen_ids/map.js deleted file mode 100644 index 16118e88..00000000 --- a/common/src/leap/soledad/common/ddocs/syncs/views/seen_ids/map.js +++ /dev/null @@ -1,11 +0,0 @@ -function(doc) { -  if (doc['_id'] == 'u1db_sync_state' && doc['ongoing_syncs'] != null) -    for (var source_replica_uid in doc['ongoing_syncs']) { -      var sync_id = doc['ongoing_syncs'][source_replica_uid]['sync_id']; -      emit( -        [source_replica_uid, sync_id], -        { -          'seen_ids': doc['ongoing_syncs'][source_replica_uid]['seen_ids'], -        }); -    } -} diff --git a/common/src/leap/soledad/common/ddocs/syncs/views/state/map.js b/common/src/leap/soledad/common/ddocs/syncs/views/state/map.js deleted file mode 100644 index e88c6ebb..00000000 --- a/common/src/leap/soledad/common/ddocs/syncs/views/state/map.js +++ /dev/null @@ -1,17 +0,0 @@ -function(doc) { -  if (doc['_id'] == 'u1db_sync_state' && doc['ongoing_syncs'] != null) -    for (var source_replica_uid in doc['ongoing_syncs']) { -      var changes = doc['ongoing_syncs'][source_replica_uid]['changes_to_return']; -      var sync_id = doc['ongoing_syncs'][source_replica_uid]['sync_id']; -      if (changes == null) -        emit([source_replica_uid, sync_id], null); -      else -        emit( -          [source_replica_uid, sync_id], -          { -            'gen': changes['gen'], -            'trans_id': changes['trans_id'], -            'number_of_changes': changes['changes_to_return'].length -          }); -    } -} diff --git a/common/src/leap/soledad/common/ddocs/transactions/lists/generation.js b/common/src/leap/soledad/common/ddocs/transactions/lists/generation.js deleted file mode 100644 index dbdfff0d..00000000 --- a/common/src/leap/soledad/common/ddocs/transactions/lists/generation.js +++ /dev/null @@ -1,20 +0,0 @@ -function(head, req) { -    var row; -    var rows=[]; -    // fetch all rows -    while(row = getRow()) { -        rows.push(row); -    } -    if (rows.length > 0) -        send(JSON.stringify({ -            "generation": rows.length, -            "doc_id": rows[rows.length-1]['id'], -            "transaction_id": rows[rows.length-1]['value'] -        })); -    else -        send(JSON.stringify({ -            "generation": 0, -            "doc_id": "", -            "transaction_id": "", -        })); -} diff --git a/common/src/leap/soledad/common/ddocs/transactions/lists/trans_id_for_gen.js b/common/src/leap/soledad/common/ddocs/transactions/lists/trans_id_for_gen.js deleted file mode 100644 index 2ec91794..00000000 --- a/common/src/leap/soledad/common/ddocs/transactions/lists/trans_id_for_gen.js +++ /dev/null @@ -1,19 +0,0 @@ -function(head, req) { -    var row; -    var rows=[]; -    var i = 1; -    var gen = 1; -    if (req.query.gen) -        gen = parseInt(req.query['gen']); -    // fetch all rows -    while(row = getRow()) -        rows.push(row); -    if (gen <= rows.length) -        send(JSON.stringify({ -            "generation": gen, -            "doc_id": rows[gen-1]['id'], -            "transaction_id": rows[gen-1]['value'], -        })); -    else -        send('{}'); -} diff --git a/common/src/leap/soledad/common/ddocs/transactions/lists/whats_changed.js b/common/src/leap/soledad/common/ddocs/transactions/lists/whats_changed.js deleted file mode 100644 index b35cdf51..00000000 --- a/common/src/leap/soledad/common/ddocs/transactions/lists/whats_changed.js +++ /dev/null @@ -1,22 +0,0 @@ -function(head, req) { -    var row; -    var gen = 1; -    var old_gen = 0; -    if (req.query.old_gen) -        old_gen = parseInt(req.query['old_gen']); -    send('{"transactions":[\n'); -    // fetch all rows -    while(row = getRow()) { -        if (gen > old_gen) { -            if (gen > old_gen+1) -                send(',\n'); -            send(JSON.stringify({ -                "generation": gen, -                "doc_id": row["id"], -                "transaction_id": row["value"] -            })); -        } -        gen++; -    } -    send('\n]}'); -} diff --git a/common/src/leap/soledad/common/ddocs/transactions/views/log/map.js b/common/src/leap/soledad/common/ddocs/transactions/views/log/map.js deleted file mode 100644 index 94ef63ca..00000000 --- a/common/src/leap/soledad/common/ddocs/transactions/views/log/map.js +++ /dev/null @@ -1,7 +0,0 @@ -function(doc) { -    if (doc.u1db_transactions) -        doc.u1db_transactions.forEach(function(t) { -            emit(t[0],  // use timestamp as key so the results are ordered -                 t[1]); // value is the transaction_id -        }); -} diff --git a/common/src/leap/soledad/common/errors.py b/common/src/leap/soledad/common/errors.py index dec871c9..d543a3de 100644 --- a/common/src/leap/soledad/common/errors.py +++ b/common/src/leap/soledad/common/errors.py @@ -77,7 +77,6 @@ http_errors.ERROR_STATUSES = set(  class InvalidURLError(Exception): -      """      Exception raised when Soledad encounters a malformed URL.      """ @@ -90,3 +89,15 @@ class BackendNotReadyError(SoledadError):      """      wire_description = "backend not ready"      status = 500 + + +class WrongCouchSchemaVersionError(SoledadError): +    """ +    Raised in case there is a user database with wrong couch schema version. +    """ + + +class MissingCouchConfigDocumentError(SoledadError): +    """ +    Raised if a database has documents but lacks the couch config document. +    """ diff --git a/common/src/leap/soledad/common/l2db/__init__.py b/common/src/leap/soledad/common/l2db/__init__.py index c0bd15fe..568897c4 100644 --- a/common/src/leap/soledad/common/l2db/__init__.py +++ b/common/src/leap/soledad/common/l2db/__init__.py @@ -16,10 +16,7 @@  """L2DB""" -try: -    import simplejson as json -except ImportError: -    import json  # noqa +import json  from leap.soledad.common.l2db.errors import InvalidJSON, InvalidContent diff --git a/common/src/leap/soledad/common/l2db/backends/__init__.py b/common/src/leap/soledad/common/l2db/backends/__init__.py index 922daafd..c731c3d3 100644 --- a/common/src/leap/soledad/common/l2db/backends/__init__.py +++ b/common/src/leap/soledad/common/l2db/backends/__init__.py @@ -17,10 +17,7 @@  """Abstract classes and common implementations for the backends."""  import re -try: -    import simplejson as json -except ImportError: -    import json  # noqa +import json  import uuid  from leap.soledad.common import l2db diff --git a/common/src/leap/soledad/common/l2db/backends/inmemory.py b/common/src/leap/soledad/common/l2db/backends/inmemory.py index 06a934a6..6fd251af 100644 --- a/common/src/leap/soledad/common/l2db/backends/inmemory.py +++ b/common/src/leap/soledad/common/l2db/backends/inmemory.py @@ -16,10 +16,7 @@  """The in-memory Database class for U1DB.""" -try: -    import simplejson as json -except ImportError: -    import json  # noqa +import json  from leap.soledad.common.l2db import (      Document, errors, diff --git a/common/src/leap/soledad/common/l2db/backends/sqlite_backend.py b/common/src/leap/soledad/common/l2db/backends/sqlite_backend.py index ba273039..d73c0d16 100644 --- a/common/src/leap/soledad/common/l2db/backends/sqlite_backend.py +++ b/common/src/leap/soledad/common/l2db/backends/sqlite_backend.py @@ -21,17 +21,14 @@ A L2DB implementation that uses SQLite as its persistence layer.  import errno  import os -try: -    import simplejson as json -except ImportError: -    import json  # noqa -from sqlite3 import dbapi2 +import json  import sys  import time  import uuid -  import pkg_resources +from sqlite3 import dbapi2 +  from leap.soledad.common.l2db.backends import CommonBackend, CommonSyncTarget  from leap.soledad.common.l2db import (      Document, errors, diff --git a/common/src/leap/soledad/common/l2db/remote/basic_auth_middleware.py b/common/src/leap/soledad/common/l2db/remote/basic_auth_middleware.py index a2cbff62..96d0d872 100644 --- a/common/src/leap/soledad/common/l2db/remote/basic_auth_middleware.py +++ b/common/src/leap/soledad/common/l2db/remote/basic_auth_middleware.py @@ -15,10 +15,8 @@  # along with u1db.  If not, see <http://www.gnu.org/licenses/>.  """U1DB Basic Auth authorisation WSGI middleware."""  import httplib -try: -    import simplejson as json -except ImportError: -    import json  # noqa +import json +  from wsgiref.util import shift_path_info diff --git a/common/src/leap/soledad/common/l2db/remote/http_app.py b/common/src/leap/soledad/common/l2db/remote/http_app.py index 65277bd1..5cf6645e 100644 --- a/common/src/leap/soledad/common/l2db/remote/http_app.py +++ b/common/src/leap/soledad/common/l2db/remote/http_app.py @@ -23,10 +23,7 @@ HTTP Application exposing U1DB.  import functools  import httplib  import inspect -try: -    import simplejson as json -except ImportError: -    import json  # noqa +import json  import sys  import urlparse diff --git a/common/src/leap/soledad/common/l2db/remote/http_client.py b/common/src/leap/soledad/common/l2db/remote/http_client.py index a65264b6..53363c0a 100644 --- a/common/src/leap/soledad/common/l2db/remote/http_client.py +++ b/common/src/leap/soledad/common/l2db/remote/http_client.py @@ -17,10 +17,7 @@  """Base class to make requests to a remote HTTP server."""  import httplib -try: -    import simplejson as json -except ImportError: -    import json  # noqa +import json  import socket  import ssl  import sys diff --git a/common/src/leap/soledad/common/l2db/remote/http_database.py b/common/src/leap/soledad/common/l2db/remote/http_database.py index b2b48dee..7512379f 100644 --- a/common/src/leap/soledad/common/l2db/remote/http_database.py +++ b/common/src/leap/soledad/common/l2db/remote/http_database.py @@ -16,10 +16,7 @@  """HTTPDatabase to access a remote db over the HTTP API.""" -try: -    import simplejson as json -except ImportError: -    import json  # noqa +import json  import uuid  from leap.soledad.common.l2db import ( diff --git a/common/src/leap/soledad/common/l2db/remote/http_target.py b/common/src/leap/soledad/common/l2db/remote/http_target.py index 7e7f366f..38804f01 100644 --- a/common/src/leap/soledad/common/l2db/remote/http_target.py +++ b/common/src/leap/soledad/common/l2db/remote/http_target.py @@ -16,10 +16,7 @@  """SyncTarget API implementation to a remote HTTP server.""" -try: -    import simplejson as json -except ImportError: -    import json  # noqa +import json  from leap.soledad.common.l2db import Document, SyncTarget  from leap.soledad.common.l2db.errors import BrokenSyncStream diff --git a/common/src/leap/soledad/common/l2db/remote/server_state.py b/common/src/leap/soledad/common/l2db/remote/server_state.py index f131e09e..e20b4679 100644 --- a/common/src/leap/soledad/common/l2db/remote/server_state.py +++ b/common/src/leap/soledad/common/l2db/remote/server_state.py @@ -15,8 +15,6 @@  # along with u1db.  If not, see <http://www.gnu.org/licenses/>.  """State for servers exposing a set of U1DB databases.""" -import os -import errno  class ServerState(object): diff --git a/common/src/leap/soledad/common/l2db/sync.py b/common/src/leap/soledad/common/l2db/sync.py index c612629f..5e9b22f4 100644 --- a/common/src/leap/soledad/common/l2db/sync.py +++ b/common/src/leap/soledad/common/l2db/sync.py @@ -126,8 +126,8 @@ class Synchronizer(object):              target_last_known_gen, target_last_known_trans_id = 0, ''          else:              target_last_known_gen, target_last_known_trans_id = ( -            self.source._get_replica_gen_and_trans_id(  # nopep8 -                self.target_replica_uid)) +                self.source._get_replica_gen_and_trans_id(  # nopep8 +                    self.target_replica_uid))          if not changes and target_last_known_gen == target_gen:              if target_trans_id != target_last_known_trans_id:                  raise errors.InvalidTransactionId diff --git a/common/src/leap/soledad/common/log.py b/common/src/leap/soledad/common/log.py new file mode 100644 index 00000000..3f026045 --- /dev/null +++ b/common/src/leap/soledad/common/log.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# log.py +# Copyright (C) 2016 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +This module centralizes logging facilities and allows for different behaviours, +as using the python logging module instead of twisted logger, and to print logs +to stdout, mainly for development purposes. +""" + + +import os +import sys + +from twisted.logger import Logger +from twisted.logger import textFileLogObserver + + +def getLogger(*args, **kwargs): + +    if os.environ.get('SOLEDAD_USE_PYTHON_LOGGING'): +        import logging +        return logging.getLogger(__name__) + +    if os.environ.get('SOLEDAD_LOG_TO_STDOUT'): +        kwargs({'observer': textFileLogObserver(sys.stdout)}) + +    return Logger(*args, **kwargs) + + +__all__ = ['getLogger'] diff --git a/scripts/ddocs/update_design_docs.py b/scripts/ddocs/update_design_docs.py deleted file mode 100644 index 281482b8..00000000 --- a/scripts/ddocs/update_design_docs.py +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/python - -# This script updates Soledad's design documents in the session database and -# all user databases with contents from the installed leap.soledad.common -# package. - -import json -import logging -import argparse -import re -import threading -import binascii - -from urlparse import urlparse -from getpass import getpass -from ConfigParser import ConfigParser - -from couchdb.client import Server -from couchdb.http import Resource -from couchdb.http import Session -from couchdb.http import ResourceNotFound - -from leap.soledad.common import ddocs - - -MAX_THREADS = 20 -DESIGN_DOCS = { -    '_design/docs': json.loads(binascii.a2b_base64(ddocs.docs)), -    '_design/syncs': json.loads(binascii.a2b_base64(ddocs.syncs)), -    '_design/transactions': json.loads( -        binascii.a2b_base64(ddocs.transactions)), -} - - -# create a logger -logger = logging.getLogger(__name__) -LOG_FORMAT = '%(asctime)s %(message)s' -logging.basicConfig(format=LOG_FORMAT, level=logging.INFO) - - -def _parse_args(): -    parser = argparse.ArgumentParser() -    parser.add_argument('-u', dest='uuid', default=None, type=str, -                        help='the UUID of the user') -    parser.add_argument('-t', dest='threads', default=MAX_THREADS, type=int, -                        help='the number of parallel threads') -    return parser.parse_args() - - -def _get_url(): -    # get couch url -    cp = ConfigParser() -    cp.read('/etc/soledad/soledad-server.conf') -    url = urlparse(cp.get('soledad-server', 'couch_url')) -    # get admin password -    netloc = re.sub('^.*@', '', url.netloc) -    url = url._replace(netloc=netloc) -    password = getpass("Admin password for %s: " % url.geturl()) -    return url._replace(netloc='admin:%s@%s' % (password, netloc)) - - -def _get_server(url): -    resource = Resource( -        url.geturl(), Session(retry_delays=[1, 2, 4, 8], timeout=10)) -    return Server(url=resource) - - -def _confirm(url): -    hidden_url = re.sub( -        'http://(.*):.*@', -        'http://\\1:xxxxx@', -        url.geturl()) - -    print """ -    ========== -    ATTENTION! -    ========== - -    This script will modify Soledad's shared and user databases in: - -      %s - -    This script does not make a backup of the couch db data, so make sure you -    have a copy or you may loose data. -    """ % hidden_url -    confirm = raw_input("Proceed (type uppercase YES)? ") - -    if confirm != "YES": -        exit(1) - - -# -# Thread -# - -class DBWorkerThread(threading.Thread): - -    def __init__(self, server, dbname, db_idx, db_len, release_fun): -        threading.Thread.__init__(self) -        self._dbname = dbname -        self._cdb = server[self._dbname] -        self._db_idx = db_idx -        self._db_len = db_len -        self._release_fun = release_fun - -    def run(self): - -        logger.info( -            "(%d/%d) Updating db %s." -            % (self._db_idx, self._db_len, self._dbname)) - -        for doc_id in DESIGN_DOCS: -            try: -                doc = self._cdb[doc_id] -            except ResourceNotFound: -                doc = {'_id': doc_id} -            for key in ['lists', 'views', 'updates']: -                if key in DESIGN_DOCS[doc_id]: -                    doc[key] = DESIGN_DOCS[doc_id][key] -            self._cdb.save(doc) - -        # release the semaphore -        self._release_fun() - - -def _launch_update_design_docs_thread( -        server, dbname, db_idx, db_len, semaphore_pool): -    semaphore_pool.acquire()  # wait for an available working slot -    thread = DBWorkerThread( -        server, dbname, db_idx, db_len, semaphore_pool.release) -    thread.daemon = True -    thread.start() -    return thread - - -def _update_design_docs(args, server): - -    # find the actual databases to be updated -    dbs = [] -    if args.uuid: -        dbs.append('user-%s' % args.uuid) -    else: -        for dbname in server: -            if dbname.startswith('user-') or dbname == 'shared': -                dbs.append(dbname) -            else: -                logger.info("Skipping db %s." % dbname) - -    db_idx = 0 -    db_len = len(dbs) -    semaphore_pool = threading.BoundedSemaphore(value=args.threads) -    threads = [] - -    # launch the update -    for db in dbs: -        db_idx += 1 -        threads.append( -            _launch_update_design_docs_thread( -                server, db, db_idx, db_len, semaphore_pool)) - -    # wait for all threads to finish -    map(lambda thread: thread.join(), threads) - - -if __name__ == "__main__": -    args = _parse_args() -    url = _get_url() -    _confirm(url) -    server = _get_server(url) -    _update_design_docs(args, server) diff --git a/scripts/docker/Dockerfile b/scripts/docker/Dockerfile index 915508ea..21764d84 100644 --- a/scripts/docker/Dockerfile +++ b/scripts/docker/Dockerfile @@ -1,51 +1,32 @@  # start with a fresh debian image -FROM debian - -# expose soledad server port in case we want to run a server container -EXPOSE 2424 - -# install dependencies from debian repos -COPY files/apt/leap.list /etc/apt/sources.list.d/ - -RUN apt-get update -RUN apt-get -y --force-yes install leap-archive-keyring +# we use backports because of libsqlcipher-dev +FROM debian:jessie-backports  RUN apt-get update  RUN apt-get -y install git -RUN apt-get -y install vim -RUN apt-get -y install python-ipdb -# install python deps +# needed to build python twisted module  RUN apt-get -y install libpython2.7-dev -RUN apt-get -y install libffi-dev +# needed to build python cryptography module  RUN apt-get -y install libssl-dev -RUN apt-get -y install libzmq3-dev -RUN apt-get -y install python-pip -RUN apt-get -y install couchdb -RUN apt-get -y install python-srp -RUN apt-get -y install python-scrypt -RUN apt-get -y install leap-keymanager -RUN apt-get -y install python-tz +RUN apt-get -y install libffi-dev +# needed to build pysqlcipher +RUN apt-get -y install libsqlcipher-dev +# needed to support keymanager +RUN apt-get -y install libsqlite3-dev +# install pip and tox +RUN apt-get -y install python-pip  RUN pip install -U pip -RUN pip install psutil - -# install soledad-perf deps -RUN pip install klein -RUN apt-get -y install curl -RUN apt-get -y install httperf +RUN pip install tox  # clone repositories -ENV BASEURL "https://github.com/leapcode" -ENV VARDIR "/var/local" -ENV REPOS "soledad leap_pycommon soledad-perf" -RUN for repo in ${REPOS}; do git clone ${BASEURL}/${repo}.git /var/local/${repo}; done +RUN mkdir -p /builds/leap +RUN git clone -b develop https://0xacab.org/leap/soledad.git /builds/leap/soledad -# copy over files to help setup the environment and run soledad -RUN mkdir -p /usr/local/soledad - -COPY files/build/install-deps-from-repos.sh /usr/local/soledad/ -RUN /usr/local/soledad/install-deps-from-repos.sh +# use tox to install everything needed to run tests +RUN cd /builds/leap/soledad/testing && tox -v -r --notest +RUN mkdir -p /usr/local/soledad  COPY files/bin/ /usr/local/soledad/ diff --git a/scripts/docker/Makefile b/scripts/docker/Makefile index 4fa2e264..7050526a 100644 --- a/scripts/docker/Makefile +++ b/scripts/docker/Makefile @@ -16,7 +16,7 @@  # Some configurations you might override when calling this makefile #  ##################################################################### -IMAGE_NAME           ?= leap/soledad:1.0 +IMAGE_NAME           ?= leapcode/soledad:latest  SOLEDAD_REMOTE       ?= https://0xacab.org/leap/soledad.git  SOLEDAD_BRANCH       ?= develop  SOLEDAD_PRELOAD_NUM  ?= 100 @@ -27,11 +27,14 @@ MEMORY               ?= 512m  # Docker image generation (main make target) #  ############################################## -all: image +all: soledad-image couchdb-image -image: +soledad-image:  	docker build -t $(IMAGE_NAME) . +couchdb-image: +	(cd couchdb/ && make) +  ##################################################  # Run a Soledad Server inside a docker container #  ################################################## @@ -69,23 +72,37 @@ run-client-bootstrap:  	  /usr/local/soledad/run-client-bootstrap.sh  ################################################# -# Run all trial tests inside a docker container # +# Run all tests inside a docker container       #  ################################################# -run-trial: +run-tox: +	name=$$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 5 | head -n 1); \ +	docker run -d --name $${name} leap/couchdb; \  	docker run -t -i \  	  --memory="$(MEMORY)" \  	  --env="SOLEDAD_REMOTE=$(SOLEDAD_REMOTE)" \  	  --env="SOLEDAD_BRANCH=$(SOLEDAD_BRANCH)" \ +	  --env="COUCH_URL=http://$${name}:5984" \ +	  --link $${name} \  	  $(IMAGE_NAME) \ -	  /usr/local/soledad/run-trial.sh +	  /usr/local/soledad/run-tox.sh  ############################################  # Performance tests and graphic generation #  ############################################ -run-perf-test: -	helper/run-test.sh perf +run-perf: +	name=$$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 5 | head -n 1); \ +	docker run -d --name $${name} leap/couchdb; \ +	docker run -t -i \ +	  --memory="$(MEMORY)" \ +	  --env="SOLEDAD_REMOTE=$(SOLEDAD_REMOTE)" \ +	  --env="SOLEDAD_BRANCH=$(SOLEDAD_BRANCH)" \ +	  --env="SOLEDAD_PRELOAD_NUM=$(SOLEDAD_PRELOAD_NUM)" \ +	  --env="COUCH_URL=http://$${name}:5984" \ +	  --link $${name} \ +	  $(IMAGE_NAME) \ +	  /usr/local/soledad/run-perf.sh  run-client-perf:  	@if [ -z "$(CONTAINER_ID_FILE)" ]; then \ @@ -123,7 +140,7 @@ cp-perf-result:  # Other helper targets #  ######################## -run-shell: image +run-shell: soledad-image  	docker run -t -i \  	  --memory="$(MEMORY)" \  	  $(IMAGE_NAME) \ diff --git a/scripts/docker/README.md b/scripts/docker/README.md index c4d7ac94..97b39f87 100644 --- a/scripts/docker/README.md +++ b/scripts/docker/README.md @@ -11,7 +11,20 @@ Check the `Dockerfile` for the steps for creating the docker image.  Check the `Makefile` for the rules for running containers. -Check the `helper/` directory for scripts that help running tests. + +Installation +------------ + +1. Install docker for your system: https://docs.docker.com/ +2. Build images by running `make` +3. Execute `make run-tox` and `make run-perf` to run tox tests and perf tests, +   respectivelly. +4. You may want to pass some variables to the `make` command to control +   parameters of execution, for example: + +      make run-perf SOLEDAD_PRELOAD_NUM=500 + +   See more variables below.  Environment variables for docker containers diff --git a/scripts/docker/TODO b/scripts/docker/TODO index 5185d754..90597637 100644 --- a/scripts/docker/TODO +++ b/scripts/docker/TODO @@ -1 +1,5 @@  - limit resources of containers (mem and cpu) +- allow running couchdb on another container +- use a config file to get defaults for running tests +- use the /builds directory as base of git repo +- save the test state to a directory to make it reproducible diff --git a/scripts/docker/couchdb/Dockerfile b/scripts/docker/couchdb/Dockerfile new file mode 100644 index 00000000..03448da5 --- /dev/null +++ b/scripts/docker/couchdb/Dockerfile @@ -0,0 +1,3 @@ +FROM couchdb:latest + +COPY local.ini /usr/local/etc/couchdb/ diff --git a/scripts/docker/couchdb/Makefile b/scripts/docker/couchdb/Makefile new file mode 100644 index 00000000..cf3ac966 --- /dev/null +++ b/scripts/docker/couchdb/Makefile @@ -0,0 +1,4 @@ +IMAGE_NAME ?= leap/couchdb + +image: +	docker build -t $(IMAGE_NAME) . diff --git a/scripts/docker/couchdb/README.rst b/scripts/docker/couchdb/README.rst new file mode 100644 index 00000000..31a791a8 --- /dev/null +++ b/scripts/docker/couchdb/README.rst @@ -0,0 +1,12 @@ +Couchdb Docker image +==================== + +This directory contains rules to build a custom couchdb docker image to be +provided as backend to soledad server. + +Type `make` to build the image. + +Differences between this image and the official one: + +  - add the "nodelay" socket option on the httpd section of the config file +    (see: https://leap.se/code/issues/8264). diff --git a/scripts/docker/couchdb/local.ini b/scripts/docker/couchdb/local.ini new file mode 100644 index 00000000..3650e0ed --- /dev/null +++ b/scripts/docker/couchdb/local.ini @@ -0,0 +1,2 @@ +[httpd] +socket_options = [{recbuf, 262144}, {sndbuf, 262144}, {nodelay, true}] diff --git a/scripts/docker/files/bin/run-perf.sh b/scripts/docker/files/bin/run-perf.sh new file mode 100755 index 00000000..72060230 --- /dev/null +++ b/scripts/docker/files/bin/run-perf.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +REPO=/builds/leap/soledad/testing +COUCH_URL="${COUCH_URL:-http://127.0.0.1:5984}" +SOLEDAD_PRELOAD_NUM="${SOLEDAD_PRELOAD_NUM:-100}" + +if [ ! -z "${SOLEDAD_REMOTE}" ]; then +  git -C ${REPO} remote set-url origin ${SOLEDAD_REMOTE} +  git -C ${REPO} fetch origin +fi + +if [ ! -z "${SOLEDAD_BRANCH}" ]; then +  git -C ${REPO} checkout ${SOLEDAD_BRANCH} +fi + +cd ${REPO} + +tox perf -- \ +	--durations 0 \ +	--couch-url ${COUCH_URL} \ +	--twisted \ +	--num-docs ${SOLEDAD_PRELOAD_NUM} diff --git a/scripts/docker/files/bin/run-tox.sh b/scripts/docker/files/bin/run-tox.sh new file mode 100755 index 00000000..74fde182 --- /dev/null +++ b/scripts/docker/files/bin/run-tox.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +REPO=/builds/leap/soledad/testing +COUCH_URL="${COUCH_URL:-http://127.0.0.1:5984}" + +if [ ! -z "${SOLEDAD_REMOTE}" ]; then +  git -C ${REPO} remote set-url origin ${SOLEDAD_REMOTE} +  git -C ${REPO} fetch origin +fi + +if [ ! -z "${SOLEDAD_BRANCH}" ]; then +  git -C ${REPO} checkout ${SOLEDAD_BRANCH} +fi + +cd ${REPO} + +tox -- --couch-url ${COUCH_URL} diff --git a/scripts/docker/files/bin/setup-test-env.py b/scripts/docker/files/bin/setup-test-env.py index 0f3ea6f4..4868fd56 100755 --- a/scripts/docker/files/bin/setup-test-env.py +++ b/scripts/docker/files/bin/setup-test-env.py @@ -194,12 +194,12 @@ def user_db_create(args):      url = 'http://localhost:%d/user-%s' % (args.port, args.uuid)      try:          CouchDatabase.open_database( -            url=url, create=False, replica_uid=None, ensure_ddocs=True) +            url=url, create=False, replica_uid=None)          print '[*] error: database "user-%s" already exists' % args.uuid          exit(1)      except DatabaseDoesNotExist:          CouchDatabase.open_database( -            url=url, create=True, replica_uid=None, ensure_ddocs=True) +            url=url, create=True, replica_uid=None)          print '[+] database created: user-%s' % args.uuid @@ -372,7 +372,10 @@ CERT_CONFIG_FILE = os.path.join(  def cert_create(args):      private_key = os.path.join(args.basedir, args.private_key)      cert_key = os.path.join(args.basedir, args.cert_key) -    os.mkdir(args.basedir) +    try: +        os.mkdir(args.basedir) +    except OSError: +        pass      call([          'openssl',          'req', @@ -389,8 +392,11 @@ def cert_create(args):  def cert_delete(args):      private_key = os.path.join(args.basedir, args.private_key)      cert_key = os.path.join(args.basedir, args.cert_key) -    os.unlink(private_key) -    os.unlink(cert_key) +    try: +        os.unlink(private_key) +        os.unlink(cert_key) +    except OSError: +        pass  # diff --git a/scripts/migration/0.9.0/.gitignore b/scripts/migration/0.9.0/.gitignore new file mode 100644 index 00000000..6115c109 --- /dev/null +++ b/scripts/migration/0.9.0/.gitignore @@ -0,0 +1 @@ +log/* diff --git a/scripts/migration/0.9.0/README.md b/scripts/migration/0.9.0/README.md new file mode 100644 index 00000000..919a5235 --- /dev/null +++ b/scripts/migration/0.9.0/README.md @@ -0,0 +1,73 @@ +CouchDB schema migration to Soledad 0.8.2 +========================================= + +Migrate couch database schema from <= 0.8.1 version to 0.8.2 version. + + +ATTENTION! +---------- + +  - This script does not backup your data for you. Make sure you have a backup +    copy of your databases before running this script! + +  - Make sure you turn off any service that might be writing to the couch +    database before running this script. + + +Usage +----- + +To see what the script would do, run: + +    ./migrate.py + +To actually run the migration, add the --do-migrate command line option: + +    ./migrate.py --do-migrate + +See command line options: + +    ./migrate.py --help + + +Log +--- + +If you don't pass a --log-file command line option, a log will be written to +the `log/` folder. + + +Differences between old and new couch schema +-------------------------------------------- + +The differences between old and new schemas are: + +    - Transaction metadata was previously stored inside each document, and we +      used design doc view/list functions to retrieve that information. Now, +      transaction metadata is stored in documents with special ids +      (gen-0000000001 to gen-9999999999). + +    - Database replica config metadata was stored in a document called +      "u1db_config", and now we store it in the "_local/config" document. + +    - Sync metadata was previously stored in documents with id +      "u1db_sync_<source-replica-id>", and now are stored in +      "_local/sync_<source-replica-id>". + +    - The new schema doesn't make use of any design documents. + + +What does this script do +------------------------ + +- List all databases starting with "user-". +- For each one, do: +  - Check if it contains the old "u1db_config" document. +  - If it doesn't, skip this db. +  - Get the transaction log using the usual design doc view/list functions. +  - Write a new "gen-X" document for each line on the transaction log. +  - Get the "u1db_config" document, create a new one in "_local/config", +    Delete the old one. +  - List all "u1db_sync_X" documents, create new ones in "_local/sync_X", +    delete the old ones. +  - Delete unused design documents. diff --git a/scripts/migration/0.9.0/log/.empty b/scripts/migration/0.9.0/log/.empty new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/scripts/migration/0.9.0/log/.empty diff --git a/scripts/migration/0.9.0/migrate.py b/scripts/migration/0.9.0/migrate.py new file mode 100755 index 00000000..6ad5bc2d --- /dev/null +++ b/scripts/migration/0.9.0/migrate.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python +# migrate.py + +""" +Migrate CouchDB schema to Soledad 0.8.2 schema. + +****************************************************************************** +                               ATTENTION! + +  - This script does not backup your data for you. Make sure you have a backup +    copy of your databases before running this script! + +  - Make sure you turn off any service that might be writing to the couch +    database before running this script. + +****************************************************************************** + +Run this script with the --help option to see command line options. + +See the README.md file for more information. +""" + +import datetime +import logging +import netrc +import os + +from argparse import ArgumentParser + +from leap.soledad.server import load_configuration + +from migrate_couch_schema import migrate + + +TARGET_VERSION = '0.8.2' +DEFAULT_COUCH_URL = 'http://127.0.0.1:5984' +CONF = load_configuration('/etc/soledad/soledad-server.conf') +NETRC_PATH = CONF['soledad-server']['admin_netrc'] + + +# +# command line args and execution +# + +def _configure_logger(log_file, level=logging.INFO): +    if not log_file: +        fname, _ = os.path.basename(__file__).split('.') +        timestr = datetime.datetime.now().strftime('%Y-%m-%d_%H:%M:%S') +        filename = 'soledad_%s_%s_%s.log' \ +                   % (TARGET_VERSION, fname, timestr) +        dirname = os.path.join( +            os.path.dirname(os.path.realpath(__file__)), 'log') +        log_file = os.path.join(dirname, filename) +    logging.basicConfig( +        filename=log_file, +        filemode='a', +        format='%(asctime)s,%(msecs)d %(levelname)s %(message)s', +        datefmt='%H:%M:%S', +        level=level) + + +def _default_couch_url(): +    if not os.path.exists(NETRC_PATH): +        return DEFAULT_COUCH_URL +    parsed_netrc = netrc.netrc(NETRC_PATH) +    host, (login, _, password) = parsed_netrc.hosts.items()[0] +    url = ('http://%(login)s:%(password)s@%(host)s:5984' % { +           'login': login, +           'password': password, +           'host': host}) +    return url + + +def _parse_args(): +    parser = ArgumentParser() +    parser.add_argument( +        '--couch_url', +        help='the url for the couch database', +        default=_default_couch_url()) +    parser.add_argument( +        '--do-migrate', +        help='actually perform the migration (otherwise ' +             'just print what would be done)', +        action='store_true') +    parser.add_argument( +        '--log-file', +        help='the log file to use') +    parser.add_argument( +        '--pdb', action='store_true', +        help='escape to pdb shell in case of exception') +    parser.add_argument( +        '--verbose', action='store_true', +        help='output detailed information about the migration ' +             '(i.e. include debug messages)') +    return parser.parse_args() + + +def _enable_pdb(): +    import sys +    from IPython.core import ultratb +    sys.excepthook = ultratb.FormattedTB( +        mode='Verbose', color_scheme='Linux', call_pdb=1) + + +if __name__ == '__main__': +    args = _parse_args() +    if args.pdb: +        _enable_pdb() +    _configure_logger( +        args.log_file, +        level=logging.DEBUG if args.verbose else logging.INFO) +    logger = logging.getLogger(__name__) +    try: +        migrate(args, TARGET_VERSION) +    except: +        logger.exception('Fatal error on migrate script!') +        raise diff --git a/scripts/migration/0.9.0/migrate_couch_schema/__init__.py b/scripts/migration/0.9.0/migrate_couch_schema/__init__.py new file mode 100644 index 00000000..f0b456e4 --- /dev/null +++ b/scripts/migration/0.9.0/migrate_couch_schema/__init__.py @@ -0,0 +1,192 @@ +# __init__.py +""" +Support functions for migration script. +""" + +import logging + +from couchdb import Server +from couchdb import ResourceNotFound +from couchdb import ResourceConflict + +from leap.soledad.common.couch import GENERATION_KEY +from leap.soledad.common.couch import TRANSACTION_ID_KEY +from leap.soledad.common.couch import REPLICA_UID_KEY +from leap.soledad.common.couch import DOC_ID_KEY +from leap.soledad.common.couch import SCHEMA_VERSION_KEY +from leap.soledad.common.couch import CONFIG_DOC_ID +from leap.soledad.common.couch import SYNC_DOC_ID_PREFIX +from leap.soledad.common.couch import SCHEMA_VERSION + + +logger = logging.getLogger(__name__) + + +# +# support functions +# + +def _get_couch_server(couch_url): +    return Server(couch_url) + + +def _is_migrateable(db): +    config_doc = db.get('u1db_config') +    return bool(config_doc) + + +def _get_transaction_log(db): +    ddoc_path = ['_design', 'transactions', '_view', 'log'] +    resource = db.resource(*ddoc_path) +    try: +        _, _, data = resource.get_json() +    except ResourceNotFound: +        logger.warning( +            '[%s] missing transactions design document, ' +            'can\'t get transaction log.' % db.name) +        return [] +    rows = data['rows'] +    transaction_log = [] +    gen = 1 +    for row in rows: +        transaction_log.append((gen, row['id'], row['value'])) +        gen += 1 +    return transaction_log + + +def _get_user_dbs(server): +    user_dbs = filter(lambda dbname: dbname.startswith('user-'), server) +    return user_dbs + + +# +# migration main functions +# + +def migrate(args, target_version): +    server = _get_couch_server(args.couch_url) +    logger.info('starting couch schema migration to %s' % target_version) +    if not args.do_migrate: +        logger.warning('dry-run: no changes will be made to databases') +    user_dbs = _get_user_dbs(server) +    for dbname in user_dbs: +        db = server[dbname] +        if not _is_migrateable(db): +            logger.warning("[%s] skipping not migrateable user db" % dbname) +            continue +        logger.info("[%s] starting migration of user db" % dbname) +        try: +            _migrate_user_db(db, args.do_migrate) +            logger.info("[%s] finished migration of user db" % dbname) +        except: +            logger.exception('[%s] error migrating user db' % dbname) +            logger.error('continuing with next database.') +    logger.info('finished couch schema migration to %s' % target_version) + + +def _migrate_user_db(db, do_migrate): +    _migrate_transaction_log(db, do_migrate) +    _migrate_sync_docs(db, do_migrate) +    _delete_design_docs(db, do_migrate) +    _migrate_config_doc(db, do_migrate) + + +def _migrate_transaction_log(db, do_migrate): +    transaction_log = _get_transaction_log(db) +    for gen, doc_id, trans_id in transaction_log: +        gen_doc_id = 'gen-%s' % str(gen).zfill(10) +        doc = { +            '_id': gen_doc_id, +            GENERATION_KEY: gen, +            DOC_ID_KEY: doc_id, +            TRANSACTION_ID_KEY: trans_id, +        } +        logger.debug('[%s] creating gen doc: %s' % (db.name, gen_doc_id)) +        if do_migrate: +            try: +                db.save(doc) +            except ResourceConflict: +                # this gen document already exists. if documents are the same, +                # continue with migration. +                existing_doc = db.get(gen_doc_id) +                for key in [GENERATION_KEY, DOC_ID_KEY, TRANSACTION_ID_KEY]: +                    if existing_doc[key] != doc[key]: +                        raise + + +def _migrate_config_doc(db, do_migrate): +    old_doc = db['u1db_config'] +    new_doc = { +        '_id': CONFIG_DOC_ID, +        REPLICA_UID_KEY: old_doc[REPLICA_UID_KEY], +        SCHEMA_VERSION_KEY: SCHEMA_VERSION, +    } +    logger.info("[%s] moving config doc: %s -> %s" +                % (db.name, old_doc['_id'], new_doc['_id'])) +    if do_migrate: +        # the config doc must not exist, otherwise we would have skipped this +        # database. +        db.save(new_doc) +        db.delete(old_doc) + + +def _migrate_sync_docs(db, do_migrate): +    logger.info('[%s] moving sync docs' % db.name) +    view = db.view( +        '_all_docs', +        startkey='u1db_sync', +        endkey='u1db_synd', +        include_docs='true') +    for row in view.rows: +        old_doc = row['doc'] +        old_id = old_doc['_id'] + +        # older schemas used different documents with ids starting with +        # "u1db_sync" to store sync-related data: +        # +        #   - u1db_sync_log: was used to store the whole sync log. +        #   - u1db_sync_state: was used to store the sync state. +        # +        # if any of these documents exist in the current db, they are leftover +        # from previous migrations, and should just be removed. +        if old_id in ['u1db_sync_log', 'u1db_sync_state']: +            logger.info('[%s] removing leftover document: %s' +                        % (db.name, old_id)) +            if do_migrate: +                db.delete(old_doc) +            continue + +        replica_uid = old_id.replace('u1db_sync_', '') +        new_id = "%s%s" % (SYNC_DOC_ID_PREFIX, replica_uid) +        new_doc = { +            '_id': new_id, +            GENERATION_KEY: old_doc['generation'], +            TRANSACTION_ID_KEY: old_doc['transaction_id'], +            REPLICA_UID_KEY: replica_uid, +        } +        logger.debug("[%s] moving sync doc: %s -> %s" +                     % (db.name, old_id, new_id)) +        if do_migrate: +            try: +                db.save(new_doc) +            except ResourceConflict: +                # this sync document already exists. if documents are the same, +                # continue with migration. +                existing_doc = db.get(new_id) +                for key in [GENERATION_KEY, TRANSACTION_ID_KEY, +                            REPLICA_UID_KEY]: +                    if existing_doc[key] != new_doc[key]: +                        raise +            db.delete(old_doc) + + +def _delete_design_docs(db, do_migrate): +    for ddoc in ['docs', 'syncs', 'transactions']: +        doc_id = '_design/%s' % ddoc +        doc = db.get(doc_id) +        if doc: +            logger.info("[%s] deleting design doc: %s" % (db.name, doc_id)) +            if do_migrate: +                db.delete(doc) +        else: +            logger.warning("[%s] design doc not found: %s" % (db.name, doc_id)) diff --git a/scripts/migration/0.9.0/requirements.pip b/scripts/migration/0.9.0/requirements.pip new file mode 100644 index 00000000..ea22a1a4 --- /dev/null +++ b/scripts/migration/0.9.0/requirements.pip @@ -0,0 +1,3 @@ +couchdb +leap.soledad.common==0.9.0 +leap.soledad.server==0.9.0 diff --git a/scripts/migration/0.9.0/setup.py b/scripts/migration/0.9.0/setup.py new file mode 100644 index 00000000..0467e932 --- /dev/null +++ b/scripts/migration/0.9.0/setup.py @@ -0,0 +1,8 @@ +from setuptools import setup +from setuptools import find_packages + + +setup( +    name='migrate_couch_schema', +    packages=find_packages('.'), +) diff --git a/scripts/migration/0.9.0/tests/conftest.py b/scripts/migration/0.9.0/tests/conftest.py new file mode 100644 index 00000000..61f6c7ee --- /dev/null +++ b/scripts/migration/0.9.0/tests/conftest.py @@ -0,0 +1,54 @@ +# conftest.py + +""" +Provide a couch database with content stored in old schema. +""" + +import couchdb +import pytest +import uuid + + +COUCH_URL = 'http://127.0.0.1:5984' + +transaction_map = """ +function(doc) { +    if (doc.u1db_transactions) +        doc.u1db_transactions.forEach(function(t) { +            emit(t[0],  // use timestamp as key so the results are ordered +                 t[1]); // value is the transaction_id +        }); +} +""" + +initial_docs = [ +    {'_id': 'u1db_config', 'replica_uid': 'an-uid'}, +    {'_id': 'u1db_sync_A', 'generation': 0, 'replica_uid': 'A', +     'transaction_id': ''}, +    {'_id': 'u1db_sync_B', 'generation': 2, 'replica_uid': 'B', +     'transaction_id': 'X'}, +    {'_id': 'doc1', 'u1db_transactions': [(1, 'trans-1'), (3, 'trans-3')]}, +    {'_id': 'doc2', 'u1db_transactions': [(2, 'trans-2'), (4, 'trans-4')]}, +    {'_id': '_design/docs'}, +    {'_id': '_design/syncs'}, +    {'_id': '_design/transactions', +     'views': {'log': {'map': transaction_map}}}, +    # add some data from previous interrupted migration +    {'_id': '_local/sync_A', 'gen': 0, 'trans_id': '', 'replica_uid': 'A'}, +    {'_id': 'gen-0000000002', +     'gen': 2, 'trans_id': 'trans-2', 'doc_id': 'doc2'}, +    # the following should be removed if found in the dbs +    {'_id': 'u1db_sync_log'}, +    {'_id': 'u1db_sync_state'}, +] + + +@pytest.fixture(scope='function') +def db(request): +    server = couchdb.Server(COUCH_URL) +    dbname = "user-" + uuid.uuid4().hex +    db = server.create(dbname) +    for doc in initial_docs: +        db.save(doc) +    request.addfinalizer(lambda: server.delete(dbname)) +    return db diff --git a/scripts/migration/0.9.0/tests/test_migrate.py b/scripts/migration/0.9.0/tests/test_migrate.py new file mode 100644 index 00000000..10c8b906 --- /dev/null +++ b/scripts/migration/0.9.0/tests/test_migrate.py @@ -0,0 +1,67 @@ +# test_migrate.py + +""" +Ensure that the migration script works! +""" + +from migrate_couch_schema import _migrate_user_db + +from leap.soledad.common.couch import GENERATION_KEY +from leap.soledad.common.couch import TRANSACTION_ID_KEY +from leap.soledad.common.couch import REPLICA_UID_KEY +from leap.soledad.common.couch import DOC_ID_KEY +from leap.soledad.common.couch import SCHEMA_VERSION_KEY +from leap.soledad.common.couch import CONFIG_DOC_ID +from leap.soledad.common.couch import SYNC_DOC_ID_PREFIX +from leap.soledad.common.couch import SCHEMA_VERSION + + +def test__migrate_user_db(db): +    _migrate_user_db(db, True) + +    # we should find exactly 6 documents: 2 normal documents and 4 generation +    # documents +    view = db.view('_all_docs') +    assert len(view.rows) == 6 + +    # ensure that the ids of the documents we found on the database are correct +    doc_ids = map(lambda doc: doc.id, view.rows) +    assert 'doc1' in doc_ids +    assert 'doc2' in doc_ids +    assert 'gen-0000000001' in doc_ids +    assert 'gen-0000000002' in doc_ids +    assert 'gen-0000000003' in doc_ids +    assert 'gen-0000000004' in doc_ids + +    # assert config doc contents +    config_doc = db.get(CONFIG_DOC_ID) +    assert config_doc[REPLICA_UID_KEY] == 'an-uid' +    assert config_doc[SCHEMA_VERSION_KEY] == SCHEMA_VERSION + +    # assert sync docs contents +    sync_doc_A = db.get('%s%s' % (SYNC_DOC_ID_PREFIX, 'A')) +    assert sync_doc_A[GENERATION_KEY] == 0 +    assert sync_doc_A[REPLICA_UID_KEY] == 'A' +    assert sync_doc_A[TRANSACTION_ID_KEY] == '' +    sync_doc_B = db.get('%s%s' % (SYNC_DOC_ID_PREFIX, 'B')) +    assert sync_doc_B[GENERATION_KEY] == 2 +    assert sync_doc_B[REPLICA_UID_KEY] == 'B' +    assert sync_doc_B[TRANSACTION_ID_KEY] == 'X' + +    # assert gen docs contents +    gen_1 = db.get('gen-0000000001') +    assert gen_1[DOC_ID_KEY] == 'doc1' +    assert gen_1[GENERATION_KEY] == 1 +    assert gen_1[TRANSACTION_ID_KEY] == 'trans-1' +    gen_2 = db.get('gen-0000000002') +    assert gen_2[DOC_ID_KEY] == 'doc2' +    assert gen_2[GENERATION_KEY] == 2 +    assert gen_2[TRANSACTION_ID_KEY] == 'trans-2' +    gen_3 = db.get('gen-0000000003') +    assert gen_3[DOC_ID_KEY] == 'doc1' +    assert gen_3[GENERATION_KEY] == 3 +    assert gen_3[TRANSACTION_ID_KEY] == 'trans-3' +    gen_4 = db.get('gen-0000000004') +    assert gen_4[DOC_ID_KEY] == 'doc2' +    assert gen_4[GENERATION_KEY] == 4 +    assert gen_4[TRANSACTION_ID_KEY] == 'trans-4' diff --git a/scripts/migration/0.9.0/tox.ini b/scripts/migration/0.9.0/tox.ini new file mode 100644 index 00000000..2bb6be4c --- /dev/null +++ b/scripts/migration/0.9.0/tox.ini @@ -0,0 +1,13 @@ +[tox] +envlist = py27 + +[testenv] +commands = py.test {posargs} +changedir = tests +deps = +    pytest +    couchdb +    pdbpp +    -e../../../common +setenv = +    TERM=xterm diff --git a/scripts/packaging/compile_design_docs.py b/scripts/packaging/compile_design_docs.py deleted file mode 100644 index b2b5729a..00000000 --- a/scripts/packaging/compile_design_docs.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/python - - -# This script builds files for the design documents represented in the -# ../common/src/soledad/common/ddocs directory structure (relative to the -# current location of the script) into a target directory. - - -import argparse -from os import listdir -from os.path import realpath, dirname, isdir, join, isfile, basename -import json - -DDOCS_REL_PATH = ('..', 'common', 'src', 'leap', 'soledad', 'common', 'ddocs') - - -def build_ddocs(): -    """ -    Build design documents. - -    For ease of development, couch backend design documents are stored as -    `.js` files in  subdirectories of -    `../common/src/leap/soledad/common/ddocs`. This function scans that -    directory for javascript files, and builds the design documents structure. - -    This funciton uses the following conventions to generate design documents: - -      - Design documents are represented by directories in the form -        `<prefix>/<ddoc>`, there prefix is the `src/leap/soledad/common/ddocs` -        directory. -      - Design document directories might contain `views`, `lists` and -        `updates` subdirectories. -      - Views subdirectories must contain a `map.js` file and may contain a -        `reduce.js` file. -      - List and updates subdirectories may contain any number of javascript -        files (i.e. ending in `.js`) whose names will be mapped to the -        corresponding list or update function name. -    """ -    ddocs = {} - -    # design docs are represented by subdirectories of `DDOCS_REL_PATH` -    cur_pwd = dirname(realpath(__file__)) -    ddocs_path = join(cur_pwd, *DDOCS_REL_PATH) -    for ddoc in [f for f in listdir(ddocs_path) -                 if isdir(join(ddocs_path, f))]: - -        ddocs[ddoc] = {'_id': '_design/%s' % ddoc} - -        for t in ['views', 'lists', 'updates']: -            tdir = join(ddocs_path, ddoc, t) -            if isdir(tdir): - -                ddocs[ddoc][t] = {} - -                if t == 'views':  # handle views (with map/reduce functions) -                    for view in [f for f in listdir(tdir) -                                 if isdir(join(tdir, f))]: -                        # look for map.js and reduce.js -                        mapfile = join(tdir, view, 'map.js') -                        reducefile = join(tdir, view, 'reduce.js') -                        mapfun = None -                        reducefun = None -                        try: -                            with open(mapfile) as f: -                                mapfun = f.read() -                        except IOError: -                            pass -                        try: -                            with open(reducefile) as f: -                                reducefun = f.read() -                        except IOError: -                            pass -                        ddocs[ddoc]['views'][view] = {} - -                        if mapfun is not None: -                            ddocs[ddoc]['views'][view]['map'] = mapfun -                        if reducefun is not None: -                            ddocs[ddoc]['views'][view]['reduce'] = reducefun - -                else:  # handle lists, updates, etc -                    for fun in [f for f in listdir(tdir) -                                if isfile(join(tdir, f))]: -                        funfile = join(tdir, fun) -                        funname = basename(funfile).replace('.js', '') -                        try: -                            with open(funfile) as f: -                                ddocs[ddoc][t][funname] = f.read() -                        except IOError: -                            pass -    return ddocs - - -if __name__ == '__main__': -    parser = argparse.ArgumentParser() -    parser.add_argument( -        'target', type=str, -        help='the target dir where to store design documents') -    args = parser.parse_args() - -    # check if given target is a directory -    if not isdir(args.target): -        print 'Error: %s is not a directory.' % args.target -        exit(1) - -    # write desifgn docs files -    ddocs = build_ddocs() -    for ddoc in ddocs: -        ddoc_filename = "%s.json" % ddoc -        with open(join(args.target, ddoc_filename), 'w') as f: -            f.write("%s" % json.dumps(ddocs[ddoc], indent=3)) -        print "Wrote _design/%s content in %s" \ -              % (ddoc, join(args.target, ddoc_filename,)) diff --git a/scripts/profiling/mail/couchdb_server.py b/scripts/profiling/mail/couchdb_server.py index 2cf0a3fd..452f8ec2 100644 --- a/scripts/profiling/mail/couchdb_server.py +++ b/scripts/profiling/mail/couchdb_server.py @@ -18,8 +18,7 @@ def start_couchdb_wrapper():  def get_u1db_database(dbname, port):      return CouchDatabase.open_database(          'http://127.0.0.1:%d/%s' % (port, dbname), -        True, -        ensure_ddocs=True) +        True)  def create_tokens_database(port, uuid, token_value): @@ -38,5 +37,5 @@ def get_couchdb_wrapper_and_u1db(uuid, token_value):      couchdb_u1db = get_u1db_database('user-%s' % uuid, couchdb_wrapper.port)      get_u1db_database('shared', couchdb_wrapper.port)      create_tokens_database(couchdb_wrapper.port, uuid, token_value) -     +      return couchdb_wrapper, couchdb_u1db diff --git a/server/pkg/create-user-db b/server/pkg/create-user-db index 5e48d4de..b955b4c3 100755 --- a/server/pkg/create-user-db +++ b/server/pkg/create-user-db @@ -80,7 +80,7 @@ def ensure_database(dbname):      url = url_for_db(dbname)      db_security = CONF['database-security']      db = CouchDatabase.open_database(url=url, create=True, -                                     replica_uid=None, ensure_ddocs=True, +                                     replica_uid=None,                                       database_security=db_security)      print ('success! Ensured that database %s exists, with replica_uid: %s' %             (db._dbname, db.replica_uid)) diff --git a/server/pkg/requirements-latest.pip b/server/pkg/requirements-latest.pip index 46a7ccba..d32e1ffa 100644 --- a/server/pkg/requirements-latest.pip +++ b/server/pkg/requirements-latest.pip @@ -1,5 +1,5 @@  --index-url https://pypi.python.org/simple/ --e 'git+https://github.com/pixelated-project/leap_pycommon.git@develop#egg=leap.common' +-e 'git+https://github.com/leapcode/leap_pycommon.git@develop#egg=leap.common'  -e '../common'  -e . diff --git a/server/pkg/requirements-leap.pip b/server/pkg/requirements-leap.pip index aaad340c..93b447e5 100644 --- a/server/pkg/requirements-leap.pip +++ b/server/pkg/requirements-leap.pip @@ -1 +1 @@ -leap.soledad.common>=0.6.5 +leap.soledad.common>=0.9.0 diff --git a/server/pkg/requirements.pip b/server/pkg/requirements.pip index 2d845f24..e92dfde6 100644 --- a/server/pkg/requirements.pip +++ b/server/pkg/requirements.pip @@ -1,6 +1,5 @@  configparser  PyOpenSSL  twisted>=12.3.0 -#pinned for wheezy compatibility -Beaker==1.6.3 #wheezy -couchdb==0.8 #wheezy +Beaker +couchdb diff --git a/server/pkg/soledad-server b/server/pkg/soledad-server index 74ed122e..9dada6a0 100644 --- a/server/pkg/soledad-server +++ b/server/pkg/soledad-server @@ -12,7 +12,6 @@  PATH=/sbin:/bin:/usr/sbin:/usr/bin  PIDFILE=/var/run/soledad.pid  OBJ=leap.soledad.server.application -LOGFILE=/var/log/soledad.log  HTTPS_PORT=2424  CONFDIR=/etc/soledad  CERT_PATH="${CONFDIR}/soledad-server.pem" @@ -37,7 +36,8 @@ case "${1}" in              --exec ${TWISTD_PATH} -- \              --uid=${USER} --gid=${GROUP} \              --pidfile=${PIDFILE} \ -            --logfile=${LOGFILE} \ +	    --syslog \ +	    --prefix=soledad-server \              web \              --wsgi=${OBJ} \              --port=ssl:${HTTPS_PORT}:privateKey=${PRIVKEY_PATH}:certKey=${CERT_PATH}:sslmethod=${SSL_METHOD} diff --git a/server/setup.py b/server/setup.py index b3b26010..a18d0b2d 100644 --- a/server/setup.py +++ b/server/setup.py @@ -122,13 +122,13 @@ requirements = utils.parse_requirements()  if utils.is_develop_mode():      print -    print ("[WARNING] Skipping leap-specific dependencies " -           "because development mode is detected.") -    print ("[WARNING] You can install " -           "the latest published versions with " -           "'pip install -r pkg/requirements-leap.pip'") -    print ("[WARNING] Or you can instead do 'python setup.py develop' " -           "from the parent folder of each one of them.") +    print("[WARNING] Skipping leap-specific dependencies " +          "because development mode is detected.") +    print("[WARNING] You can install " +          "the latest published versions with " +          "'pip install -r pkg/requirements-leap.pip'") +    print("[WARNING] Or you can instead do 'python setup.py develop' " +          "from the parent folder of each one of them.")      print  else:      requirements += utils.parse_requirements( diff --git a/server/src/leap/soledad/server/__init__.py b/server/src/leap/soledad/server/__init__.py index 34570b52..d154e3fe 100644 --- a/server/src/leap/soledad/server/__init__.py +++ b/server/src/leap/soledad/server/__init__.py @@ -80,7 +80,6 @@ documents on the shared database is handled by `leap.soledad.server.auth`  module.  """ -import configparser  import urlparse  import sys @@ -88,11 +87,10 @@ from leap.soledad.common.l2db.remote import http_app, utils  from leap.soledad.server.auth import SoledadTokenAuthMiddleware  from leap.soledad.server.gzip_middleware import GzipMiddleware -from leap.soledad.server.sync import ( -    SyncResource, -    MAX_REQUEST_SIZE, -    MAX_ENTRY_SIZE, -) +from leap.soledad.server.sync import SyncResource +from leap.soledad.server.sync import MAX_REQUEST_SIZE +from leap.soledad.server.sync import MAX_ENTRY_SIZE +from leap.soledad.server.config import load_configuration  from leap.soledad.common import SHARED_DB_NAME  from leap.soledad.common.backend import SoledadBackend @@ -100,6 +98,14 @@ from leap.soledad.common.couch.state import CouchServerState  from ._version import get_versions + +__all__ = [ +    'SoledadApp', +    'application', +    '__version__', +] + +  # ----------------------------------------------------------------------------  # Soledad WSGI application  # ---------------------------------------------------------------------------- @@ -250,57 +256,6 @@ http_app.HTTPInvocationByMethodWithBody = HTTPInvocationByMethodWithBody  # ---------------------------------------------------------------------------- -# Auxiliary functions -# ---------------------------------------------------------------------------- -CONFIG_DEFAULTS = { -    'soledad-server': { -        'couch_url': 'http://localhost:5984', -        'create_cmd': None, -        'admin_netrc': '/etc/couchdb/couchdb-admin.netrc', -        'batching': False -    }, -    'database-security': { -        'members': ['soledad'], -        'members_roles': [], -        'admins': [], -        'admins_roles': [] -    } -} - - -def load_configuration(file_path): -    """ -    Load server configuration from file. - -    @param file_path: The path to the configuration file. -    @type file_path: str - -    @return: A dictionary with the configuration. -    @rtype: dict -    """ -    defaults = dict(CONFIG_DEFAULTS) -    config = configparser.SafeConfigParser() -    config.read(file_path) -    for section in defaults: -        if not config.has_section(section): -            continue -        for key, value in defaults[section].items(): -            if not config.has_option(section, key): -                continue -            elif type(value) == bool: -                defaults[section][key] = config.getboolean(section, key) -            elif type(value) == list: -                values = config.get(section, key).split(',') -                values = [v.strip() for v in values] -                defaults[section][key] = values -            else: -                defaults[section][key] = config.get(section, key) -    # TODO: implement basic parsing/sanitization of options comming from -    # config file. -    return defaults - - -# ----------------------------------------------------------------------------  # Run as Twisted WSGI Resource  # ---------------------------------------------------------------------------- @@ -312,25 +267,23 @@ def _load_config():  def _get_couch_state():      conf = _load_config() -    state = CouchServerState(conf['couch_url'], create_cmd=conf['create_cmd']) +    state = CouchServerState(conf['couch_url'], create_cmd=conf['create_cmd'], +                             check_schema_versions=True)      SoledadBackend.BATCH_SUPPORT = conf.get('batching', False)      return state - -def application(environ, start_response): -    """return WSGI application that may be used by `twistd -web`""" -    state = _get_couch_state() +try: +    _couch_state = _get_couch_state() +    # a WSGI application that may be used by `twistd -web`      application = GzipMiddleware( -        SoledadTokenAuthMiddleware(SoledadApp(state))) -    return application(environ, start_response) +        SoledadTokenAuthMiddleware(SoledadApp(_couch_state))) +except: +    pass -def debug_local_application_do_not_use(environ, start_response): -    """in where we bypass token auth middleware for ease of mind while -    debugging in your local environment""" -    state = _get_couch_state() -    application = SoledadApp(state) -    return application(environ, start_response) +# another WSGI application in which we bypass token auth middleware for ease of +# mind while debugging in your local environment +# debug_local_application_do_not_use = SoledadApp(_couch_state)  __version__ = get_versions()['version'] diff --git a/server/src/leap/soledad/server/auth.py b/server/src/leap/soledad/server/auth.py index ecee2d5d..b7186b3b 100644 --- a/server/src/leap/soledad/server/auth.py +++ b/server/src/leap/soledad/server/auth.py @@ -22,13 +22,16 @@ import json  from abc import ABCMeta, abstractmethod  from routes.mapper import Mapper -from twisted.python import log +from leap.soledad.common.log import getLogger  from leap.soledad.common.l2db import DBNAME_CONSTRAINTS, errors as u1db_errors  from leap.soledad.common import SHARED_DB_NAME  from leap.soledad.common import USER_DB_PREFIX +logger = getLogger(__name__) + +  class URLToAuthorization(object):      """      Verify if actions can be performed by a user. @@ -378,7 +381,7 @@ class SoledadTokenAuthMiddleware(SoledadAuthMiddleware):          try:              return self._state.verify_token(uuid, token)          except Exception as e: -            log.err(e) +            logger.error(e)              return False      def _get_auth_error_string(self): diff --git a/server/src/leap/soledad/server/config.py b/server/src/leap/soledad/server/config.py new file mode 100644 index 00000000..4a791cbe --- /dev/null +++ b/server/src/leap/soledad/server/config.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# config.py +# Copyright (C) 2016 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import configparser + + +CONFIG_DEFAULTS = { +    'soledad-server': { +        'couch_url': 'http://localhost:5984', +        'create_cmd': None, +        'admin_netrc': '/etc/couchdb/couchdb-admin.netrc', +        'batching': False +    }, +    'database-security': { +        'members': ['soledad'], +        'members_roles': [], +        'admins': [], +        'admins_roles': [] +    } +} + + +def load_configuration(file_path): +    """ +    Load server configuration from file. + +    @param file_path: The path to the configuration file. +    @type file_path: str + +    @return: A dictionary with the configuration. +    @rtype: dict +    """ +    defaults = dict(CONFIG_DEFAULTS) +    config = configparser.SafeConfigParser() +    config.read(file_path) +    for section in defaults: +        if not config.has_section(section): +            continue +        for key, value in defaults[section].items(): +            if not config.has_option(section, key): +                continue +            elif type(value) == bool: +                defaults[section][key] = config.getboolean(section, key) +            elif type(value) == list: +                values = config.get(section, key).split(',') +                values = [v.strip() for v in values] +                defaults[section][key] = values +            else: +                defaults[section][key] = config.get(section, key) +    # TODO: implement basic parsing/sanitization of options comming from +    # config file. +    return defaults diff --git a/testing/pytest.ini b/testing/pytest.ini new file mode 100644 index 00000000..2d34c607 --- /dev/null +++ b/testing/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +norecursedirs = tests/perf diff --git a/testing/setup.py b/testing/setup.py index 059b2489..c1204c9a 100644 --- a/testing/setup.py +++ b/testing/setup.py @@ -5,5 +5,5 @@ from setuptools import find_packages  setup(      name='test_soledad',      packages=find_packages('.'), -    package_data={'': ['*.conf']} +    package_data={'': ['*.conf', 'u1db_tests/testing-certs/*']}  ) diff --git a/testing/test_soledad/u1db_tests/test_open.py b/testing/test_soledad/u1db_tests/test_open.py index 30d4de00..b572fba0 100644 --- a/testing/test_soledad/u1db_tests/test_open.py +++ b/testing/test_soledad/u1db_tests/test_open.py @@ -18,24 +18,25 @@  """Test u1db.open"""  import os +import pytest +  from unittest import skip -from leap.soledad.common.l2db import ( -    errors, open as u1db_open, -)  from test_soledad import u1db_tests as tests +from test_soledad.u1db_tests.test_backends import TestAlternativeDocument + +from leap.soledad.common.l2db import errors +from leap.soledad.common.l2db import open as u1db_open  from leap.soledad.common.l2db.backends import sqlite_backend -from test_soledad.u1db_tests.test_backends \ -    import TestAlternativeDocument  @skip("Skiping tests imported from U1DB.") +@pytest.mark.usefixtures('method_tmpdir')  class TestU1DBOpen(tests.TestCase):      def setUp(self):          super(TestU1DBOpen, self).setUp() -        tmpdir = self.createTempDir() -        self.db_path = tmpdir + '/test.db' +        self.db_path = self.tempdir + '/test.db'      def test_open_no_create(self):          self.assertRaises(errors.DatabaseDoesNotExist, diff --git a/testing/test_soledad/util.py b/testing/test_soledad/util.py index 033a55df..d53f6cda 100644 --- a/testing/test_soledad/util.py +++ b/testing/test_soledad/util.py @@ -22,11 +22,10 @@ Utilities used by multiple test suites.  import os -import tempfile -import shutil  import random  import string  import couchdb +import pytest  from uuid import uuid4  from mock import Mock @@ -42,7 +41,6 @@ from leap.soledad.common import l2db  from leap.soledad.common.l2db import sync  from leap.soledad.common.l2db.remote import http_database -from leap.soledad.common import soledad_assert  from leap.soledad.common.document import SoledadDocument  from leap.soledad.common.couch import CouchDatabase  from leap.soledad.common.couch.state import CouchServerState @@ -225,6 +223,7 @@ class BaseSoledadTest(BaseLeapTest, MockedSharedDBTest):      """      defer_sync_encryption = False +    @pytest.mark.usefixtures("method_tmpdir")      def setUp(self):          # The following snippet comes from BaseLeapTest.setUpClass, but we          # repeat it here because twisted.trial does not work with @@ -232,7 +231,6 @@ class BaseSoledadTest(BaseLeapTest, MockedSharedDBTest):          self.old_path = os.environ['PATH']          self.old_home = os.environ['HOME'] -        self.tempdir = tempfile.mkdtemp(prefix="leap_tests-")          self.home = self.tempdir          bin_tdir = os.path.join(              self.tempdir, @@ -275,14 +273,6 @@ class BaseSoledadTest(BaseLeapTest, MockedSharedDBTest):                        self._soledad.secrets.secrets_path]:                  if os.path.isfile(f):                      os.unlink(f) -            # The following snippet comes from BaseLeapTest.setUpClass, but we -            # repeat it here because twisted.trial does not work with -            # setUpClass/tearDownClass. -            soledad_assert( -                self.tempdir.startswith('/tmp/leap_tests-'), -                "beware! tried to remove a dir which does not " -                "live in temporal folder!") -            shutil.rmtree(self.tempdir)          from twisted.internet import reactor          reactor.addSystemEventTrigger( @@ -344,6 +334,7 @@ class BaseSoledadTest(BaseLeapTest, MockedSharedDBTest):          self.assertEqual(exp_doc.content, doc.content) +@pytest.mark.usefixtures("couch_url")  class CouchDBTestCase(unittest.TestCase, MockedSharedDBTest):      """ @@ -354,8 +345,6 @@ class CouchDBTestCase(unittest.TestCase, MockedSharedDBTest):          """          Make sure we have a CouchDB instance for a test.          """ -        self.couch_port = 5984 -        self.couch_url = 'http://localhost:%d' % self.couch_port          self.couch_server = couchdb.Server(self.couch_url)      def delete_db(self, name): @@ -391,8 +380,7 @@ class CouchServerStateForTests(CouchServerState):          db = CouchDatabase.open_database(              urljoin(self.couch_url, dbname),              True, -            replica_uid=replica_uid or 'test', -            ensure_ddocs=True) +            replica_uid=replica_uid or 'test')          self.dbs.append(db)          return db diff --git a/testing/tests/client/test_app.py b/testing/tests/client/test_app.py index fef2f371..6867473e 100644 --- a/testing/tests/client/test_app.py +++ b/testing/tests/client/test_app.py @@ -17,6 +17,8 @@  """  Test ObjectStore and Couch backend bits.  """ +import pytest +  from testscenarios import TestWithScenarios  from test_soledad.util import BaseSoledadTest @@ -31,9 +33,15 @@ from test_soledad.u1db_tests import test_backends  # The following tests come from `u1db.tests.test_backends`.  # ----------------------------------------------------------------------------- +@pytest.mark.usefixtures('method_tmpdir')  class SoledadTests(          TestWithScenarios, test_backends.AllDatabaseTests, BaseSoledadTest): +    def setUp(self): +        TestWithScenarios.setUp(self) +        test_backends.AllDatabaseTests.setUp(self) +        BaseSoledadTest.setUp(self) +      scenarios = [          ('token_http', {              'make_database_for_test': make_token_http_database_for_test, diff --git a/testing/tests/client/test_doc.py b/testing/tests/client/test_doc.py index e158d768..36479e90 100644 --- a/testing/tests/client/test_doc.py +++ b/testing/tests/client/test_doc.py @@ -17,6 +17,8 @@  """  Test Leap backend bits: soledad docs  """ +import pytest +  from testscenarios import TestWithScenarios  from test_soledad.u1db_tests import test_document @@ -28,6 +30,7 @@ from test_soledad.util import make_soledad_document_for_test  # The following tests come from `u1db.tests.test_document`.  # ----------------------------------------------------------------------------- +@pytest.mark.usefixtures('method_tmpdir')  class TestSoledadDocument(          TestWithScenarios,          test_document.TestDocument, BaseSoledadTest): @@ -37,6 +40,7 @@ class TestSoledadDocument(              'make_document_for_test': make_soledad_document_for_test})]) +@pytest.mark.usefixtures('method_tmpdir')  class TestSoledadPyDocument(          TestWithScenarios,          test_document.TestPyDocument, BaseSoledadTest): diff --git a/testing/tests/client/test_https.py b/testing/tests/client/test_https.py index caac16da..1b6caed6 100644 --- a/testing/tests/client/test_https.py +++ b/testing/tests/client/test_https.py @@ -17,7 +17,7 @@  """  Test Leap backend bits: https  """ -from unittest import skip +import pytest  from testscenarios import TestWithScenarios @@ -62,7 +62,7 @@ def token_leap_https_sync_target(test, host, path, cert_file=None):      return st -@skip("Skiping tests imported from U1DB.") +@pytest.mark.skip  class TestSoledadHTTPSyncTargetHttpsSupport(          TestWithScenarios,          # test_https.TestHttpSyncTargetHttpsSupport, diff --git a/testing/tests/conftest.py b/testing/tests/conftest.py new file mode 100644 index 00000000..9e4319ac --- /dev/null +++ b/testing/tests/conftest.py @@ -0,0 +1,18 @@ +import pytest + + +def pytest_addoption(parser): +    parser.addoption( +        "--couch-url", type="string", default="http://127.0.0.1:5984", +        help="the url for the couch server to be used during tests") + + +@pytest.fixture +def couch_url(request): +    url = request.config.getoption('--couch-url') +    request.cls.couch_url = url + + +@pytest.fixture +def method_tmpdir(request, tmpdir): +    request.instance.tempdir = tmpdir.strpath diff --git a/testing/tests/couch/common.py b/testing/tests/couch/common.py index b08e1fa3..84790059 100644 --- a/testing/tests/couch/common.py +++ b/testing/tests/couch/common.py @@ -13,20 +13,17 @@ nested_doc = tests.nested_doc  def make_couch_database_for_test(test, replica_uid): -    port = str(test.couch_port)      dbname = ('test-%s' % uuid4().hex)      db = couch.CouchDatabase.open_database( -        urljoin('http://localhost:' + port, dbname), +        urljoin(test.couch_url, dbname),          create=True, -        replica_uid=replica_uid or 'test', -        ensure_ddocs=True) +        replica_uid=replica_uid or 'test')      test.addCleanup(test.delete_db, dbname)      return db  def copy_couch_database_for_test(test, db): -    port = str(test.couch_port) -    couch_url = 'http://localhost:' + port +    couch_url = test.couch_url      new_dbname = db._dbname + '_copy'      new_db = couch.CouchDatabase.open_database(          urljoin(couch_url, new_dbname), @@ -41,15 +38,10 @@ def copy_couch_database_for_test(test, db):          # bypass u1db_config document          if doc_id == 'u1db_config':              pass -        # copy design docs -        elif doc_id.startswith('_design'): -            del doc['_rev'] -            new_couch_db.save(doc)          # copy u1db docs          elif 'u1db_rev' in doc:              new_doc = {                  '_id': doc['_id'], -                'u1db_transactions': doc['u1db_transactions'],                  'u1db_rev': doc['u1db_rev']              }              attachments = [] @@ -65,6 +57,8 @@ def copy_couch_database_for_test(test, db):                  if (att is not None):                      new_couch_db.put_attachment(new_doc, att,                                                  filename=att_name) +        elif doc_id.startswith('gen-'): +            new_couch_db.save(doc)      # cleanup connections to prevent file descriptor leaking      return new_db diff --git a/testing/tests/couch/conftest.py b/testing/tests/couch/conftest.py new file mode 100644 index 00000000..1074f091 --- /dev/null +++ b/testing/tests/couch/conftest.py @@ -0,0 +1,31 @@ +import couchdb +import pytest +import random +import string + + +@pytest.fixture +def random_name(): +    return 'user-' + ''.join( +        random.choice( +            string.ascii_lowercase) for _ in range(10)) + + +class RandomDatabase(object): + +    def __init__(self, couch_url, name): +        self.couch_url = couch_url +        self.name = name +        self.server = couchdb.client.Server(couch_url) +        self.database = self.server.create(name) + +    def teardown(self): +        self.server.delete(self.name) + + +@pytest.fixture +def db(random_name, request): +    couch_url = request.config.getoption('--couch-url') +    db = RandomDatabase(couch_url, random_name) +    request.addfinalizer(db.teardown) +    return db diff --git a/testing/tests/couch/couchdb.ini.template b/testing/tests/couch/couchdb.ini.template deleted file mode 100644 index 174d9d86..00000000 --- a/testing/tests/couch/couchdb.ini.template +++ /dev/null @@ -1,22 +0,0 @@ -; etc/couchdb/default.ini.tpl.  Generated from default.ini.tpl.in by configure. - -; Upgrading CouchDB will overwrite this file. - -[couchdb] -database_dir = %(tempdir)s/lib -view_index_dir = %(tempdir)s/lib -max_document_size = 4294967296 ; 4 GB -os_process_timeout = 120000 ; 120 seconds. for view and external servers. -max_dbs_open = 100 -delayed_commits = true ; set this to false to ensure an fsync before 201 Created is returned -uri_file = %(tempdir)s/lib/couch.uri -file_compression = snappy - -[log] -file = %(tempdir)s/log/couch.log -level = info -include_sasl = true - -[httpd] -port = 0 -bind_address = 127.0.0.1 diff --git a/testing/tests/couch/test_atomicity.py b/testing/tests/couch/test_atomicity.py index aec9c6cf..a3ae0314 100644 --- a/testing/tests/couch/test_atomicity.py +++ b/testing/tests/couch/test_atomicity.py @@ -18,7 +18,7 @@  Test atomicity of couch operations.  """  import os -import tempfile +import pytest  import threading  from urlparse import urljoin @@ -41,6 +41,7 @@ from test_soledad.u1db_tests import TestCaseWithServer  REPEAT_TIMES = 20 +@pytest.mark.usefixtures('method_tmpdir')  class CouchAtomicityTestCase(CouchDBTestCase, TestCaseWithServer):      @staticmethod @@ -90,9 +91,7 @@ class CouchAtomicityTestCase(CouchDBTestCase, TestCaseWithServer):          self.db = CouchDatabase.open_database(              urljoin(self.couch_url, 'user-' + self.user),              create=True, -            replica_uid='replica', -            ensure_ddocs=True) -        self.tempdir = tempfile.mkdtemp(prefix="leap_tests-") +            replica_uid='replica')          self.startTwistedServer()      def tearDown(self): diff --git a/testing/tests/couch/test_backend.py b/testing/tests/couch/test_backend.py index f178e8a5..4fad11cf 100644 --- a/testing/tests/couch/test_backend.py +++ b/testing/tests/couch/test_backend.py @@ -39,12 +39,8 @@ class TestCouchBackendImpl(CouchDBTestCase):      def test__allocate_doc_id(self):          db = couch.CouchDatabase.open_database( -            urljoin( -                'http://localhost:' + str(self.couch_port), -                ('test-%s' % uuid4().hex) -            ), -            create=True, -            ensure_ddocs=True) +            urljoin(self.couch_url, 'test-%s' % uuid4().hex), +            create=True)          doc_id1 = db._allocate_doc_id()          self.assertTrue(doc_id1.startswith('D-'))          self.assertEqual(34, len(doc_id1)) diff --git a/testing/tests/couch/test_command.py b/testing/tests/couch/test_command.py index f61e118d..68097fb1 100644 --- a/testing/tests/couch/test_command.py +++ b/testing/tests/couch/test_command.py @@ -1,6 +1,6 @@  from twisted.trial import unittest -from leap.soledad.common import couch +from leap.soledad.common.couch import state as couch_state  from leap.soledad.common.l2db import errors as u1db_errors  from mock import Mock @@ -9,7 +9,8 @@ from mock import Mock  class CommandBasedDBCreationTest(unittest.TestCase):      def test_ensure_db_using_custom_command(self): -        state = couch.state.CouchServerState("url", create_cmd="echo") +        state = couch_state.CouchServerState( +            "url", create_cmd="/bin/echo", check_schema_versions=False)          mock_db = Mock()          mock_db.replica_uid = 'replica_uid'          state.open_database = Mock(return_value=mock_db) @@ -18,11 +19,12 @@ class CommandBasedDBCreationTest(unittest.TestCase):          self.assertEquals(mock_db.replica_uid, replica_uid)      def test_raises_unauthorized_on_failure(self): -        state = couch.state.CouchServerState("url", create_cmd="inexistent") +        state = couch_state.CouchServerState( +            "url", create_cmd="inexistent", check_schema_versions=False)          self.assertRaises(u1db_errors.Unauthorized,                            state.ensure_database, "user-1337")      def test_raises_unauthorized_by_default(self): -        state = couch.state.CouchServerState("url") +        state = couch_state.CouchServerState("url", check_schema_versions=False)          self.assertRaises(u1db_errors.Unauthorized,                            state.ensure_database, "user-1337") diff --git a/testing/tests/couch/test_ddocs.py b/testing/tests/couch/test_ddocs.py index 9ff32633..3937f2de 100644 --- a/testing/tests/couch/test_ddocs.py +++ b/testing/tests/couch/test_ddocs.py @@ -1,6 +1,5 @@  from uuid import uuid4 -from leap.soledad.common.couch import errors  from leap.soledad.common import couch  from test_soledad.util import CouchDBTestCase @@ -10,174 +9,27 @@ class CouchDesignDocsTests(CouchDBTestCase):      def setUp(self):          CouchDBTestCase.setUp(self) +        self.create_db() -    def create_db(self, ensure=True, dbname=None): +    def create_db(self, dbname=None):          if not dbname:              dbname = ('test-%s' % uuid4().hex)          if dbname not in self.couch_server:              self.couch_server.create(dbname)          self.db = couch.CouchDatabase( -            ('http://127.0.0.1:%d' % self.couch_port), -            dbname, -            ensure_ddocs=ensure) +            (self.couch_url), +            dbname)      def tearDown(self):          self.db.delete_database()          self.db.close()          CouchDBTestCase.tearDown(self) -    def test_missing_design_doc_raises(self): -        """ -        Test that all methods that access design documents will raise if the -        design docs are not present. -        """ -        self.create_db(ensure=False) -        # get_generation_info() -        self.assertRaises( -            errors.MissingDesignDocError, -            self.db.get_generation_info) -        # get_trans_id_for_gen() -        self.assertRaises( -            errors.MissingDesignDocError, -            self.db.get_trans_id_for_gen, 1) -        # get_transaction_log() -        self.assertRaises( -            errors.MissingDesignDocError, -            self.db.get_transaction_log) -        # whats_changed() -        self.assertRaises( -            errors.MissingDesignDocError, -            self.db.whats_changed) - -    def test_missing_design_doc_functions_raises(self): -        """ -        Test that all methods that access design documents list functions -        will raise if the functions are not present. -        """ -        self.create_db(ensure=True) -        # erase views from _design/transactions -        transactions = self.db._database['_design/transactions'] -        transactions['lists'] = {} -        self.db._database.save(transactions) -        # get_generation_info() -        self.assertRaises( -            errors.MissingDesignDocListFunctionError, -            self.db.get_generation_info) -        # get_trans_id_for_gen() -        self.assertRaises( -            errors.MissingDesignDocListFunctionError, -            self.db.get_trans_id_for_gen, 1) -        # whats_changed() -        self.assertRaises( -            errors.MissingDesignDocListFunctionError, -            self.db.whats_changed) - -    def test_absent_design_doc_functions_raises(self): -        """ -        Test that all methods that access design documents list functions -        will raise if the functions are not present. -        """ -        self.create_db(ensure=True) -        # erase views from _design/transactions -        transactions = self.db._database['_design/transactions'] -        del transactions['lists'] -        self.db._database.save(transactions) -        # get_generation_info() -        self.assertRaises( -            errors.MissingDesignDocListFunctionError, -            self.db.get_generation_info) -        # _get_trans_id_for_gen() -        self.assertRaises( -            errors.MissingDesignDocListFunctionError, -            self.db.get_trans_id_for_gen, 1) -        # whats_changed() -        self.assertRaises( -            errors.MissingDesignDocListFunctionError, -            self.db.whats_changed) - -    def test_missing_design_doc_named_views_raises(self): -        """ -        Test that all methods that access design documents' named views  will -        raise if the views are not present. -        """ -        self.create_db(ensure=True) -        # erase views from _design/docs -        docs = self.db._database['_design/docs'] -        del docs['views'] -        self.db._database.save(docs) -        # erase views from _design/syncs -        syncs = self.db._database['_design/syncs'] -        del syncs['views'] -        self.db._database.save(syncs) -        # erase views from _design/transactions -        transactions = self.db._database['_design/transactions'] -        del transactions['views'] -        self.db._database.save(transactions) -        # get_generation_info() -        self.assertRaises( -            errors.MissingDesignDocNamedViewError, -            self.db.get_generation_info) -        # _get_trans_id_for_gen() -        self.assertRaises( -            errors.MissingDesignDocNamedViewError, -            self.db.get_trans_id_for_gen, 1) -        # _get_transaction_log() -        self.assertRaises( -            errors.MissingDesignDocNamedViewError, -            self.db.get_transaction_log) -        # whats_changed() -        self.assertRaises( -            errors.MissingDesignDocNamedViewError, -            self.db.whats_changed) - -    def test_deleted_design_doc_raises(self): -        """ -        Test that all methods that access design documents will raise if the -        design docs are not present. -        """ -        self.create_db(ensure=True) -        # delete _design/docs -        del self.db._database['_design/docs'] -        # delete _design/syncs -        del self.db._database['_design/syncs'] -        # delete _design/transactions -        del self.db._database['_design/transactions'] -        # get_generation_info() -        self.assertRaises( -            errors.MissingDesignDocDeletedError, -            self.db.get_generation_info) -        # get_trans_id_for_gen() -        self.assertRaises( -            errors.MissingDesignDocDeletedError, -            self.db.get_trans_id_for_gen, 1) -        # get_transaction_log() -        self.assertRaises( -            errors.MissingDesignDocDeletedError, -            self.db.get_transaction_log) -        # whats_changed() -        self.assertRaises( -            errors.MissingDesignDocDeletedError, -            self.db.whats_changed) - -    def test_ensure_ddoc_independently(self): -        """ -        Test that a missing ddocs other than _design/docs will be ensured -        even if _design/docs is there. -        """ -        self.create_db(ensure=True) -        del self.db._database['_design/transactions'] -        self.assertRaises( -            errors.MissingDesignDocDeletedError, -            self.db.get_transaction_log) -        self.create_db(ensure=True, dbname=self.db._dbname) -        self.db.get_transaction_log() -      def test_ensure_security_doc(self):          """          Ensure_security creates a _security ddoc to ensure that only soledad          will have the lowest privileged access to an user db.          """ -        self.create_db(ensure=False)          self.assertFalse(self.db._database.resource.get_json('_security')[2])          self.db.ensure_security_ddoc()          security_ddoc = self.db._database.resource.get_json('_security')[2] @@ -190,7 +42,6 @@ class CouchDesignDocsTests(CouchDBTestCase):          """          Given a configuration, follow it to create the security document          """ -        self.create_db(ensure=False)          configuration = {'members': ['user1', 'user2'],                           'members_roles': ['role1', 'role2'],                           'admins': ['admin'], diff --git a/testing/tests/couch/test_state.py b/testing/tests/couch/test_state.py new file mode 100644 index 00000000..e293b5b8 --- /dev/null +++ b/testing/tests/couch/test_state.py @@ -0,0 +1,25 @@ +import pytest + +from leap.soledad.common.couch import CONFIG_DOC_ID +from leap.soledad.common.couch import SCHEMA_VERSION +from leap.soledad.common.couch import SCHEMA_VERSION_KEY +from leap.soledad.common.couch.state import CouchServerState + +from leap.soledad.common.errors import WrongCouchSchemaVersionError +from leap.soledad.common.errors import MissingCouchConfigDocumentError + + +def test_wrong_couch_version_raises(db): +    wrong_schema_version = SCHEMA_VERSION + 1 +    db.database.create( +        {'_id': CONFIG_DOC_ID, SCHEMA_VERSION_KEY: wrong_schema_version}) +    with pytest.raises(WrongCouchSchemaVersionError): +        CouchServerState(db.couch_url, create_cmd='/bin/echo', +                         check_schema_versions=True) + + +def test_missing_config_doc_raises(db): +    db.database.create({}) +    with pytest.raises(MissingCouchConfigDocumentError): +        CouchServerState(db.couch_url, create_cmd='/bin/echo', +                         check_schema_versions=True) diff --git a/testing/tests/perf/assets/cert_default.conf b/testing/tests/perf/assets/cert_default.conf new file mode 100644 index 00000000..8043cea3 --- /dev/null +++ b/testing/tests/perf/assets/cert_default.conf @@ -0,0 +1,15 @@ +[ req ] +default_bits           = 1024 +default_keyfile        = keyfile.pem +distinguished_name     = req_distinguished_name +prompt                 = no +output_password        = mypass + +[ req_distinguished_name ] +C                      = GB +ST                     = Test State or Province +L                      = Test Locality +O                      = Organization Name +OU                     = Organizational Unit Name +CN                     = localhost +emailAddress           = test@email.address diff --git a/testing/tests/perf/conftest.py b/testing/tests/perf/conftest.py new file mode 100644 index 00000000..5ac1f3c0 --- /dev/null +++ b/testing/tests/perf/conftest.py @@ -0,0 +1,249 @@ +import json +import os +import pytest +import requests +import random +import base64 +import signal +import time + +from hashlib import sha512 +from uuid import uuid4 +from subprocess import call +from urlparse import urljoin +from twisted.internet import threads, reactor + +from leap.soledad.client import Soledad +from leap.soledad.common.couch import CouchDatabase + + +# we have to manually setup the events server in order to be able to signal +# events. This is usually done by the enclosing application using soledad +# client (i.e. bitmask client). +from leap.common.events import server +server.ensure_server() + + +def pytest_addoption(parser): +    parser.addoption( +        "--couch-url", type="string", default="http://127.0.0.1:5984", +        help="the url for the couch server to be used during tests") +    parser.addoption( +        "--num-docs", type="int", default=100, +        help="the number of documents to use in performance tests") + + +# +# default options for all tests +# + +DEFAULT_PASSPHRASE = '123' + +DEFAULT_URL = 'http://127.0.0.1:2424' +DEFAULT_PRIVKEY = 'soledad_privkey.pem' +DEFAULT_CERTKEY = 'soledad_certkey.pem' +DEFAULT_TOKEN = 'an-auth-token' + + +@pytest.fixture() +def payload(): +    def generate(size): +        random.seed(1337)  # same seed to avoid different bench results +        payload_bytes = bytearray(random.getrandbits(8) for _ in xrange(size)) +        # encode as base64 to avoid ascii encode/decode errors +        return base64.b64encode(payload_bytes)[:size]  # remove b64 overhead +    return generate + + +# +# soledad_dbs fixture: provides all databases needed by soledad server in a per +# module scope (same databases for all tests in this module). +# + +def _token_dbname(): +    dbname = 'tokens_' + \ +        str(int(time.time() / (30 * 24 * 3600))) +    return dbname + + +class SoledadDatabases(object): + +    def __init__(self, url): +        self._token_db_url = urljoin(url, _token_dbname()) +        self._shared_db_url = urljoin(url, 'shared') + +    def setup(self, uuid): +        self._create_dbs() +        self._add_token(uuid) + +    def _create_dbs(self): +        requests.put(self._token_db_url) +        requests.put(self._shared_db_url) + +    def _add_token(self, uuid): +        token = sha512(DEFAULT_TOKEN).hexdigest() +        content = {'type': 'Token', 'user_id': uuid} +        requests.put( +            self._token_db_url + '/' + token, data=json.dumps(content)) + +    def teardown(self): +        requests.delete(self._token_db_url) +        requests.delete(self._shared_db_url) + + +@pytest.fixture() +def soledad_dbs(request): +    couch_url = request.config.option.couch_url + +    def create(uuid): +        db = SoledadDatabases(couch_url) +        request.addfinalizer(db.teardown) +        return db.setup(uuid) +    return create + + +# +# remote_db fixture: provides an empty database for a given user in a per +# function scope. +# + +class UserDatabase(object): + +    def __init__(self, url, uuid): +        self._remote_db_url = urljoin(url, 'user-%s' % uuid) + +    def setup(self): +        return CouchDatabase.open_database( +            url=self._remote_db_url, create=True, replica_uid=None) + +    def teardown(self): +        requests.delete(self._remote_db_url) + + +@pytest.fixture() +def remote_db(request): +    couch_url = request.config.option.couch_url + +    def create(uuid): +        db = UserDatabase(couch_url, uuid) +        request.addfinalizer(db.teardown) +        return db.setup() +    return create + + +def get_pid(pidfile): +    if not os.path.isfile(pidfile): +        return 0 +    try: +        with open(pidfile) as f: +            return int(f.read()) +    except IOError: +        return 0 + + +# +# soledad_server fixture: provides a running soledad server in a per module +# context (same soledad server for all tests in this module). +# + +class SoledadServer(object): + +    def __init__(self, tmpdir_factory, couch_url): +        tmpdir = tmpdir_factory.mktemp('soledad-server') +        self._pidfile = os.path.join(tmpdir.strpath, 'soledad-server.pid') +        self._logfile = os.path.join(tmpdir.strpath, 'soledad-server.log') +        self._couch_url = couch_url + +    def start(self): +        self._create_conf_file() +        # start the server +        call([ +            'twistd', +            '--logfile=%s' % self._logfile, +            '--pidfile=%s' % self._pidfile, +            'web', +            '--wsgi=leap.soledad.server.application', +            '--port=2424' +        ]) + +    def _create_conf_file(self): +        if not os.access('/etc', os.W_OK): +            return +        if not os.path.isdir('/etc/soledad'): +            os.mkdir('/etc/soledad') +        with open('/etc/soledad/soledad-server.conf', 'w') as f: +            content = '[soledad-server]\ncouch_url = %s' % self._couch_url +            f.write(content) + +    def stop(self): +        pid = get_pid(self._pidfile) +        os.kill(pid, signal.SIGKILL) + + +@pytest.fixture(scope='module') +def soledad_server(tmpdir_factory, request): +    couch_url = request.config.option.couch_url +    server = SoledadServer(tmpdir_factory, couch_url) +    server.start() +    request.addfinalizer(server.stop) +    return server + + +@pytest.fixture() +def txbenchmark(benchmark): +    def blockOnThread(*args, **kwargs): +        return threads.deferToThread( +            benchmark, threads.blockingCallFromThread, +            reactor, *args, **kwargs) +    return blockOnThread + + +@pytest.fixture() +def txbenchmark_with_setup(benchmark): +    def blockOnThreadWithSetup(setup, f): +        def blocking_runner(*args, **kwargs): +            return threads.blockingCallFromThread(reactor, f, *args, **kwargs) + +        def blocking_setup(): +            args = threads.blockingCallFromThread(reactor, setup) +            try: +                return tuple(arg for arg in args), {} +            except TypeError: +                    return ((args,), {}) if args else None + +        def bench(): +            return benchmark.pedantic(blocking_runner, setup=blocking_setup, +                                      rounds=4, warmup_rounds=1) +        return threads.deferToThread(bench) +    return blockOnThreadWithSetup + + +# +# soledad_client fixture: provides a clean soledad client for a test function. +# + +@pytest.fixture() +def soledad_client(tmpdir, soledad_server, remote_db, soledad_dbs, request): +    passphrase = DEFAULT_PASSPHRASE +    server_url = DEFAULT_URL +    token = DEFAULT_TOKEN +    default_uuid = uuid4().hex +    remote_db(default_uuid) +    soledad_dbs(default_uuid) + +    # get a soledad instance +    def create(): +        secrets_path = os.path.join(tmpdir.strpath, '%s.secret' % uuid4().hex) +        local_db_path = os.path.join(tmpdir.strpath, '%s.db' % uuid4().hex) +        soledad_client = Soledad( +            default_uuid, +            unicode(passphrase), +            secrets_path=secrets_path, +            local_db_path=local_db_path, +            server_url=server_url, +            cert_file=None, +            auth_token=token, +            defer_encryption=True) +        request.addfinalizer(soledad_client.close) +        return soledad_client +    return create diff --git a/testing/tests/perf/pytest.ini b/testing/tests/perf/pytest.ini new file mode 100644 index 00000000..7a0508ce --- /dev/null +++ b/testing/tests/perf/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +twisted = yes diff --git a/testing/tests/perf/test_crypto.py b/testing/tests/perf/test_crypto.py new file mode 100644 index 00000000..be00560b --- /dev/null +++ b/testing/tests/perf/test_crypto.py @@ -0,0 +1,81 @@ +import pytest +import json +from uuid import uuid4 +from leap.soledad.common.document import SoledadDocument +from leap.soledad.client.crypto import encrypt_sym +from leap.soledad.client.crypto import decrypt_sym + + +def create_doc_encryption(size): +    @pytest.mark.benchmark(group="test_crypto_encrypt_doc") +    def test_doc_encryption(soledad_client, benchmark, payload): +        crypto = soledad_client()._crypto + +        DOC_CONTENT = {'payload': payload(size)} +        doc = SoledadDocument( +            doc_id=uuid4().hex, rev='rev', +            json=json.dumps(DOC_CONTENT)) + +        benchmark(crypto.encrypt_doc, doc) +    return test_doc_encryption + + +def create_doc_decryption(size): +    @pytest.mark.benchmark(group="test_crypto_decrypt_doc") +    def test_doc_decryption(soledad_client, benchmark, payload): +        crypto = soledad_client()._crypto + +        DOC_CONTENT = {'payload': payload(size)} +        doc = SoledadDocument( +            doc_id=uuid4().hex, rev='rev', +            json=json.dumps(DOC_CONTENT)) +        encrypted_doc = crypto.encrypt_doc(doc) +        doc.set_json(encrypted_doc) + +        benchmark(crypto.decrypt_doc, doc) +    return test_doc_decryption + + +test_encrypt_doc_10k = create_doc_encryption(10*1000) +test_encrypt_doc_100k = create_doc_encryption(100*1000) +test_encrypt_doc_500k = create_doc_encryption(500*1000) +test_encrypt_doc_1M = create_doc_encryption(1000*1000) +test_encrypt_doc_10M = create_doc_encryption(10*1000*1000) +test_encrypt_doc_50M = create_doc_encryption(50*1000*1000) +test_decrypt_doc_10k = create_doc_decryption(10*1000) +test_decrypt_doc_100k = create_doc_decryption(100*1000) +test_decrypt_doc_500k = create_doc_decryption(500*1000) +test_decrypt_doc_1M = create_doc_decryption(1000*1000) +test_decrypt_doc_10M = create_doc_decryption(10*1000*1000) +test_decrypt_doc_50M = create_doc_decryption(50*1000*1000) + + +def create_raw_encryption(size): +    @pytest.mark.benchmark(group="test_crypto_raw_encrypt") +    def test_raw_encrypt(benchmark, payload): +        key = payload(32) +        benchmark(encrypt_sym, payload(size), key) +    return test_raw_encrypt + + +def create_raw_decryption(size): +    @pytest.mark.benchmark(group="test_crypto_raw_decrypt") +    def test_raw_decrypt(benchmark, payload): +        key = payload(32) +        iv, ciphertext = encrypt_sym(payload(size), key) +        benchmark(decrypt_sym, ciphertext, key, iv) +    return test_raw_decrypt + + +test_encrypt_raw_10k = create_raw_encryption(10*1000) +test_encrypt_raw_100k = create_raw_encryption(100*1000) +test_encrypt_raw_500k = create_raw_encryption(500*1000) +test_encrypt_raw_1M = create_raw_encryption(1000*1000) +test_encrypt_raw_10M = create_raw_encryption(10*1000*1000) +test_encrypt_raw_50M = create_raw_encryption(50*1000*1000) +test_decrypt_raw_10k = create_raw_decryption(10*1000) +test_decrypt_raw_100k = create_raw_decryption(100*1000) +test_decrypt_raw_500k = create_raw_decryption(500*1000) +test_decrypt_raw_1M = create_raw_decryption(1000*1000) +test_decrypt_raw_10M = create_raw_decryption(10*1000*1000) +test_decrypt_raw_50M = create_raw_decryption(50*1000*1000) diff --git a/testing/tests/perf/test_encdecpool.py b/testing/tests/perf/test_encdecpool.py new file mode 100644 index 00000000..77091a41 --- /dev/null +++ b/testing/tests/perf/test_encdecpool.py @@ -0,0 +1,78 @@ +import pytest +import json +from uuid import uuid4 +from twisted.internet.defer import gatherResults +from leap.soledad.client.encdecpool import SyncEncrypterPool +from leap.soledad.client.encdecpool import SyncDecrypterPool +from leap.soledad.common.document import SoledadDocument +# FIXME: test load is low due issue #7370, higher values will get out of memory + + +def create_encrypt(amount, size): +    @pytest.mark.benchmark(group="test_pool_encrypt") +    @pytest.inlineCallbacks +    def test(soledad_client, txbenchmark_with_setup, request, payload): +        DOC_CONTENT = {'payload': payload(size)} + +        def setup(): +            client = soledad_client() +            pool = SyncEncrypterPool(client._crypto, client._sync_db) +            pool.start() +            request.addfinalizer(pool.stop) +            docs = [ +                SoledadDocument(doc_id=uuid4().hex, rev='rev', +                                json=json.dumps(DOC_CONTENT)) +                for _ in xrange(amount) +            ] +            return pool, docs + +        @pytest.inlineCallbacks +        def put_and_wait(pool, docs): +            yield gatherResults([pool.encrypt_doc(doc) for doc in docs]) + +        yield txbenchmark_with_setup(setup, put_and_wait) +    return test + +test_encdecpool_encrypt_100_10k = create_encrypt(100, 10*1000) +test_encdecpool_encrypt_100_100k = create_encrypt(100, 100*1000) +test_encdecpool_encrypt_100_500k = create_encrypt(100, 500*1000) + + +def create_decrypt(amount, size): +    @pytest.mark.benchmark(group="test_pool_decrypt") +    @pytest.inlineCallbacks +    def test(soledad_client, txbenchmark_with_setup, request, payload): +        DOC_CONTENT = {'payload': payload(size)} +        client = soledad_client() + +        def setup(): +            pool = SyncDecrypterPool( +                client._crypto, +                client._sync_db, +                source_replica_uid=client._dbpool.replica_uid, +                insert_doc_cb=lambda x, y, z: False)  # ignored +            pool.start(amount) +            request.addfinalizer(pool.stop) +            crypto = client._crypto +            docs = [] +            for _ in xrange(amount): +                doc = SoledadDocument( +                    doc_id=uuid4().hex, rev='rev', +                    json=json.dumps(DOC_CONTENT)) +                encrypted_content = json.loads(crypto.encrypt_doc(doc)) +                docs.append((doc.doc_id, encrypted_content)) +            return pool, docs + +        def put_and_wait(pool, docs): +            deferreds = []  # fires on completion +            for idx, (doc_id, content) in enumerate(docs, 1): +                deferreds.append(pool.insert_encrypted_received_doc( +                    doc_id, 'rev', content, idx, "trans_id", idx)) +            return gatherResults(deferreds) + +        yield txbenchmark_with_setup(setup, put_and_wait) +    return test + +test_encdecpool_decrypt_100_10k = create_decrypt(100, 10*1000) +test_encdecpool_decrypt_100_100k = create_decrypt(100, 100*1000) +test_encdecpool_decrypt_100_500k = create_decrypt(100, 500*1000) diff --git a/testing/tests/perf/test_misc.py b/testing/tests/perf/test_misc.py new file mode 100644 index 00000000..ead48adf --- /dev/null +++ b/testing/tests/perf/test_misc.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.mark.benchmark(group="test_instance") +def test_initialization(soledad_client, benchmark): +    benchmark(soledad_client) diff --git a/testing/tests/perf/test_sqlcipher.py b/testing/tests/perf/test_sqlcipher.py new file mode 100644 index 00000000..e7a54228 --- /dev/null +++ b/testing/tests/perf/test_sqlcipher.py @@ -0,0 +1,38 @@ +''' +Tests SoledadClient/SQLCipher interaction +''' +import pytest + +from twisted.internet.defer import gatherResults + + +def load_up(client, amount, payload, defer=True): +    results = [client.create_doc({'content': payload}) for _ in xrange(amount)] +    if defer: +        return gatherResults(results) + + +def build_test_sqlcipher_async_create(amount, size): +    @pytest.inlineCallbacks +    @pytest.mark.benchmark(group="test_sqlcipher_async_create") +    def test(soledad_client, txbenchmark, payload): +        client = soledad_client() +        yield txbenchmark(load_up, client, amount, payload(size)) +    return test + + +def build_test_sqlcipher_create(amount, size): +    @pytest.mark.benchmark(group="test_sqlcipher_create") +    def test(soledad_client, benchmark, payload): +        client = soledad_client()._dbsyncer +        benchmark(load_up, client, amount, payload(size), defer=False) +    return test + + +test_async_create_20_500k = build_test_sqlcipher_async_create(20, 500*1000) +test_async_create_100_100k = build_test_sqlcipher_async_create(100, 100*1000) +test_async_create_1000_10k = build_test_sqlcipher_async_create(1000, 10*1000) +# synchronous +test_create_20_500k = build_test_sqlcipher_create(20, 500*1000) +test_create_100_100k = build_test_sqlcipher_create(100, 100*1000) +test_create_1000_10k = build_test_sqlcipher_create(1000, 10*1000) diff --git a/testing/tests/perf/test_sync.py b/testing/tests/perf/test_sync.py new file mode 100644 index 00000000..0b48a0b9 --- /dev/null +++ b/testing/tests/perf/test_sync.py @@ -0,0 +1,68 @@ +import pytest + +from twisted.internet.defer import gatherResults + + +def load_up(client, amount, payload): +    deferreds = [] +    # create a bunch of local documents +    for i in xrange(amount): +        d = client.create_doc({'content': payload}) +        deferreds.append(d) +    d = gatherResults(deferreds) +    d.addCallback(lambda _: None) +    return d + + +def create_upload(uploads, size): +    @pytest.inlineCallbacks +    @pytest.mark.benchmark(group="test_upload") +    def test(soledad_client, txbenchmark_with_setup, payload): +        client = soledad_client() + +        def setup(): +            return load_up(client, uploads, payload(size)) + +        yield txbenchmark_with_setup(setup, client.sync) +    return test + + +test_upload_20_500k = create_upload(20, 500*1000) +test_upload_100_100k = create_upload(100, 100*1000) +test_upload_1000_10k = create_upload(1000, 10*1000) + + +def create_download(downloads, size): +    @pytest.inlineCallbacks +    @pytest.mark.benchmark(group="test_download") +    def test(soledad_client, txbenchmark_with_setup, payload): +        client = soledad_client() + +        yield load_up(client, downloads, payload(size)) +        yield client.sync() +        # We could create them directly on couch, but sending them +        # ensures we are dealing with properly encrypted docs + +        def setup(): +            return soledad_client() + +        def sync(clean_client): +            return clean_client.sync() +        yield txbenchmark_with_setup(setup, sync) +    return test + + +test_download_20_500k = create_download(20, 500*1000) +test_download_100_100k = create_download(100, 100*1000) +test_download_1000_10k = create_download(1000, 10*1000) + + +@pytest.inlineCallbacks +@pytest.mark.benchmark(group="test_nothing_to_sync") +def test_nothing_to_sync(soledad_client, txbenchmark_with_setup): +    def setup(): +        return soledad_client() + +    def sync(clean_client): +        return clean_client.sync() +    yield txbenchmark_with_setup(setup, sync) diff --git a/testing/tests/server/test_server.py b/testing/tests/server/test_server.py index b99d1939..6bbcf002 100644 --- a/testing/tests/server/test_server.py +++ b/testing/tests/server/test_server.py @@ -20,7 +20,7 @@ Tests for server-related functionality.  import binascii  import mock  import os -import tempfile +import pytest  from hashlib import sha512  from pkg_resources import resource_filename @@ -43,8 +43,8 @@ from test_soledad.util import (  from leap.soledad.common import crypto  from leap.soledad.client import Soledad -from leap.soledad.server import load_configuration -from leap.soledad.server import CONFIG_DEFAULTS +from leap.soledad.server.config import load_configuration +from leap.soledad.server.config import CONFIG_DEFAULTS  from leap.soledad.server.auth import URLToAuthorization  from leap.soledad.server.auth import SoledadTokenAuthMiddleware @@ -287,6 +287,7 @@ class ServerAuthorizationTestCase(BaseSoledadTest):                  self._make_environ('/%s/sync-from/x' % dbname, 'POST'))) +@pytest.mark.usefixtures("method_tmpdir")  class EncryptedSyncTestCase(          CouchDBTestCase, TestCaseWithServer): @@ -349,11 +350,7 @@ class EncryptedSyncTestCase(          return self.make_app_with_state(self.request_state)      def setUp(self): -        # the order of the following initializations is crucial because of -        # dependencies. -        # XXX explain better          CouchDBTestCase.setUp(self) -        self.tempdir = tempfile.mkdtemp(prefix="leap_tests-")          TestCaseWithServer.setUp(self)      def tearDown(self): @@ -391,8 +388,7 @@ class EncryptedSyncTestCase(          # ensure remote db exists before syncing          db = CouchDatabase.open_database(              urljoin(self.couch_url, 'user-' + user), -            create=True, -            ensure_ddocs=True) +            create=True)          def _db1AssertEmptyDocList(results):              _, doclist = results diff --git a/testing/tests/sqlcipher/test_backend.py b/testing/tests/sqlcipher/test_backend.py index 11472d46..caacba0d 100644 --- a/testing/tests/sqlcipher/test_backend.py +++ b/testing/tests/sqlcipher/test_backend.py @@ -18,10 +18,9 @@  Test sqlcipher backend internals.  """  import os +import pytest  import time  import threading -import tempfile -import shutil  from pysqlcipher import dbapi2  from testscenarios import TestWithScenarios @@ -33,7 +32,6 @@ from leap.soledad.common.l2db.backends.sqlite_backend \      import SQLitePartialExpandDatabase  # soledad stuff. -from leap.soledad.common import soledad_assert  from leap.soledad.common.document import SoledadDocument  from leap.soledad.client.sqlcipher import SQLCipherDatabase  from leap.soledad.client.sqlcipher import SQLCipherOptions @@ -109,6 +107,7 @@ class SQLCipherIndexTests(  # The following tests come from `u1db.tests.test_sqlite_backend`.  # ----------------------------------------------------------------------------- +@pytest.mark.usefixtures('method_tmpdir')  class TestSQLCipherDatabase(tests.TestCase):      """      Tests from u1db.tests.test_sqlite_backend.TestSQLiteDatabase. @@ -117,8 +116,7 @@ class TestSQLCipherDatabase(tests.TestCase):      def test_atomic_initialize(self):          # This test was modified to ensure that db2.close() is called within          # the thread that created the database. -        tmpdir = self.createTempDir() -        dbname = os.path.join(tmpdir, 'atomic.db') +        dbname = os.path.join(self.tempdir, 'atomic.db')          t2 = None  # will be a thread @@ -164,6 +162,7 @@ class TestSQLCipherDatabase(tests.TestCase):          db1.close() +@pytest.mark.usefixtures('method_tmpdir')  class TestSQLCipherPartialExpandDatabase(tests.TestCase):      """      Tests from u1db.tests.test_sqlite_backend.TestSQLitePartialExpandDatabase. @@ -226,8 +225,7 @@ class TestSQLCipherPartialExpandDatabase(tests.TestCase):          pass      def test__open_database_non_existent(self): -        temp_dir = self.createTempDir(prefix='u1db-test-') -        path = temp_dir + '/non-existent.sqlite' +        path = self.tempdir + '/non-existent.sqlite'          self.assertRaises(errors.DatabaseDoesNotExist,                            sqlcipher_open,                            path, PASSWORD, create=False) @@ -243,8 +241,7 @@ class TestSQLCipherPartialExpandDatabase(tests.TestCase):          # This test was modified to ensure that an empty database file will          # raise a DatabaseIsNotEncrypted exception instead of a          # dbapi2.OperationalError exception. -        temp_dir = self.createTempDir(prefix='u1db-test-') -        path1 = temp_dir + '/invalid1.db' +        path1 = self.tempdir + '/invalid1.db'          with open(path1, 'wb') as f:              f.write("")          self.assertRaises(DatabaseIsNotEncrypted, @@ -270,8 +267,7 @@ class TestSQLCipherPartialExpandDatabase(tests.TestCase):      def test_open_database_create(self):          # SQLCipherDatabas has no open_database() method, so we just test for          # the actual database constructor effects. -        temp_dir = self.createTempDir(prefix='u1db-test-') -        path = temp_dir + '/new.sqlite' +        path = self.tempdir + '/new.sqlite'          db1 = sqlcipher_open(path, PASSWORD, create=True)          db2 = sqlcipher_open(path, PASSWORD, create=False)          self.assertIsInstance(db2, SQLCipherDatabase) @@ -395,8 +391,7 @@ class TestSQLCipherPartialExpandDatabase(tests.TestCase):                           c.fetchall())      def test__ensure_schema_rollback(self): -        temp_dir = self.createTempDir(prefix='u1db-test-') -        path = temp_dir + '/rollback.db' +        path = self.tempdir + '/rollback.db'          class SQLitePartialExpandDbTesting(SQLCipherDatabase): @@ -414,15 +409,13 @@ class TestSQLCipherPartialExpandDatabase(tests.TestCase):          db._initialize(db._db_handle.cursor())      def test_open_database_non_existent(self): -        temp_dir = self.createTempDir(prefix='u1db-test-') -        path = temp_dir + '/non-existent.sqlite' +        path = self.tempdir + '/non-existent.sqlite'          self.assertRaises(errors.DatabaseDoesNotExist,                            sqlcipher_open, path, "123",                            create=False)      def test_delete_database_existent(self): -        temp_dir = self.createTempDir(prefix='u1db-test-') -        path = temp_dir + '/new.sqlite' +        path = self.tempdir + '/new.sqlite'          db = sqlcipher_open(path, "123", create=True)          db.close()          SQLCipherDatabase.delete_database(path) @@ -431,8 +424,7 @@ class TestSQLCipherPartialExpandDatabase(tests.TestCase):                            create=False)      def test_delete_database_nonexistent(self): -        temp_dir = self.createTempDir(prefix='u1db-test-') -        path = temp_dir + '/non-existent.sqlite' +        path = self.tempdir + '/non-existent.sqlite'          self.assertRaises(errors.DatabaseDoesNotExist,                            SQLCipherDatabase.delete_database, path) @@ -630,37 +622,13 @@ class SQLCipherEncryptionTests(BaseSoledadTest):                  os.unlink(dbfile)      def setUp(self): -        # the following come from BaseLeapTest.setUpClass, because -        # twisted.trial doesn't support such class methods for setting up -        # test classes. -        self.old_path = os.environ['PATH'] -        self.old_home = os.environ['HOME'] -        self.tempdir = tempfile.mkdtemp(prefix="leap_tests-") -        self.home = self.tempdir -        bin_tdir = os.path.join( -            self.tempdir, -            'bin') -        os.environ["PATH"] = bin_tdir -        os.environ["HOME"] = self.tempdir -        # this is our own stuff +        BaseSoledadTest.setUp(self)          self.DB_FILE = os.path.join(self.tempdir, 'test.db')          self._delete_dbfiles()      def tearDown(self):          self._delete_dbfiles() -        # the following come from BaseLeapTest.tearDownClass, because -        # twisted.trial doesn't support such class methods for tearing down -        # test classes. -        os.environ["PATH"] = self.old_path -        os.environ["HOME"] = self.old_home -        # safety check! please do not wipe my home... -        # XXX needs to adapt to non-linuces -        soledad_assert( -            self.tempdir.startswith('/tmp/leap_tests-') or -            self.tempdir.startswith('/var/folder'), -            "beware! tried to remove a dir which does not " -            "live in temporal folder!") -        shutil.rmtree(self.tempdir) +        BaseSoledadTest.tearDown(self)      def test_try_to_open_encrypted_db_with_sqlite_backend(self):          """ diff --git a/testing/tests/sync/test_encdecpool.py b/testing/tests/sync/test_encdecpool.py index 82e99a47..4a32885e 100644 --- a/testing/tests/sync/test_encdecpool.py +++ b/testing/tests/sync/test_encdecpool.py @@ -29,7 +29,6 @@ from leap.soledad.client.encdecpool import SyncDecrypterPool  from leap.soledad.common.document import SoledadDocument  from test_soledad.util import BaseSoledadTest  from twisted.internet import defer -from twisted.test.proto_helpers import MemoryReactorClock  DOC_ID = "mydoc"  DOC_REV = "rev" @@ -65,17 +64,11 @@ class TestSyncEncrypterPool(BaseSoledadTest):          """          doc = SoledadDocument(              doc_id=DOC_ID, rev=DOC_REV, json=json.dumps(DOC_CONTENT)) -        self._pool.encrypt_doc(doc) -        # exhaustivelly attempt to get the encrypted document -        encrypted = None -        attempts = 0 -        while encrypted is None and attempts < 10: -            encrypted = yield self._pool.get_encrypted_doc(DOC_ID, DOC_REV) -            attempts += 1 +        yield self._pool.encrypt_doc(doc) +        encrypted = yield self._pool.get_encrypted_doc(DOC_ID, DOC_REV)          self.assertIsNotNone(encrypted) -        self.assertTrue(attempts < 10)  class TestSyncDecrypterPool(BaseSoledadTest): @@ -219,9 +212,6 @@ class TestSyncDecrypterPool(BaseSoledadTest):          This test ensures that processing of documents only occur if there is          a sequence in place.          """ -        reactor_clock = MemoryReactorClock() -        self._pool._loop.clock = reactor_clock -          crypto = self._soledad._crypto          docs = [] @@ -234,18 +224,19 @@ class TestSyncDecrypterPool(BaseSoledadTest):              docs.append((doc, encrypted_content))          # insert the encrypted document in the pool -        self._pool.start(10)  # pool is expecting to process 10 docs +        yield self._pool.start(10)  # pool is expecting to process 10 docs +        self._pool._loop.stop()  # we are processing manually          # first three arrives, forming a sequence          for i, (doc, encrypted_content) in enumerate(docs[:3]):              gen = idx = i + 1              yield self._pool.insert_encrypted_received_doc(                  doc.doc_id, doc.rev, encrypted_content, gen, "trans_id", idx) +          # last one arrives alone, so it can't be processed          doc, encrypted_content = docs[-1]          yield self._pool.insert_encrypted_received_doc(              doc.doc_id, doc.rev, encrypted_content, 10, "trans_id", 10) -        reactor_clock.advance(self._pool.DECRYPT_LOOP_PERIOD)          yield self._pool._decrypt_and_recurse()          self.assertEqual(3, self._pool._processed_docs) diff --git a/testing/tests/sync/test_sync.py b/testing/tests/sync/test_sync.py index 095884ce..5290003e 100644 --- a/testing/tests/sync/test_sync.py +++ b/testing/tests/sync/test_sync.py @@ -15,7 +15,6 @@  # You should have received a copy of the GNU General Public License  # along with this program. If not, see <http://www.gnu.org/licenses/>.  import json -import tempfile  import threading  import time @@ -60,7 +59,6 @@ class InterruptableSyncTestCase(      def setUp(self):          TestCaseWithServer.setUp(self)          CouchDBTestCase.setUp(self) -        self.tempdir = tempfile.mkdtemp(prefix="leap_tests-")      def tearDown(self):          CouchDBTestCase.tearDown(self) @@ -101,8 +99,7 @@ class InterruptableSyncTestCase(          # ensure remote db exists before syncing          db = couch.CouchDatabase.open_database(              urljoin(self.couch_url, 'user-user-uuid'), -            create=True, -            ensure_ddocs=True) +            create=True)          # create interruptor thread          t = _SyncInterruptor(sol, db) diff --git a/testing/tests/sync/test_sync_mutex.py b/testing/tests/sync/test_sync_mutex.py index 787cfee8..2626ab2a 100644 --- a/testing/tests/sync/test_sync_mutex.py +++ b/testing/tests/sync/test_sync_mutex.py @@ -24,8 +24,6 @@ be two concurrent synchronization processes at the same time.  import time  import uuid -import tempfile -import shutil  from urlparse import urljoin @@ -91,13 +89,11 @@ class TestSyncMutex(      def setUp(self):          TestCaseWithServer.setUp(self)          CouchDBTestCase.setUp(self) -        self.tempdir = tempfile.mkdtemp(prefix="leap_tests-")          self.user = ('user-%s' % uuid.uuid4().hex)      def tearDown(self):          CouchDBTestCase.tearDown(self)          TestCaseWithServer.tearDown(self) -        shutil.rmtree(self.tempdir)      def test_two_concurrent_syncs_do_not_overlap_no_docs(self):          self.startServer() @@ -105,8 +101,7 @@ class TestSyncMutex(          # ensure remote db exists before syncing          db = CouchDatabase.open_database(              urljoin(self.couch_url, 'user-' + self.user), -            create=True, -            ensure_ddocs=True) +            create=True)          sol = self._soledad_instance(              user=self.user, server_url=self.getURL()) diff --git a/testing/tox.ini b/testing/tox.ini index 3663eef3..31cb8a4f 100644 --- a/testing/tox.ini +++ b/testing/tox.ini @@ -2,22 +2,49 @@  envlist = py27  [testenv] -commands = py.test --pep8 {posargs} -changedir = tests +basepython = python2.7 +commands = py.test --cov-report=html \ +                   --cov-report=term \ +		   --cov=leap.soledad \ +		   {posargs}  deps = +    coverage      pytest -    pytest-flake8 -    pytest-pep8 +    pytest-cov +    pytest-twisted      mock      testscenarios      setuptools-trial -    pep8      pdbpp      couchdb +    requests  # install soledad local packages      -e../common      -e../client      -e../server  setenv =      HOME=/tmp +    TERM=xterm +install_command = pip install {opts} {packages} + +[testenv:perf] +deps = +    {[testenv]deps} +    pytest-benchmark +commands = py.test tests/perf {posargs} + +[testenv:code-check] +changedir = .. +deps = +    pep8 +    flake8 +commands = +    pep8 client server common +    flake8 --ignore=F812,E731 client server common + +[testenv:parallel] +deps = +    {[testenv]deps} +    pytest-xdist  install_command = pip install {opts} {packages} +commands = py.test {posargs} -n 4 | 
