Newer
Older
# Copyright 2019 PrivateStorage.io, LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
This module implements models (in the MVC sense) for the client side of
the storage plugin.
"""
from functools import (
wraps,
)
from json import (
loads,
dumps,
)
from datetime import (
datetime,
)
from zope.interface import (
Interface,
implementer,
)
from sqlite3 import (
from aniso8601 import (
parse_datetime,
)
from twisted.logger import (
Logger,
)
from twisted.python.filepath import (
FilePath,
from ._base64 import (
urlsafe_b64decode,
)
from .validators import (
is_base64_encoded,
has_length,
greater_than,
)
from .storage_common import (
pass_value_attribute,
get_configured_pass_value,
required_passes,
)
from .schema import (
get_schema_version,
get_schema_upgrades,
run_schema_upgrades,
)
class ILeaseMaintenanceObserver(Interface):
"""
An object which is interested in receiving events related to the progress
of lease maintenance activity.
"""
def observe(sizes):
"""
Observe some shares encountered during lease maintenance.
:param list[int] sizes: The sizes of the shares encountered.
"""
def finish():
"""
Observe that a run of lease maintenance has completed.
"""
class StoreOpenError(Exception):
"""
There was a problem opening the underlying data store.
"""
def __init__(self, reason):
self.reason = reason
class NotEnoughTokens(Exception):
"""
An attempt to extract tokens failed because the store does not contain as
many tokens as were requested.
"""
CONFIG_DB_NAME = u"privatestorageio-zkapauthz-v1.sqlite3"
def open_and_initialize(path, connect=None):
Open a SQLite3 database for use as a voucher store.
Create the database and populate it with a schema, if it does not already
exist.
:param FilePath path: The location of the SQLite3 database file.
:return: A SQLite3 connection object for the database at the given path.
"""
if connect is None:
connect = _connect
try:
path.parent().makedirs(ignoreExistingDirectory=True)
except OSError as e:
raise StoreOpenError(e)
dbfile = path.asBytesMode().path
try:
conn = connect(
dbfile,
isolation_level="IMMEDIATE",
)
except OperationalError as e:
raise StoreOpenError(e)
# Enforcement of foreign key constraints is off by default. It must be
# enabled on a per-connection basis. This is a helpful feature to ensure
# consistency so we want it enforced and we use it in our schema.
conn.execute("PRAGMA foreign_keys = ON")
with conn:
cursor = conn.cursor()
actual_version = get_schema_version(cursor)
schema_upgrades = list(get_schema_upgrades(actual_version))
run_schema_upgrades(schema_upgrades, cursor)
# Create some tables that only exist (along with their contents) for
# this connection. These are outside of the schema because they are not
# persistent. We can change them any time we like without worrying about
# upgrade logic because we re-create them on every connection.
conn.execute(
"""
-- Track tokens in use by the process holding this connection.
CREATE TEMPORARY TABLE [in-use] (
[unblinded-token] text, -- The base64 encoded unblinded token.
PRIMARY KEY([unblinded-token])
-- A foreign key on unblinded-token to [unblinded-tokens]([token])
-- would be alright - however SQLite3 foreign key constraints
-- can't cross databases (and temporary tables are considered to
-- be in a different database than normal tables).
)
""",
-- Track tokens that we want to remove from the database. Mainly just
-- works around the awkward DB-API interface for dealing with deleting
-- many rows.
CREATE TEMPORARY TABLE [to-discard] (
[unblinded-token] text
)
""",
)
conn.execute(
"""
-- Track tokens that we want to remove from the [in-use] set. Similar
-- to [to-discard].
CREATE TEMPORARY TABLE [to-reset] (
[unblinded-token] text
)
""",
)
return conn
def with_cursor(f):
"""
Decorate a function so it is automatically passed a cursor with an active
transaction as the first positional argument. If the function returns
normally then the transaction will be committed. Otherwise, the
transaction will be rolled back.
"""
@wraps(f)
def with_cursor(self, *a, **kw):
with self._connection:
cursor = self._connection.cursor()
cursor.execute("BEGIN IMMEDIATE TRANSACTION")
return f(self, cursor, *a, **kw)
return with_cursor
def memory_connect(path, *a, **kw):
"""
Always connect to an in-memory SQLite3 database.
"""
return _connect(":memory:", *a, **kw)
# The largest integer SQLite3 can represent in an integer column. Larger than
# this an the representation loses precision as a floating point.
_SQLITE3_INTEGER_MAX = 2 ** 63 - 1
@attr.s(frozen=True)
class VoucherStore(object):
This class implements persistence for vouchers.
:ivar allmydata.node._Config node_config: The Tahoe-LAFS node configuration object for
the node that owns the persisted vouchers.
:ivar now: A no-argument callable that returns the time of the call as a
``datetime`` instance.
_log = Logger()
pass_value = pass_value_attribute()
database_path = attr.ib(validator=attr.validators.instance_of(FilePath))
_connection = attr.ib()
@classmethod
def from_node_config(cls, node_config, now, connect=None):
"""
Create or open the ``VoucherStore`` for a given node.
:param allmydata.node._Config node_config: The Tahoe-LAFS
configuration object for the node for which we want to open a
store.
:param now: See ``VoucherStore.now``.
:param connect: An alternate database connection function. This is
primarily for the purposes of the test suite.
"""
db_path = FilePath(node_config.get_private_path(CONFIG_DB_NAME))
conn = open_and_initialize(
db_path,
get_configured_pass_value(node_config),
conn,
)
@with_cursor
"""
:param unicode voucher: The text value of a voucher to retrieve.
:return Voucher: The voucher object that matches the given value.
"""
cursor.execute(
"""
SELECT
[number], [created], [expected-tokens], [state], [finished], [token-count], [public-key], [counter]
[vouchers]
WHERE
[number] = ?
""",
)
refs = cursor.fetchall()
if len(refs) == 0:
return Voucher.from_row(refs[0])
def add(self, cursor, voucher, expected_tokens, counter, get_tokens):
Add random tokens associated with a voucher (possibly new, possibly
existing) to the database. If the (voucher, counter) pair is already
present, do nothing.
:param unicode voucher: The text value of a voucher with which to
associate the tokens.
:param int expected_tokens: The total number of tokens for which this
voucher is expected to be redeemed. This is only respected the
first time a voucher is added. Subsequent calls with the same
voucher but a different count ignore the value because it is
already known (and the database knows better than the caller what
it should be).
This probably means ``add`` is a broken interface for doing these
two things. Maybe it should be fixed someday.
:param int counter: The redemption counter for the given voucher with
which to associate the tokens.
:param list[RandomToken]: The tokens to add alongside the voucher.
"""
now = self.now()
if not isinstance(now, datetime):
raise TypeError("{} returned {}, expected datetime".format(self.now, now))
cursor.execute(
"""
WHERE [voucher] = ? AND [counter] = ?
(voucher, counter),
rows = cursor.fetchall()
if len(rows) > 0:
self._log.info(
"Loaded {count} random tokens for a voucher ({voucher}[{counter}]).",
count=len(rows),
voucher=voucher,
counter=counter,
)
tokens = list(
RandomToken(token_value)
for (token_value,)
in rows
)
else:
tokens = get_tokens()
self._log.info(
"Persisting {count} random tokens for a voucher ({voucher}[{counter}]).",
count=len(tokens),
voucher=voucher,
counter=counter,
)
cursor.execute(
"""
INSERT OR IGNORE INTO [vouchers] ([number], [expected-tokens], [created]) VALUES (?, ?, ?)
(voucher, expected_tokens, self.now())
cursor.executemany(
"""
INSERT INTO [tokens] ([voucher], [counter], [text]) VALUES (?, ?, ?)
(voucher, counter, token.token_value)
for token
in tokens
),
)
@with_cursor
def list(self, cursor):
"""
Get all known vouchers.
:return list[Voucher]: All vouchers known to the store.
"""
cursor.execute(
"""
[number], [created], [expected-tokens], [state], [finished], [token-count], [public-key], [counter]
""",
)
refs = cursor.fetchall()
Voucher.from_row(row)
for row
def _insert_unblinded_tokens(self, cursor, unblinded_tokens):
Helper function to really insert unblinded tokens into the database.
"""
cursor.executemany(
"""
INSERT INTO [unblinded-tokens] VALUES (?)
""",
list(
(token,)
for token
in unblinded_tokens
),
)
@with_cursor
def insert_unblinded_tokens(self, cursor, unblinded_tokens):
"""
Store some unblinded tokens, for example as part of a backup-restore
process.
:param list[unicode] unblinded_tokens: The unblinded tokens to store.
"""
self._insert_unblinded_tokens(cursor, unblinded_tokens)
def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens, completed):
Store some unblinded tokens received from redemption of a voucher.
:param unicode voucher: The voucher associated with the unblinded
tokens. This voucher will be marked as redeemed to indicate it
has fulfilled its purpose and has no further use for us.
:param unicode public_key: The encoded public key for the private key
which was used to sign these tokens.
:param list[UnblindedToken] unblinded_tokens: The unblinded tokens to
store.
:param bool completed: ``True`` if redemption of this voucher is now
complete, ``False`` otherwise.
Loading
Loading full blame…