mirror of
https://github.com/tahoe-lafs/tahoe-lafs.git
synced 2025-01-31 00:24:13 +00:00
Merge remote-tracking branch 'origin/master' into 3899.failed-server
This commit is contained in:
commit
537ab5c8ca
@ -18,15 +18,11 @@ RUN apt-get --quiet update && \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
libyaml-dev \
|
||||
virtualenv
|
||||
virtualenv \
|
||||
tor
|
||||
|
||||
# Get the project source. This is better than it seems. CircleCI will
|
||||
# *update* this checkout on each job run, saving us more time per-job.
|
||||
COPY . ${BUILD_SRC_ROOT}
|
||||
|
||||
RUN "${BUILD_SRC_ROOT}"/.circleci/prepare-image.sh "${WHEELHOUSE_PATH}" "${VIRTUALENV_PATH}" "${BUILD_SRC_ROOT}" "python${PYTHON_VERSION}"
|
||||
|
||||
# Only the integration tests currently need this but it doesn't hurt to always
|
||||
# have it present and it's simpler than building a whole extra image just for
|
||||
# the integration tests.
|
||||
RUN ${BUILD_SRC_ROOT}/integration/install-tor.sh
|
||||
|
@ -18,8 +18,7 @@ workflows:
|
||||
- "debian-10":
|
||||
{}
|
||||
- "debian-11":
|
||||
requires:
|
||||
- "debian-10"
|
||||
{}
|
||||
|
||||
- "ubuntu-20-04":
|
||||
{}
|
||||
@ -58,7 +57,7 @@ workflows:
|
||||
requires:
|
||||
# If the unit test suite doesn't pass, don't bother running the
|
||||
# integration tests.
|
||||
- "debian-10"
|
||||
- "debian-11"
|
||||
|
||||
- "typechecks":
|
||||
{}
|
||||
@ -297,6 +296,10 @@ jobs:
|
||||
|
||||
integration:
|
||||
<<: *DEBIAN
|
||||
docker:
|
||||
- <<: *DOCKERHUB_AUTH
|
||||
image: "tahoelafsci/debian:11-py3.9"
|
||||
user: "nobody"
|
||||
|
||||
environment:
|
||||
<<: *UTF_8_ENVIRONMENT
|
||||
|
@ -52,7 +52,7 @@ fi
|
||||
# This is primarily aimed at catching hangs on the PyPy job which runs for
|
||||
# about 21 minutes and then gets killed by CircleCI in a way that fails the
|
||||
# job and bypasses our "allowed failure" logic.
|
||||
TIMEOUT="timeout --kill-after 1m 15m"
|
||||
TIMEOUT="timeout --kill-after 1m 25m"
|
||||
|
||||
# Run the test suite as a non-root user. This is the expected usage some
|
||||
# small areas of the test suite assume non-root privileges (such as unreadable
|
||||
|
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@ -163,7 +163,9 @@ jobs:
|
||||
matrix:
|
||||
os:
|
||||
- windows-latest
|
||||
- ubuntu-latest
|
||||
# 22.04 has some issue with Tor at the moment:
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3943
|
||||
- ubuntu-20.04
|
||||
python-version:
|
||||
- 3.7
|
||||
- 3.9
|
||||
@ -175,7 +177,7 @@ jobs:
|
||||
steps:
|
||||
|
||||
- name: Install Tor [Ubuntu]
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: ${{ contains(matrix.os, 'ubuntu') }}
|
||||
run: sudo apt install tor
|
||||
|
||||
# TODO: See https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3744.
|
||||
|
59
Makefile
59
Makefile
@ -224,3 +224,62 @@ src/allmydata/_version.py:
|
||||
|
||||
.tox/create-venvs.log: tox.ini setup.py
|
||||
tox --notest -p all | tee -a "$(@)"
|
||||
|
||||
|
||||
# to make a new release:
|
||||
# - create a ticket for the release in Trac
|
||||
# - ensure local copy is up-to-date
|
||||
# - create a branch like "XXXX.release" from up-to-date master
|
||||
# - in the branch, run "make release"
|
||||
# - run "make release-test"
|
||||
# - perform any other sanity-checks on the release
|
||||
# - run "make release-upload"
|
||||
# Note that several commands below hard-code "meejah"; if you are
|
||||
# someone else please adjust them.
|
||||
release:
|
||||
@echo "Is checkout clean?"
|
||||
git diff-files --quiet
|
||||
git diff-index --quiet --cached HEAD --
|
||||
|
||||
@echo "Clean docs build area"
|
||||
rm -rf docs/_build/
|
||||
|
||||
@echo "Install required build software"
|
||||
python3 -m pip install --editable .[build]
|
||||
|
||||
@echo "Test README"
|
||||
python3 setup.py check -r -s
|
||||
|
||||
@echo "Update NEWS"
|
||||
python3 -m towncrier build --yes --version `python3 misc/build_helpers/update-version.py --no-tag`
|
||||
git add -u
|
||||
git commit -m "update NEWS for release"
|
||||
|
||||
# note that this always bumps the "middle" number, e.g. from 1.17.1 -> 1.18.0
|
||||
# and produces a tag into the Git repository
|
||||
@echo "Bump version and create tag"
|
||||
python3 misc/build_helpers/update-version.py
|
||||
|
||||
@echo "Build and sign wheel"
|
||||
python3 setup.py bdist_wheel
|
||||
gpg --pinentry=loopback -u meejah@meejah.ca --armor --detach-sign dist/tahoe_lafs-`git describe | cut -b 12-`-py3-none-any.whl
|
||||
ls dist/*`git describe | cut -b 12-`*
|
||||
|
||||
@echo "Build and sign source-dist"
|
||||
python3 setup.py sdist
|
||||
gpg --pinentry=loopback -u meejah@meejah.ca --armor --detach-sign dist/tahoe-lafs-`git describe | cut -b 12-`.tar.gz
|
||||
ls dist/*`git describe | cut -b 12-`*
|
||||
|
||||
# basically just a bare-minimum smoke-test that it installs and runs
|
||||
release-test:
|
||||
gpg --verify dist/tahoe-lafs-`git describe | cut -b 12-`.tar.gz.asc
|
||||
gpg --verify dist/tahoe_lafs-`git describe | cut -b 12-`-py3-none-any.whl.asc
|
||||
virtualenv testmf_venv
|
||||
testmf_venv/bin/pip install dist/tahoe_lafs-`git describe | cut -b 12-`-py3-none-any.whl
|
||||
testmf_venv/bin/tahoe --version
|
||||
rm -rf testmf_venv
|
||||
|
||||
release-upload:
|
||||
scp dist/*`git describe | cut -b 12-`* meejah@tahoe-lafs.org:/home/source/downloads
|
||||
git push origin_push tahoe-lafs-`git describe | cut -b 12-`
|
||||
twine upload dist/tahoe_lafs-`git describe | cut -b 12-`-py3-none-any.whl dist/tahoe_lafs-`git describe | cut -b 12-`-py3-none-any.whl.asc dist/tahoe-lafs-`git describe | cut -b 12-`.tar.gz dist/tahoe-lafs-`git describe | cut -b 12-`.tar.gz.asc
|
||||
|
41
NEWS.rst
41
NEWS.rst
@ -5,6 +5,47 @@ User-Visible Changes in Tahoe-LAFS
|
||||
==================================
|
||||
|
||||
.. towncrier start line
|
||||
Release 1.18.0 (2022-10-02)
|
||||
'''''''''''''''''''''''''''
|
||||
|
||||
Backwards Incompatible Changes
|
||||
------------------------------
|
||||
|
||||
- Python 3.6 is no longer supported, as it has reached end-of-life and is no longer receiving security updates. (`#3865 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3865>`_)
|
||||
- Python 3.7 or later is now required; Python 2 is no longer supported. (`#3873 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3873>`_)
|
||||
- Share corruption reports stored on disk are now always encoded in UTF-8. (`#3879 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3879>`_)
|
||||
- Record both the PID and the process creation-time:
|
||||
|
||||
a new kind of pidfile in `running.process` records both
|
||||
the PID and the creation-time of the process. This facilitates
|
||||
automatic discovery of a "stale" pidfile that points to a
|
||||
currently-running process. If the recorded creation-time matches
|
||||
the creation-time of the running process, then it is a still-running
|
||||
`tahoe run` process. Otherwise, the file is stale.
|
||||
|
||||
The `twistd.pid` file is no longer present. (`#3926 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3926>`_)
|
||||
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
- The implementation of SDMF and MDMF (mutables) now requires RSA keys to be exactly 2048 bits, aligning them with the specification.
|
||||
|
||||
Some code existed to allow tests to shorten this and it's
|
||||
conceptually possible a modified client produced mutables
|
||||
with different key-sizes. However, the spec says that they
|
||||
must be 2048 bits. If you happen to have a capability with
|
||||
a key-size different from 2048 you may use 1.17.1 or earlier
|
||||
to read the content. (`#3828 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3828>`_)
|
||||
- "make" based release automation (`#3846 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3846>`_)
|
||||
|
||||
|
||||
Misc/Other
|
||||
----------
|
||||
|
||||
- `#3327 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3327>`_, `#3526 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3526>`_, `#3697 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3697>`_, `#3709 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3709>`_, `#3786 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3786>`_, `#3788 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3788>`_, `#3802 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3802>`_, `#3816 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3816>`_, `#3855 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3855>`_, `#3858 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3858>`_, `#3859 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3859>`_, `#3860 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3860>`_, `#3867 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3867>`_, `#3868 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3868>`_, `#3871 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3871>`_, `#3872 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3872>`_, `#3875 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3875>`_, `#3876 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3876>`_, `#3877 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3877>`_, `#3881 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3881>`_, `#3882 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3882>`_, `#3883 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3883>`_, `#3889 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3889>`_, `#3890 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3890>`_, `#3891 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3891>`_, `#3893 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3893>`_, `#3895 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3895>`_, `#3896 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3896>`_, `#3898 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3898>`_, `#3900 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3900>`_, `#3909 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3909>`_, `#3913 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3913>`_, `#3915 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3915>`_, `#3916 <https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3916>`_
|
||||
|
||||
|
||||
Release 1.17.1 (2022-01-07)
|
||||
'''''''''''''''''''''''''''
|
||||
|
||||
|
47
docs/check_running.py
Normal file
47
docs/check_running.py
Normal file
@ -0,0 +1,47 @@
|
||||
|
||||
import psutil
|
||||
import filelock
|
||||
|
||||
|
||||
def can_spawn_tahoe(pidfile):
|
||||
"""
|
||||
Determine if we can spawn a Tahoe-LAFS for the given pidfile. That
|
||||
pidfile may be deleted if it is stale.
|
||||
|
||||
:param pathlib.Path pidfile: the file to check, that is the Path
|
||||
to "running.process" in a Tahoe-LAFS configuration directory
|
||||
|
||||
:returns bool: True if we can spawn `tahoe run` here
|
||||
"""
|
||||
lockpath = pidfile.parent / (pidfile.name + ".lock")
|
||||
with filelock.FileLock(lockpath):
|
||||
try:
|
||||
with pidfile.open("r") as f:
|
||||
pid, create_time = f.read().strip().split(" ", 1)
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
|
||||
# somewhat interesting: we have a pidfile
|
||||
pid = int(pid)
|
||||
create_time = float(create_time)
|
||||
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
# most interesting case: there _is_ a process running at the
|
||||
# recorded PID -- but did it just happen to get that PID, or
|
||||
# is it the very same one that wrote the file?
|
||||
if create_time == proc.create_time():
|
||||
# _not_ stale! another intance is still running against
|
||||
# this configuration
|
||||
return False
|
||||
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
|
||||
# the file is stale
|
||||
pidfile.unlink()
|
||||
return True
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
print("can spawn?", can_spawn_tahoe(Path("running.process")))
|
@ -63,7 +63,7 @@ release = u'1.x'
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
language = "en"
|
||||
|
||||
# There are two options for replacing |today|: either, you set today to some
|
||||
# non-false value, then it is used:
|
||||
|
@ -30,12 +30,12 @@ Glossary
|
||||
introducer
|
||||
a Tahoe-LAFS process at a known location configured to re-publish announcements about the location of storage servers
|
||||
|
||||
fURL
|
||||
:ref:`fURLs <fURLs>`
|
||||
a self-authenticating URL-like string which can be used to locate a remote object using the Foolscap protocol
|
||||
(the storage service is an example of such an object)
|
||||
|
||||
NURL
|
||||
a self-authenticating URL-like string almost exactly like a NURL but without being tied to Foolscap
|
||||
:ref:`NURLs <NURLs>`
|
||||
a self-authenticating URL-like string almost exactly like a fURL but without being tied to Foolscap
|
||||
|
||||
swissnum
|
||||
a short random string which is part of a fURL/NURL and which acts as a shared secret to authorize clients to use a storage service
|
||||
@ -350,8 +350,10 @@ Because of the simple types used throughout
|
||||
and the equivalence described in `RFC 7049`_
|
||||
these examples should be representative regardless of which of these two encodings is chosen.
|
||||
|
||||
The one exception is sets.
|
||||
For CBOR messages, any sequence that is semantically a set (i.e. no repeated values allowed, order doesn't matter, and elements are hashable in Python) should be sent as a set.
|
||||
Tag 6.258 is used to indicate sets in CBOR; see `the CBOR registry <https://www.iana.org/assignments/cbor-tags/cbor-tags.xhtml>`_ for more details.
|
||||
Sets will be represented as JSON lists in examples because JSON doesn't support sets.
|
||||
|
||||
HTTP Design
|
||||
~~~~~~~~~~~
|
||||
@ -393,8 +395,8 @@ Encoding
|
||||
General
|
||||
~~~~~~~
|
||||
|
||||
``GET /v1/version``
|
||||
!!!!!!!!!!!!!!!!!!!
|
||||
``GET /storage/v1/version``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
Retrieve information about the version of the storage server.
|
||||
Information is returned as an encoded mapping.
|
||||
@ -407,14 +409,13 @@ For example::
|
||||
"tolerates-immutable-read-overrun": true,
|
||||
"delete-mutable-shares-with-zero-length-writev": true,
|
||||
"fills-holes-with-zero-bytes": true,
|
||||
"prevents-read-past-end-of-share-data": true,
|
||||
"gbs-anonymous-storage-url": "pb://...#v=1"
|
||||
"prevents-read-past-end-of-share-data": true
|
||||
},
|
||||
"application-version": "1.13.0"
|
||||
}
|
||||
|
||||
``PUT /v1/lease/:storage_index``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
``PUT /storage/v1/lease/:storage_index``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
Either renew or create a new lease on the bucket addressed by ``storage_index``.
|
||||
|
||||
@ -466,8 +467,8 @@ Immutable
|
||||
Writing
|
||||
~~~~~~~
|
||||
|
||||
``POST /v1/immutable/:storage_index``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
``POST /storage/v1/immutable/:storage_index``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
Initialize an immutable storage index with some buckets.
|
||||
The buckets may have share data written to them once.
|
||||
@ -502,7 +503,7 @@ Handling repeat calls:
|
||||
Discussion
|
||||
``````````
|
||||
|
||||
We considered making this ``POST /v1/immutable`` instead.
|
||||
We considered making this ``POST /storage/v1/immutable`` instead.
|
||||
The motivation was to keep *storage index* out of the request URL.
|
||||
Request URLs have an elevated chance of being logged by something.
|
||||
We were concerned that having the *storage index* logged may increase some risks.
|
||||
@ -537,8 +538,8 @@ Rejected designs for upload secrets:
|
||||
it must contain randomness.
|
||||
Randomness means there is no need to have a secret per share, since adding share-specific content to randomness doesn't actually make the secret any better.
|
||||
|
||||
``PATCH /v1/immutable/:storage_index/:share_number``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
``PATCH /storage/v1/immutable/:storage_index/:share_number``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
Write data for the indicated share.
|
||||
The share number must belong to the storage index.
|
||||
@ -578,24 +579,6 @@ Responses:
|
||||
the response is ``CONFLICT``.
|
||||
At this point the only thing to do is abort the upload and start from scratch (see below).
|
||||
|
||||
``PUT /v1/immutable/:storage_index/:share_number/abort``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
This cancels an *in-progress* upload.
|
||||
|
||||
The request must include a ``X-Tahoe-Authorization`` header that includes the upload secret::
|
||||
|
||||
X-Tahoe-Authorization: upload-secret <base64-upload-secret>
|
||||
|
||||
The response code:
|
||||
|
||||
* When the upload is still in progress and therefore the abort has succeeded,
|
||||
the response is ``OK``.
|
||||
Future uploads can start from scratch with no pre-existing upload state stored on the server.
|
||||
* If the uploaded has already finished, the response is 405 (Method Not Allowed)
|
||||
and no change is made.
|
||||
|
||||
|
||||
Discussion
|
||||
``````````
|
||||
|
||||
@ -614,8 +597,27 @@ From RFC 7231::
|
||||
PATCH method defined in [RFC5789]).
|
||||
|
||||
|
||||
``POST /v1/immutable/:storage_index/:share_number/corrupt``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
``PUT /storage/v1/immutable/:storage_index/:share_number/abort``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
This cancels an *in-progress* upload.
|
||||
|
||||
The request must include a ``X-Tahoe-Authorization`` header that includes the upload secret::
|
||||
|
||||
X-Tahoe-Authorization: upload-secret <base64-upload-secret>
|
||||
|
||||
The response code:
|
||||
|
||||
* When the upload is still in progress and therefore the abort has succeeded,
|
||||
the response is ``OK``.
|
||||
Future uploads can start from scratch with no pre-existing upload state stored on the server.
|
||||
* If the uploaded has already finished, the response is 405 (Method Not Allowed)
|
||||
and no change is made.
|
||||
|
||||
|
||||
``POST /storage/v1/immutable/:storage_index/:share_number/corrupt``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
Advise the server the data read from the indicated share was corrupt. The
|
||||
request body includes an human-meaningful text string with details about the
|
||||
@ -623,7 +625,7 @@ corruption. It also includes potentially important details about the share.
|
||||
|
||||
For example::
|
||||
|
||||
{"reason": u"expected hash abcd, got hash efgh"}
|
||||
{"reason": "expected hash abcd, got hash efgh"}
|
||||
|
||||
.. share-type, storage-index, and share-number are inferred from the URL
|
||||
|
||||
@ -633,8 +635,8 @@ couldn't be found.
|
||||
Reading
|
||||
~~~~~~~
|
||||
|
||||
``GET /v1/immutable/:storage_index/shares``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
``GET /storage/v1/immutable/:storage_index/shares``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
Retrieve a list (semantically, a set) indicating all shares available for the
|
||||
indicated storage index. For example::
|
||||
@ -643,8 +645,8 @@ indicated storage index. For example::
|
||||
|
||||
An unknown storage index results in an empty list.
|
||||
|
||||
``GET /v1/immutable/:storage_index/:share_number``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
``GET /storage/v1/immutable/:storage_index/:share_number``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
Read a contiguous sequence of bytes from one share in one bucket.
|
||||
The response body is the raw share data (i.e., ``application/octet-stream``).
|
||||
@ -652,6 +654,11 @@ The ``Range`` header may be used to request exactly one ``bytes`` range, in whic
|
||||
Interpretation and response behavior is as specified in RFC 7233 § 4.1.
|
||||
Multiple ranges in a single request are *not* supported; open-ended ranges are also not supported.
|
||||
|
||||
If the response reads beyond the end of the data, the response may be shorter than the requested range.
|
||||
The resulting ``Content-Range`` header will be consistent with the returned data.
|
||||
|
||||
If the response to a query is an empty range, the ``NO CONTENT`` (204) response code will be used.
|
||||
|
||||
Discussion
|
||||
``````````
|
||||
|
||||
@ -679,8 +686,8 @@ Mutable
|
||||
Writing
|
||||
~~~~~~~
|
||||
|
||||
``POST /v1/mutable/:storage_index/read-test-write``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
``POST /storage/v1/mutable/:storage_index/read-test-write``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
General purpose read-test-and-write operation for mutable storage indexes.
|
||||
A mutable storage index is also called a "slot"
|
||||
@ -735,26 +742,31 @@ As a result, if there is no data at all, an empty bytestring is returned no matt
|
||||
Reading
|
||||
~~~~~~~
|
||||
|
||||
``GET /v1/mutable/:storage_index/shares``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
``GET /storage/v1/mutable/:storage_index/shares``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
Retrieve a list indicating all shares available for the indicated storage index.
|
||||
For example::
|
||||
Retrieve a set indicating all shares available for the indicated storage index.
|
||||
For example (this is shown as list, since it will be list for JSON, but will be set for CBOR)::
|
||||
|
||||
[1, 5]
|
||||
|
||||
``GET /v1/mutable/:storage_index/:share_number``
|
||||
``GET /storage/v1/mutable/:storage_index/:share_number``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
Read data from the indicated mutable shares, just like ``GET /v1/immutable/:storage_index``
|
||||
Read data from the indicated mutable shares, just like ``GET /storage/v1/immutable/:storage_index``
|
||||
|
||||
The ``Range`` header may be used to request exactly one ``bytes`` range, in which case the response code will be 206 (partial content).
|
||||
Interpretation and response behavior is as specified in RFC 7233 § 4.1.
|
||||
Multiple ranges in a single request are *not* supported; open-ended ranges are also not supported.
|
||||
|
||||
If the response reads beyond the end of the data, the response may be shorter than the requested range.
|
||||
The resulting ``Content-Range`` header will be consistent with the returned data.
|
||||
|
||||
``POST /v1/mutable/:storage_index/:share_number/corrupt``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
If the response to a query is an empty range, the ``NO CONTENT`` (204) response code will be used.
|
||||
|
||||
|
||||
``POST /storage/v1/mutable/:storage_index/:share_number/corrupt``
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
Advise the server the data read from the indicated share was corrupt.
|
||||
Just like the immutable version.
|
||||
@ -767,7 +779,7 @@ Immutable Data
|
||||
|
||||
1. Create a bucket for storage index ``AAAAAAAAAAAAAAAA`` to hold two immutable shares, discovering that share ``1`` was already uploaded::
|
||||
|
||||
POST /v1/immutable/AAAAAAAAAAAAAAAA
|
||||
POST /storage/v1/immutable/AAAAAAAAAAAAAAAA
|
||||
Authorization: Tahoe-LAFS nurl-swissnum
|
||||
X-Tahoe-Authorization: lease-renew-secret efgh
|
||||
X-Tahoe-Authorization: lease-cancel-secret jjkl
|
||||
@ -780,23 +792,25 @@ Immutable Data
|
||||
|
||||
#. Upload the content for immutable share ``7``::
|
||||
|
||||
PATCH /v1/immutable/AAAAAAAAAAAAAAAA/7
|
||||
PATCH /storage/v1/immutable/AAAAAAAAAAAAAAAA/7
|
||||
Authorization: Tahoe-LAFS nurl-swissnum
|
||||
Content-Range: bytes 0-15/48
|
||||
X-Tahoe-Authorization: upload-secret xyzf
|
||||
<first 16 bytes of share data>
|
||||
|
||||
200 OK
|
||||
{ "required": [ {"begin": 16, "end": 48 } ] }
|
||||
|
||||
PATCH /v1/immutable/AAAAAAAAAAAAAAAA/7
|
||||
PATCH /storage/v1/immutable/AAAAAAAAAAAAAAAA/7
|
||||
Authorization: Tahoe-LAFS nurl-swissnum
|
||||
Content-Range: bytes 16-31/48
|
||||
X-Tahoe-Authorization: upload-secret xyzf
|
||||
<second 16 bytes of share data>
|
||||
|
||||
200 OK
|
||||
{ "required": [ {"begin": 32, "end": 48 } ] }
|
||||
|
||||
PATCH /v1/immutable/AAAAAAAAAAAAAAAA/7
|
||||
PATCH /storage/v1/immutable/AAAAAAAAAAAAAAAA/7
|
||||
Authorization: Tahoe-LAFS nurl-swissnum
|
||||
Content-Range: bytes 32-47/48
|
||||
X-Tahoe-Authorization: upload-secret xyzf
|
||||
@ -806,16 +820,17 @@ Immutable Data
|
||||
|
||||
#. Download the content of the previously uploaded immutable share ``7``::
|
||||
|
||||
GET /v1/immutable/AAAAAAAAAAAAAAAA?share=7
|
||||
GET /storage/v1/immutable/AAAAAAAAAAAAAAAA?share=7
|
||||
Authorization: Tahoe-LAFS nurl-swissnum
|
||||
Range: bytes=0-47
|
||||
|
||||
200 OK
|
||||
Content-Range: bytes 0-47/48
|
||||
<complete 48 bytes of previously uploaded data>
|
||||
|
||||
#. Renew the lease on all immutable shares in bucket ``AAAAAAAAAAAAAAAA``::
|
||||
|
||||
PUT /v1/lease/AAAAAAAAAAAAAAAA
|
||||
PUT /storage/v1/lease/AAAAAAAAAAAAAAAA
|
||||
Authorization: Tahoe-LAFS nurl-swissnum
|
||||
X-Tahoe-Authorization: lease-cancel-secret jjkl
|
||||
X-Tahoe-Authorization: lease-renew-secret efgh
|
||||
@ -830,7 +845,7 @@ The special test vector of size 1 but empty bytes will only pass
|
||||
if there is no existing share,
|
||||
otherwise it will read a byte which won't match `b""`::
|
||||
|
||||
POST /v1/mutable/BBBBBBBBBBBBBBBB/read-test-write
|
||||
POST /storage/v1/mutable/BBBBBBBBBBBBBBBB/read-test-write
|
||||
Authorization: Tahoe-LAFS nurl-swissnum
|
||||
X-Tahoe-Authorization: write-enabler abcd
|
||||
X-Tahoe-Authorization: lease-cancel-secret efgh
|
||||
@ -862,7 +877,7 @@ otherwise it will read a byte which won't match `b""`::
|
||||
|
||||
#. Safely rewrite the contents of a known version of mutable share number ``3`` (or fail)::
|
||||
|
||||
POST /v1/mutable/BBBBBBBBBBBBBBBB/read-test-write
|
||||
POST /storage/v1/mutable/BBBBBBBBBBBBBBBB/read-test-write
|
||||
Authorization: Tahoe-LAFS nurl-swissnum
|
||||
X-Tahoe-Authorization: write-enabler abcd
|
||||
X-Tahoe-Authorization: lease-cancel-secret efgh
|
||||
@ -894,14 +909,17 @@ otherwise it will read a byte which won't match `b""`::
|
||||
|
||||
#. Download the contents of share number ``3``::
|
||||
|
||||
GET /v1/mutable/BBBBBBBBBBBBBBBB?share=3&offset=0&size=10
|
||||
GET /storage/v1/mutable/BBBBBBBBBBBBBBBB?share=3
|
||||
Authorization: Tahoe-LAFS nurl-swissnum
|
||||
Range: bytes=0-16
|
||||
|
||||
200 OK
|
||||
Content-Range: bytes 0-15/16
|
||||
<complete 16 bytes of previously uploaded data>
|
||||
|
||||
#. Renew the lease on previously uploaded mutable share in slot ``BBBBBBBBBBBBBBBB``::
|
||||
|
||||
PUT /v1/lease/BBBBBBBBBBBBBBBB
|
||||
PUT /storage/v1/lease/BBBBBBBBBBBBBBBB
|
||||
Authorization: Tahoe-LAFS nurl-swissnum
|
||||
X-Tahoe-Authorization: lease-cancel-secret efgh
|
||||
X-Tahoe-Authorization: lease-renew-secret ijkl
|
||||
|
@ -124,6 +124,35 @@ Tahoe-LAFS.
|
||||
.. _magic wormhole: https://magic-wormhole.io/
|
||||
|
||||
|
||||
Multiple Instances
|
||||
------------------
|
||||
|
||||
Running multiple instances against the same configuration directory isn't supported.
|
||||
This will lead to undefined behavior and could corrupt the configuration or state.
|
||||
|
||||
We attempt to avoid this situation with a "pidfile"-style file in the config directory called ``running.process``.
|
||||
There may be a parallel file called ``running.process.lock`` in existence.
|
||||
|
||||
The ``.lock`` file exists to make sure only one process modifies ``running.process`` at once.
|
||||
The lock file is managed by the `lockfile <https://pypi.org/project/lockfile/>`_ library.
|
||||
If you wish to make use of ``running.process`` for any reason you should also lock it and follow the semantics of lockfile.
|
||||
|
||||
If ``running.process`` exists then it contains the PID and the creation-time of the process.
|
||||
When no such file exists, there is no other process running on this configuration.
|
||||
If there is a ``running.process`` file, it may be a leftover file or it may indicate that another process is running against this config.
|
||||
To tell the difference, determine if the PID in the file exists currently.
|
||||
If it does, check the creation-time of the process versus the one in the file.
|
||||
If these match, there is another process currently running and using this config.
|
||||
Otherwise, the file is stale -- it should be removed before starting Tahoe-LAFS.
|
||||
|
||||
Some example Python code to check the above situations:
|
||||
|
||||
.. literalinclude:: check_running.py
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
A note about small grids
|
||||
------------------------
|
||||
|
||||
|
@ -7,6 +7,8 @@ These are not to be confused with the URI-like capabilities Tahoe-LAFS uses to r
|
||||
An attempt is also made to outline the rationale for certain choices about these URLs.
|
||||
The intended audience for this document is Tahoe-LAFS maintainers and other developers interested in interoperating with Tahoe-LAFS or these URLs.
|
||||
|
||||
.. _furls:
|
||||
|
||||
Background
|
||||
----------
|
||||
|
||||
@ -31,6 +33,8 @@ The client's use of the swissnum is what allows the server to authorize the clie
|
||||
|
||||
.. _`swiss number`: http://wiki.erights.org/wiki/Swiss_number
|
||||
|
||||
.. _NURLs:
|
||||
|
||||
NURLs
|
||||
-----
|
||||
|
||||
@ -47,27 +51,27 @@ This can be considered to expand to "**N**\ ew URLs" or "Authe\ **N**\ ticating
|
||||
The anticipated use for a **NURL** will still be to establish a TLS connection to a peer.
|
||||
The protocol run over that TLS connection could be Foolscap though it is more likely to be an HTTP-based protocol (such as GBS).
|
||||
|
||||
Unlike fURLs, only a single net-loc is included, for consistency with other forms of URLs.
|
||||
As a result, multiple NURLs may be available for a single server.
|
||||
|
||||
Syntax
|
||||
------
|
||||
|
||||
The EBNF for a NURL is as follows::
|
||||
|
||||
nurl = scheme, hash, "@", net-loc-list, "/", swiss-number, [ version1 ]
|
||||
|
||||
scheme = "pb://"
|
||||
nurl = tcp-nurl | tor-nurl | i2p-nurl
|
||||
tcp-nurl = "pb://", hash, "@", tcp-loc, "/", swiss-number, [ version1 ]
|
||||
tor-nurl = "pb+tor://", hash, "@", tcp-loc, "/", swiss-number, [ version1 ]
|
||||
i2p-nurl = "pb+i2p://", hash, "@", i2p-loc, "/", swiss-number, [ version1 ]
|
||||
|
||||
hash = unreserved
|
||||
|
||||
net-loc-list = net-loc, [ { ",", net-loc } ]
|
||||
net-loc = tcp-loc | tor-loc | i2p-loc
|
||||
|
||||
tcp-loc = [ "tcp:" ], hostname, [ ":" port ]
|
||||
tor-loc = "tor:", hostname, [ ":" port ]
|
||||
i2p-loc = "i2p:", i2p-addr, [ ":" port ]
|
||||
|
||||
i2p-addr = { unreserved }, ".i2p"
|
||||
tcp-loc = hostname, [ ":" port ]
|
||||
hostname = domain | IPv4address | IPv6address
|
||||
|
||||
i2p-loc = i2p-addr, [ ":" port ]
|
||||
i2p-addr = { unreserved }, ".i2p"
|
||||
|
||||
swiss-number = segment
|
||||
|
||||
version1 = "#v=1"
|
||||
@ -87,11 +91,13 @@ These differences are separated into distinct versions.
|
||||
Version 0
|
||||
---------
|
||||
|
||||
A Foolscap fURL is considered the canonical definition of a version 0 NURL.
|
||||
In theory, a Foolscap fURL with a single netloc is considered the canonical definition of a version 0 NURL.
|
||||
Notably,
|
||||
the hash component is defined as the base32-encoded SHA1 hash of the DER form of an x509v3 certificate.
|
||||
A version 0 NURL is identified by the absence of the ``v=1`` fragment.
|
||||
|
||||
In practice, real world fURLs may have more than one netloc, so lack of version fragment will likely just involve dispatching the fURL to a different parser.
|
||||
|
||||
Examples
|
||||
~~~~~~~~
|
||||
|
||||
@ -103,11 +109,8 @@ Version 1
|
||||
|
||||
The hash component of a version 1 NURL differs in three ways from the prior version.
|
||||
|
||||
1. The hash function used is SHA3-224 instead of SHA1.
|
||||
The security of SHA1 `continues to be eroded`_.
|
||||
Contrariwise SHA3 is currently the most recent addition to the SHA family by NIST.
|
||||
The 224 bit instance is chosen to keep the output short and because it offers greater collision resistance than SHA1 was thought to offer even at its inception
|
||||
(prior to security research showing actual collision resistance is lower).
|
||||
1. The hash function used is SHA-256, to match RFC 7469.
|
||||
The security of SHA1 `continues to be eroded`_; Latacora `SHA-2`_.
|
||||
2. The hash is computed over the certificate's SPKI instead of the whole certificate.
|
||||
This allows certificate re-generation so long as the public key remains the same.
|
||||
This is useful to allow contact information to be updated or extension of validity period.
|
||||
@ -122,7 +125,7 @@ The hash component of a version 1 NURL differs in three ways from the prior vers
|
||||
*all* certificate fields should be considered within the context of the relationship identified by the SPKI hash.
|
||||
|
||||
3. The hash is encoded using urlsafe-base64 (without padding) instead of base32.
|
||||
This provides a more compact representation and minimizes the usability impacts of switching from a 160 bit hash to a 224 bit hash.
|
||||
This provides a more compact representation and minimizes the usability impacts of switching from a 160 bit hash to a 256 bit hash.
|
||||
|
||||
A version 1 NURL is identified by the presence of the ``v=1`` fragment.
|
||||
Though the length of the hash string (38 bytes) could also be used to differentiate it from a version 0 NURL,
|
||||
@ -140,7 +143,8 @@ Examples
|
||||
* ``pb://azEu8vlRpnEeYm0DySQDeNY3Z2iJXHC_bsbaAw@localhost:47877/64i4aokv4ej#v=1``
|
||||
|
||||
.. _`continues to be eroded`: https://en.wikipedia.org/wiki/SHA-1#Cryptanalysis_and_validation
|
||||
.. _`explored by the web community`: https://www.imperialviolet.org/2011/05/04/pinning.html
|
||||
.. _`SHA-2`: https://latacora.micro.blog/2018/04/03/cryptographic-right-answers.html
|
||||
.. _`explored by the web community`: https://www.rfc-editor.org/rfc/rfc7469
|
||||
.. _Foolscap: https://github.com/warner/foolscap
|
||||
|
||||
.. [1] ``foolscap.furl.decode_furl`` is taken as the canonical definition of the syntax of a fURL.
|
||||
|
@ -264,3 +264,18 @@ the "tahoe-conf" file for notes about configuration and installing these
|
||||
plugins into a Munin environment.
|
||||
|
||||
.. _Munin: http://munin-monitoring.org/
|
||||
|
||||
|
||||
Scraping Stats Values in OpenMetrics Format
|
||||
===========================================
|
||||
|
||||
Time Series DataBase (TSDB) software like Prometheus_ and VictoriaMetrics_ can
|
||||
parse statistics from the e.g. http://localhost:3456/statistics?t=openmetrics
|
||||
URL in OpenMetrics_ format. Software like Grafana_ can then be used to graph
|
||||
and alert on these numbers. You can find a pre-configured dashboard for
|
||||
Grafana at https://grafana.com/grafana/dashboards/16894-tahoe-lafs/.
|
||||
|
||||
.. _OpenMetrics: https://openmetrics.io/
|
||||
.. _Prometheus: https://prometheus.io/
|
||||
.. _VictoriaMetrics: https://victoriametrics.com/
|
||||
.. _Grafana: https://grafana.com/
|
||||
|
@ -1,794 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# https://vaneyckt.io/posts/safer_bash_scripts_with_set_euxo_pipefail/
|
||||
set -euxo pipefail
|
||||
|
||||
CODENAME=$(lsb_release --short --codename)
|
||||
|
||||
if [ "$(id -u)" != "0" ]; then
|
||||
SUDO="sudo"
|
||||
else
|
||||
SUDO=""
|
||||
fi
|
||||
|
||||
# Script to install Tor
|
||||
echo "deb http://deb.torproject.org/torproject.org ${CODENAME} main" | ${SUDO} tee -a /etc/apt/sources.list
|
||||
echo "deb-src http://deb.torproject.org/torproject.org ${CODENAME} main" | ${SUDO} tee -a /etc/apt/sources.list
|
||||
|
||||
# # Install Tor repo signing key
|
||||
${SUDO} apt-key add - <<EOF
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
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
|
||||
+AIVYmX7rWpEH+bqlfaEZYJh5kLDebzCq8gtRteRzvRz0jCX/Zr7SZu1PskjRN+e
|
||||
MLdkNgsF9TXQy2vFFM5aRoEenHeD5gMla8pBFnbZP+AA4nPrbuKUyVcfdrLIIgQE
|
||||
KIvxjKetSj3MV1PTuH5YNBw2I3qxrgmrv+YlClmiy/VQXt3/////////////////
|
||||
////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////
|
||||
/////////////////////////////////////////////4kCHAQQAQIABgUCUfg8
|
||||
wQAKCRAiLOjENkQCiI1OEACItuCpRR9YS9HeORrELMBSd2IqJBeto6V0VNse//g/
|
||||
nCVKgOKJo2hpEp9BqPidjBvP20Ek/xIqHr/Pz7R6T1UVsjqtQAlLngxab81wJsRA
|
||||
QNuTpHQ0VoststglEsLtp/ziQYOvgt0yEcqKs7NmIlyA6/Uw4uzXF1D9hnfsQ1sh
|
||||
Iec3d8YpQGZf0jZFu94Hp9hpxtFkTI87yfUkqmFRRsNi9KGksl/hyN7pQMm1rmGh
|
||||
7cERHIHCiaUSu1THiAhEUc5hkMWlM2wbbFn9ZYVVGgoyDWyhDjn7qhKnERrF5dwC
|
||||
cP6mFGo9whO4U4lKUNJHA8OxtDb7mDhagY0wGVTqa+Ob2zqgqiqeLqTYdii7BnBq
|
||||
swcvkbm7BLGzpiLgyJsoxS6Rhzmb+eJiTS0Pkg22y3I/ehD2efoIO4qe/nuoBqho
|
||||
SRDkC1nl3o05NqwF+c4JB7rZo6mO6mSHut4l55avPAeurWXLdnWML9zPbdl9jJMd
|
||||
1EdVMUGfMCY5kmEkuPRw3yGYeTSM+fEB/AHj5bQZN9sjMUhatJZ3RihMoRNqJjMj
|
||||
WM0rdBHF3LGmoqq6YUPYjyfHwmNvTDpCkUM/Utz/zTmRUK6i982r3yV9vp6cdLpj
|
||||
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
|
||||
CMbEgq0I5FE5A+348Ovmobe0TUPn2WClwyRGPCe4X0WMEikEHs3Bb1CFzYfbbIe0
|
||||
N1M/DqjUvfKv0lc325P7i2DlbDuUoLmNMgHHx6+jFqsxlNCobkq+IrhKLxv27/K3
|
||||
13UOzECiPRIbMhHmLHQic9MeJp0bzJiTo1icQVRnim5ZovcpXW2piJQaWqx/TUXG
|
||||
aRdCjYrJJJZObIi6qnSB7SjdxwJUq6GuTEb/BJElQFnjsxySvTu24YkCGwQQAQIA
|
||||
BgUCUVSNVAAKCRB+fTNcWi1ewX4xD/d0R2OHFLo42KJPsIc9Wz3AMO7mfpbCmSXc
|
||||
xoM+Cyd9/GT2qgAt9hgItv3iqg9dj+AbjPNUKfpGG4Q4D/x/tb018C3F4U1PLC/P
|
||||
Q2lYX0csvuv3Gp5MuNpCuHS5bW4kLyOpRZh1JrqniL8K1Mp8cdBhMf6H+ZckQuXS
|
||||
hGHwOhGyBMu3X7biXikSvdgQmbDQMtaDbxuYZ+JGXF0uacPVnlAUwW1F55IIhmUH
|
||||
IV7t+poYo/8M0HJ/lB9y5auamrJT4acsPWS+fYHAjfGfpSE7T7QWuiIKJ2EmpVa5
|
||||
hpGhzII9ahF0wtHTKkF7d7RYV1p1UUA5nu8QFTope8fyERJDZg88ICt+TpXJ7+PJ
|
||||
9THcXgNI+papKy2wKHPfly6B+071BA4n0UX0tV7zqWk9axoN+nyUL97/k572kLTb
|
||||
xahrBEYXphdNeqqXHa/udWpTYaKwSGYmIohTSIqBZh7Xa/rhLsx2UfgR5B0WW34E
|
||||
8cTzuiZz////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////
|
||||
////////iQIcBBABAgAGBQJW5/QxAAoJEPvqMRCoU3iU3SkP+wRdT8z3EczONAcv
|
||||
Jsu7ZHgh1ggzsmozTciSuaAZRfvFmUyB9h63cKNTS86CIrqHmMZrtHRu9llkNNiE
|
||||
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
|
||||
keSPmmDrjl8cySCNsMo=
|
||||
=R0uO
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
EOF
|
||||
|
||||
${SUDO} apt-get --quiet update
|
||||
${SUDO} apt-get --quiet --yes install tor deb.torproject.org-keyring
|
@ -55,7 +55,7 @@ def i2p_network(reactor, temp_dir, request):
|
||||
proto,
|
||||
which("docker"),
|
||||
(
|
||||
"docker", "run", "-p", "7656:7656", "purplei2p/i2pd",
|
||||
"docker", "run", "-p", "7656:7656", "purplei2p/i2pd:release-2.43.0",
|
||||
# Bad URL for reseeds, so it can't talk to other routers.
|
||||
"--reseed.urls", "http://localhost:1/",
|
||||
),
|
||||
@ -63,7 +63,7 @@ def i2p_network(reactor, temp_dir, request):
|
||||
|
||||
def cleanup():
|
||||
try:
|
||||
proto.transport.signalProcess("KILL")
|
||||
proto.transport.signalProcess("INT")
|
||||
util.block_with_timeout(proto.exited, reactor)
|
||||
except ProcessExitedAlready:
|
||||
pass
|
||||
|
@ -40,8 +40,11 @@ if PY2:
|
||||
|
||||
@pytest_twisted.inlineCallbacks
|
||||
def test_onion_service_storage(reactor, request, temp_dir, flog_gatherer, tor_network, tor_introducer_furl):
|
||||
yield _create_anonymous_node(reactor, 'carol', 8008, request, temp_dir, flog_gatherer, tor_network, tor_introducer_furl)
|
||||
yield _create_anonymous_node(reactor, 'dave', 8009, request, temp_dir, flog_gatherer, tor_network, tor_introducer_furl)
|
||||
carol = yield _create_anonymous_node(reactor, 'carol', 8008, request, temp_dir, flog_gatherer, tor_network, tor_introducer_furl)
|
||||
dave = yield _create_anonymous_node(reactor, 'dave', 8009, request, temp_dir, flog_gatherer, tor_network, tor_introducer_furl)
|
||||
util.await_client_ready(carol, minimum_number_of_servers=2)
|
||||
util.await_client_ready(dave, minimum_number_of_servers=2)
|
||||
|
||||
# ensure both nodes are connected to "a grid" by uploading
|
||||
# something via carol, and retrieve it using dave.
|
||||
gold_path = join(temp_dir, "gold")
|
||||
@ -143,5 +146,6 @@ shares.total = 2
|
||||
f.write(node_config)
|
||||
|
||||
print("running")
|
||||
yield util._run_node(reactor, node_dir.path, request, None)
|
||||
result = yield util._run_node(reactor, node_dir.path, request, None)
|
||||
print("okay, launched")
|
||||
return result
|
||||
|
@ -482,14 +482,15 @@ def web_post(tahoe, uri_fragment, **kwargs):
|
||||
return resp.content
|
||||
|
||||
|
||||
def await_client_ready(tahoe, timeout=10, liveness=60*2):
|
||||
def await_client_ready(tahoe, timeout=10, liveness=60*2, minimum_number_of_servers=1):
|
||||
"""
|
||||
Uses the status API to wait for a client-type node (in `tahoe`, a
|
||||
`TahoeProcess` instance usually from a fixture e.g. `alice`) to be
|
||||
'ready'. A client is deemed ready if:
|
||||
|
||||
- it answers `http://<node_url>/statistics/?t=json/`
|
||||
- there is at least one storage-server connected
|
||||
- there is at least one storage-server connected (configurable via
|
||||
``minimum_number_of_servers``)
|
||||
- every storage-server has a "last_received_data" and it is
|
||||
within the last `liveness` seconds
|
||||
|
||||
@ -506,8 +507,8 @@ def await_client_ready(tahoe, timeout=10, liveness=60*2):
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
if len(js['servers']) == 0:
|
||||
print("waiting because no servers at all")
|
||||
if len(js['servers']) < minimum_number_of_servers:
|
||||
print("waiting because insufficient servers")
|
||||
time.sleep(1)
|
||||
continue
|
||||
server_times = [
|
||||
|
95
misc/build_helpers/update-version.py
Normal file
95
misc/build_helpers/update-version.py
Normal file
@ -0,0 +1,95 @@
|
||||
#
|
||||
# this updates the (tagged) version of the software
|
||||
#
|
||||
# Any "options" are hard-coded in here (e.g. the GnuPG key to use)
|
||||
#
|
||||
|
||||
author = "meejah <meejah@meejah.ca>"
|
||||
|
||||
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from packaging.version import Version
|
||||
|
||||
from dulwich.repo import Repo
|
||||
from dulwich.porcelain import (
|
||||
tag_list,
|
||||
tag_create,
|
||||
status,
|
||||
)
|
||||
|
||||
from twisted.internet.task import (
|
||||
react,
|
||||
)
|
||||
from twisted.internet.defer import (
|
||||
ensureDeferred,
|
||||
)
|
||||
|
||||
|
||||
def existing_tags(git):
|
||||
versions = sorted(
|
||||
Version(v.decode("utf8").lstrip("tahoe-lafs-"))
|
||||
for v in tag_list(git)
|
||||
if v.startswith(b"tahoe-lafs-")
|
||||
)
|
||||
return versions
|
||||
|
||||
|
||||
def create_new_version(git):
|
||||
versions = existing_tags(git)
|
||||
biggest = versions[-1]
|
||||
|
||||
return Version(
|
||||
"{}.{}.{}".format(
|
||||
biggest.major,
|
||||
biggest.minor + 1,
|
||||
0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def main(reactor):
|
||||
git = Repo(".")
|
||||
|
||||
st = status(git)
|
||||
if any(st.staged.values()) or st.unstaged:
|
||||
print("unclean checkout; aborting")
|
||||
raise SystemExit(1)
|
||||
|
||||
v = create_new_version(git)
|
||||
if "--no-tag" in sys.argv:
|
||||
print(v)
|
||||
return
|
||||
|
||||
print("Existing tags: {}".format("\n".join(str(x) for x in existing_tags(git))))
|
||||
print("New tag will be {}".format(v))
|
||||
|
||||
# the "tag time" is seconds from the epoch .. we quantize these to
|
||||
# the start of the day in question, in UTC.
|
||||
now = datetime.now()
|
||||
s = now.utctimetuple()
|
||||
ts = int(
|
||||
time.mktime(
|
||||
time.struct_time((s.tm_year, s.tm_mon, s.tm_mday, 0, 0, 0, 0, s.tm_yday, 0))
|
||||
)
|
||||
)
|
||||
tag_create(
|
||||
repo=git,
|
||||
tag="tahoe-lafs-{}".format(str(v)).encode("utf8"),
|
||||
author=author.encode("utf8"),
|
||||
message="Release {}".format(v).encode("utf8"),
|
||||
annotated=True,
|
||||
objectish=b"HEAD",
|
||||
sign=author.encode("utf8"),
|
||||
tag_time=ts,
|
||||
tag_timezone=0,
|
||||
)
|
||||
|
||||
print("Tag created locally, it is not pushed")
|
||||
print("To push it run something like:")
|
||||
print(" git push origin {}".format(v))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
react(lambda r: ensureDeferred(main(r)))
|
@ -1 +0,0 @@
|
||||
|
@ -1 +0,0 @@
|
||||
Added support for Python 3.10. Added support for PyPy3 (3.7 and 3.8, on Linux only).
|
@ -1,8 +0,0 @@
|
||||
The implementation of SDMF and MDMF (mutables) now requires RSA keys to be exactly 2048 bits, aligning them with the specification.
|
||||
|
||||
Some code existed to allow tests to shorten this and it's
|
||||
conceptually possible a modified client produced mutables
|
||||
with different key-sizes. However, the spec says that they
|
||||
must be 2048 bits. If you happen to have a capability with
|
||||
a key-size different from 2048 you may use 1.17.1 or earlier
|
||||
to read the content.
|
@ -1 +0,0 @@
|
||||
Python 3.6 is no longer supported, as it has reached end-of-life and is no longer receiving security updates.
|
@ -1 +0,0 @@
|
||||
Python 3.7 or later is now required; Python 2 is no longer supported.
|
@ -1 +0,0 @@
|
||||
Share corruption reports stored on disk are now always encoded in UTF-8.
|
1
newsfragments/3902.feature
Normal file
1
newsfragments/3902.feature
Normal file
@ -0,0 +1 @@
|
||||
The new HTTPS-based storage server is now enabled transparently on the same port as the Foolscap server. This will not have any user-facing impact until the HTTPS storage protocol is supported in clients as well.
|
1
newsfragments/3922.documentation
Normal file
1
newsfragments/3922.documentation
Normal file
@ -0,0 +1 @@
|
||||
Several minor errors in the Great Black Swamp proposed specification document have been fixed.
|
1
newsfragments/3938.bugfix
Normal file
1
newsfragments/3938.bugfix
Normal file
@ -0,0 +1 @@
|
||||
Work with (and require) newer versions of pycddl.
|
@ -53,10 +53,10 @@
|
||||
"homepage": "",
|
||||
"owner": "DavHau",
|
||||
"repo": "pypi-deps-db",
|
||||
"rev": "76b8f1e44a8ec051b853494bcf3cc8453a294a6a",
|
||||
"sha256": "18fgqyh4z578jjhk26n1xi2cw2l98vrqp962rgz9a6wa5yh1nm4x",
|
||||
"rev": "5fe7d2d1c85cd86d64f4f079eef3f1ff5653bcd6",
|
||||
"sha256": "0pc6mj7rzvmhh303rvj5wf4hrksm4h2rf4fsvqs0ljjdmgxrqm3f",
|
||||
"type": "tarball",
|
||||
"url": "https://github.com/DavHau/pypi-deps-db/archive/76b8f1e44a8ec051b853494bcf3cc8453a294a6a.tar.gz",
|
||||
"url": "https://github.com/DavHau/pypi-deps-db/archive/5fe7d2d1c85cd86d64f4f079eef3f1ff5653bcd6.tar.gz",
|
||||
"url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
|
||||
}
|
||||
}
|
||||
|
29
relnotes.txt
29
relnotes.txt
@ -1,6 +1,6 @@
|
||||
ANNOUNCING Tahoe, the Least-Authority File Store, v1.17.1
|
||||
ANNOUNCING Tahoe, the Least-Authority File Store, v1.18.0
|
||||
|
||||
The Tahoe-LAFS team is pleased to announce version 1.17.1 of
|
||||
The Tahoe-LAFS team is pleased to announce version 1.18.0 of
|
||||
Tahoe-LAFS, an extremely reliable decentralized storage
|
||||
system. Get it with "pip install tahoe-lafs", or download a
|
||||
tarball here:
|
||||
@ -15,10 +15,12 @@ unique security and fault-tolerance properties:
|
||||
|
||||
https://tahoe-lafs.readthedocs.org/en/latest/about.html
|
||||
|
||||
The previous stable release of Tahoe-LAFS was v1.17.0, released on
|
||||
December 6, 2021.
|
||||
The previous stable release of Tahoe-LAFS was v1.17.1, released on
|
||||
January 7, 2022.
|
||||
|
||||
This release fixes two Python3-releated regressions and 4 minor bugs.
|
||||
This release drops support for Python 2 and for Python 3.6 and earlier.
|
||||
twistd.pid is no longer used (in favour of one with pid + process creation time).
|
||||
A collection of minor bugs and issues were also fixed.
|
||||
|
||||
Please see ``NEWS.rst`` [1] for a complete list of changes.
|
||||
|
||||
@ -132,24 +134,23 @@ Of Fame" [13].
|
||||
|
||||
ACKNOWLEDGEMENTS
|
||||
|
||||
This is the nineteenth release of Tahoe-LAFS to be created
|
||||
solely as a labor of love by volunteers. Thank you very much
|
||||
to the team of "hackers in the public interest" who make
|
||||
Tahoe-LAFS possible.
|
||||
This is the twentieth release of Tahoe-LAFS to be created solely as a
|
||||
labor of love by volunteers. Thank you very much to the team of
|
||||
"hackers in the public interest" who make Tahoe-LAFS possible.
|
||||
|
||||
meejah
|
||||
on behalf of the Tahoe-LAFS team
|
||||
|
||||
January 7, 2022
|
||||
October 1, 2022
|
||||
Planet Earth
|
||||
|
||||
|
||||
[1] https://github.com/tahoe-lafs/tahoe-lafs/blob/tahoe-lafs-1.17.1/NEWS.rst
|
||||
[1] https://github.com/tahoe-lafs/tahoe-lafs/blob/tahoe-lafs-1.18.0/NEWS.rst
|
||||
[2] https://github.com/tahoe-lafs/tahoe-lafs/blob/master/docs/known_issues.rst
|
||||
[3] https://tahoe-lafs.org/trac/tahoe-lafs/wiki/RelatedProjects
|
||||
[4] https://github.com/tahoe-lafs/tahoe-lafs/blob/tahoe-lafs-1.17.1/COPYING.GPL
|
||||
[5] https://github.com/tahoe-lafs/tahoe-lafs/blob/tahoe-lafs-1.17.1/COPYING.TGPPL.rst
|
||||
[6] https://tahoe-lafs.readthedocs.org/en/tahoe-lafs-1.17.1/INSTALL.html
|
||||
[4] https://github.com/tahoe-lafs/tahoe-lafs/blob/tahoe-lafs-1.18.0/COPYING.GPL
|
||||
[5] https://github.com/tahoe-lafs/tahoe-lafs/blob/tahoe-lafs-1.18.0/COPYING.TGPPL.rst
|
||||
[6] https://tahoe-lafs.readthedocs.org/en/tahoe-lafs-1.18.0/INSTALL.html
|
||||
[7] https://lists.tahoe-lafs.org/mailman/listinfo/tahoe-dev
|
||||
[8] https://tahoe-lafs.org/trac/tahoe-lafs/roadmap
|
||||
[9] https://github.com/tahoe-lafs/tahoe-lafs/blob/master/CREDITS
|
||||
|
20
setup.py
20
setup.py
@ -114,9 +114,7 @@ install_requires = [
|
||||
"attrs >= 18.2.0",
|
||||
|
||||
# WebSocket library for twisted and asyncio
|
||||
"autobahn >= 19.5.2, != 22.5.1, != 22.4.2, != 22.4.1"
|
||||
# (the ignored versions above don't have autobahn.twisted.testing
|
||||
# packaged properly)
|
||||
"autobahn >= 22.4.3",
|
||||
|
||||
# Support for Python 3 transition
|
||||
"future >= 0.18.2",
|
||||
@ -135,10 +133,15 @@ install_requires = [
|
||||
|
||||
# HTTP server and client
|
||||
"klein",
|
||||
"werkzeug",
|
||||
# 2.2.0 has a bug: https://github.com/pallets/werkzeug/issues/2465
|
||||
"werkzeug != 2.2.0",
|
||||
"treq",
|
||||
"cbor2",
|
||||
"pycddl",
|
||||
"pycddl >= 0.2",
|
||||
|
||||
# for pid-file support
|
||||
"psutil",
|
||||
"filelock",
|
||||
]
|
||||
|
||||
setup_requires = [
|
||||
@ -377,8 +380,15 @@ setup(name="tahoe-lafs", # also set in __init__.py
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/2392 for some
|
||||
# discussion.
|
||||
':sys_platform=="win32"': ["pywin32 != 226"],
|
||||
"build": [
|
||||
"dulwich",
|
||||
"gpg",
|
||||
],
|
||||
"test": [
|
||||
"flake8",
|
||||
# On Python 3.7, importlib_metadata v5 breaks flake8.
|
||||
# https://github.com/python/importlib_metadata/issues/407
|
||||
"importlib_metadata<5; python_version < '3.8'",
|
||||
# Pin a specific pyflakes so we don't have different folks
|
||||
# disagreeing on what is or is not a lint issue. We can bump
|
||||
# this version from time to time, but we will do it
|
||||
|
@ -1,17 +1,9 @@
|
||||
"""
|
||||
Ported to Python 3.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
from __future__ import division
|
||||
from __future__ import print_function
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from future.utils import PY2
|
||||
if PY2:
|
||||
from future.builtins import filter, map, zip, ascii, chr, hex, input, next, oct, open, pow, round, super, bytes, dict, list, object, range, max, min # noqa: F401
|
||||
# Don't use future str to prevent leaking future's newbytes into foolscap, which they break.
|
||||
from past.builtins import unicode as str
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
import os, stat, time, weakref
|
||||
from base64 import urlsafe_b64encode
|
||||
from functools import partial
|
||||
@ -112,6 +104,7 @@ _client_config = configutil.ValidConfiguration(
|
||||
"reserved_space",
|
||||
"storage_dir",
|
||||
"plugins",
|
||||
"force_foolscap",
|
||||
),
|
||||
"sftpd": (
|
||||
"accounts.file",
|
||||
@ -591,6 +584,10 @@ def anonymous_storage_enabled(config):
|
||||
|
||||
@implementer(IStatsProducer)
|
||||
class _Client(node.Node, pollmixin.PollMixin):
|
||||
"""
|
||||
This class should be refactored; see
|
||||
https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3931
|
||||
"""
|
||||
|
||||
STOREDIR = 'storage'
|
||||
NODETYPE = "client"
|
||||
@ -658,6 +655,14 @@ class _Client(node.Node, pollmixin.PollMixin):
|
||||
if webport:
|
||||
self.init_web(webport) # strports string
|
||||
|
||||
# TODO this may be the wrong location for now? but as temporary measure
|
||||
# it allows us to get NURLs for testing in test_istorageserver.py. This
|
||||
# will eventually get fixed one way or another in
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3901. See also
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3931 for the bigger
|
||||
# picture issue.
|
||||
self.storage_nurls : Optional[set] = None
|
||||
|
||||
def init_stats_provider(self):
|
||||
self.stats_provider = StatsProvider(self)
|
||||
self.stats_provider.setServiceParent(self)
|
||||
@ -818,6 +823,11 @@ class _Client(node.Node, pollmixin.PollMixin):
|
||||
if anonymous_storage_enabled(self.config):
|
||||
furl_file = self.config.get_private_path("storage.furl").encode(get_filesystem_encoding())
|
||||
furl = self.tub.registerReference(FoolscapStorageServer(ss), furlFile=furl_file)
|
||||
(_, _, swissnum) = decode_furl(furl)
|
||||
if hasattr(self.tub.negotiationClass, "add_storage_server"):
|
||||
nurls = self.tub.negotiationClass.add_storage_server(ss, swissnum.encode("ascii"))
|
||||
self.storage_nurls = nurls
|
||||
announcement[storage_client.ANONYMOUS_STORAGE_NURLS] = [n.to_text() for n in nurls]
|
||||
announcement["anonymous-storage-FURL"] = furl
|
||||
|
||||
enabled_storage_servers = self._enable_storage_servers(
|
||||
|
@ -20,7 +20,7 @@ class History(object):
|
||||
MAX_UPLOAD_STATUSES = 10
|
||||
MAX_MAPUPDATE_STATUSES = 20
|
||||
MAX_PUBLISH_STATUSES = 20
|
||||
MAX_RETRIEVE_STATUSES = 20
|
||||
MAX_RETRIEVE_STATUSES = 40
|
||||
|
||||
def __init__(self, stats_provider=None):
|
||||
self.stats_provider = stats_provider
|
||||
|
@ -694,3 +694,24 @@ class Encoder(object):
|
||||
return self.uri_extension_data
|
||||
def get_uri_extension_hash(self):
|
||||
return self.uri_extension_hash
|
||||
|
||||
def get_uri_extension_size(self):
|
||||
"""
|
||||
Calculate the size of the URI extension that gets written at the end of
|
||||
immutables.
|
||||
|
||||
This may be done earlier than actual encoding, so e.g. we might not
|
||||
know the crypttext hashes, but that's fine for our purposes since we
|
||||
only care about the length.
|
||||
"""
|
||||
params = self.uri_extension_data.copy()
|
||||
params["crypttext_hash"] = b"\x00" * hashutil.CRYPTO_VAL_SIZE
|
||||
params["crypttext_root_hash"] = b"\x00" * hashutil.CRYPTO_VAL_SIZE
|
||||
params["share_root_hash"] = b"\x00" * hashutil.CRYPTO_VAL_SIZE
|
||||
assert params.keys() == {
|
||||
"codec_name", "codec_params", "size", "segment_size", "num_segments",
|
||||
"needed_shares", "total_shares", "tail_codec_params",
|
||||
"crypttext_hash", "crypttext_root_hash", "share_root_hash"
|
||||
}, params.keys()
|
||||
uri_extension = uri.pack_extension(params)
|
||||
return len(uri_extension)
|
||||
|
@ -19,6 +19,7 @@ from allmydata.util import mathutil, observer, pipeline, log
|
||||
from allmydata.util.assertutil import precondition
|
||||
from allmydata.storage.server import si_b2a
|
||||
|
||||
|
||||
class LayoutInvalid(Exception):
|
||||
""" There is something wrong with these bytes so they can't be
|
||||
interpreted as the kind of immutable file that I know how to download."""
|
||||
@ -90,7 +91,7 @@ FORCE_V2 = False # set briefly by unit tests to make small-sized V2 shares
|
||||
|
||||
def make_write_bucket_proxy(rref, server,
|
||||
data_size, block_size, num_segments,
|
||||
num_share_hashes, uri_extension_size_max):
|
||||
num_share_hashes, uri_extension_size):
|
||||
# Use layout v1 for small files, so they'll be readable by older versions
|
||||
# (<tahoe-1.3.0). Use layout v2 for large files; they'll only be readable
|
||||
# by tahoe-1.3.0 or later.
|
||||
@ -99,11 +100,11 @@ def make_write_bucket_proxy(rref, server,
|
||||
raise FileTooLargeError
|
||||
wbp = WriteBucketProxy(rref, server,
|
||||
data_size, block_size, num_segments,
|
||||
num_share_hashes, uri_extension_size_max)
|
||||
num_share_hashes, uri_extension_size)
|
||||
except FileTooLargeError:
|
||||
wbp = WriteBucketProxy_v2(rref, server,
|
||||
data_size, block_size, num_segments,
|
||||
num_share_hashes, uri_extension_size_max)
|
||||
num_share_hashes, uri_extension_size)
|
||||
return wbp
|
||||
|
||||
@implementer(IStorageBucketWriter)
|
||||
@ -112,20 +113,20 @@ class WriteBucketProxy(object):
|
||||
fieldstruct = ">L"
|
||||
|
||||
def __init__(self, rref, server, data_size, block_size, num_segments,
|
||||
num_share_hashes, uri_extension_size_max, pipeline_size=50000):
|
||||
num_share_hashes, uri_extension_size, pipeline_size=50000):
|
||||
self._rref = rref
|
||||
self._server = server
|
||||
self._data_size = data_size
|
||||
self._block_size = block_size
|
||||
self._num_segments = num_segments
|
||||
self._written_bytes = 0
|
||||
|
||||
effective_segments = mathutil.next_power_of_k(num_segments,2)
|
||||
self._segment_hash_size = (2*effective_segments - 1) * HASH_SIZE
|
||||
# how many share hashes are included in each share? This will be
|
||||
# about ln2(num_shares).
|
||||
self._share_hashtree_size = num_share_hashes * (2+HASH_SIZE)
|
||||
# we commit to not sending a uri extension larger than this
|
||||
self._uri_extension_size_max = uri_extension_size_max
|
||||
self._uri_extension_size = uri_extension_size
|
||||
|
||||
self._create_offsets(block_size, data_size)
|
||||
|
||||
@ -137,7 +138,7 @@ class WriteBucketProxy(object):
|
||||
|
||||
def get_allocated_size(self):
|
||||
return (self._offsets['uri_extension'] + self.fieldsize +
|
||||
self._uri_extension_size_max)
|
||||
self._uri_extension_size)
|
||||
|
||||
def _create_offsets(self, block_size, data_size):
|
||||
if block_size >= 2**32 or data_size >= 2**32:
|
||||
@ -195,6 +196,14 @@ class WriteBucketProxy(object):
|
||||
return self._write(offset, data)
|
||||
|
||||
def put_crypttext_hashes(self, hashes):
|
||||
# plaintext_hash_tree precedes crypttext_hash_tree. It is not used, and
|
||||
# so is not explicitly written, but we need to write everything, so
|
||||
# fill it in with nulls.
|
||||
d = self._write(self._offsets['plaintext_hash_tree'], b"\x00" * self._segment_hash_size)
|
||||
d.addCallback(lambda _: self._really_put_crypttext_hashes(hashes))
|
||||
return d
|
||||
|
||||
def _really_put_crypttext_hashes(self, hashes):
|
||||
offset = self._offsets['crypttext_hash_tree']
|
||||
assert isinstance(hashes, list)
|
||||
data = b"".join(hashes)
|
||||
@ -233,8 +242,7 @@ class WriteBucketProxy(object):
|
||||
def put_uri_extension(self, data):
|
||||
offset = self._offsets['uri_extension']
|
||||
assert isinstance(data, bytes)
|
||||
precondition(len(data) <= self._uri_extension_size_max,
|
||||
len(data), self._uri_extension_size_max)
|
||||
precondition(len(data) == self._uri_extension_size)
|
||||
length = struct.pack(self.fieldstruct, len(data))
|
||||
return self._write(offset, length+data)
|
||||
|
||||
@ -244,11 +252,12 @@ class WriteBucketProxy(object):
|
||||
# would reduce the foolscap CPU overhead per share, but wouldn't
|
||||
# reduce the number of round trips, so it might not be worth the
|
||||
# effort.
|
||||
|
||||
self._written_bytes += len(data)
|
||||
return self._pipeline.add(len(data),
|
||||
self._rref.callRemote, "write", offset, data)
|
||||
|
||||
def close(self):
|
||||
assert self._written_bytes == self.get_allocated_size(), f"{self._written_bytes} != {self.get_allocated_size()}"
|
||||
d = self._pipeline.add(0, self._rref.callRemote, "close")
|
||||
d.addCallback(lambda ign: self._pipeline.flush())
|
||||
return d
|
||||
@ -303,8 +312,6 @@ class WriteBucketProxy_v2(WriteBucketProxy):
|
||||
@implementer(IStorageBucketReader)
|
||||
class ReadBucketProxy(object):
|
||||
|
||||
MAX_UEB_SIZE = 2000 # actual size is closer to 419, but varies by a few bytes
|
||||
|
||||
def __init__(self, rref, server, storage_index):
|
||||
self._rref = rref
|
||||
self._server = server
|
||||
@ -332,11 +339,6 @@ class ReadBucketProxy(object):
|
||||
# TODO: for small shares, read the whole bucket in _start()
|
||||
d = self._fetch_header()
|
||||
d.addCallback(self._parse_offsets)
|
||||
# XXX The following two callbacks implement a slightly faster/nicer
|
||||
# way to get the ueb and sharehashtree, but it requires that the
|
||||
# storage server be >= v1.3.0.
|
||||
# d.addCallback(self._fetch_sharehashtree_and_ueb)
|
||||
# d.addCallback(self._parse_sharehashtree_and_ueb)
|
||||
def _fail_waiters(f):
|
||||
self._ready.fire(f)
|
||||
def _notify_waiters(result):
|
||||
@ -381,29 +383,6 @@ class ReadBucketProxy(object):
|
||||
self._offsets[field] = offset
|
||||
return self._offsets
|
||||
|
||||
def _fetch_sharehashtree_and_ueb(self, offsets):
|
||||
sharehashtree_size = offsets['uri_extension'] - offsets['share_hashes']
|
||||
return self._read(offsets['share_hashes'],
|
||||
self.MAX_UEB_SIZE+sharehashtree_size)
|
||||
|
||||
def _parse_sharehashtree_and_ueb(self, data):
|
||||
sharehashtree_size = self._offsets['uri_extension'] - self._offsets['share_hashes']
|
||||
if len(data) < sharehashtree_size:
|
||||
raise LayoutInvalid("share hash tree truncated -- should have at least %d bytes -- not %d" % (sharehashtree_size, len(data)))
|
||||
if sharehashtree_size % (2+HASH_SIZE) != 0:
|
||||
raise LayoutInvalid("share hash tree malformed -- should have an even multiple of %d bytes -- not %d" % (2+HASH_SIZE, sharehashtree_size))
|
||||
self._share_hashes = []
|
||||
for i in range(0, sharehashtree_size, 2+HASH_SIZE):
|
||||
hashnum = struct.unpack(">H", data[i:i+2])[0]
|
||||
hashvalue = data[i+2:i+2+HASH_SIZE]
|
||||
self._share_hashes.append( (hashnum, hashvalue) )
|
||||
|
||||
i = self._offsets['uri_extension']-self._offsets['share_hashes']
|
||||
if len(data) < i+self._fieldsize:
|
||||
raise LayoutInvalid("not enough bytes to encode URI length -- should be at least %d bytes long, not %d " % (i+self._fieldsize, len(data),))
|
||||
length = struct.unpack(self._fieldstruct, data[i:i+self._fieldsize])[0]
|
||||
self._ueb_data = data[i+self._fieldsize:i+self._fieldsize+length]
|
||||
|
||||
def _get_block_data(self, unused, blocknum, blocksize, thisblocksize):
|
||||
offset = self._offsets['data'] + blocknum * blocksize
|
||||
return self._read(offset, thisblocksize)
|
||||
@ -446,20 +425,18 @@ class ReadBucketProxy(object):
|
||||
else:
|
||||
return defer.succeed([])
|
||||
|
||||
def _get_share_hashes(self, unused=None):
|
||||
if hasattr(self, '_share_hashes'):
|
||||
return self._share_hashes
|
||||
return self._get_share_hashes_the_old_way()
|
||||
|
||||
def get_share_hashes(self):
|
||||
d = self._start_if_needed()
|
||||
d.addCallback(self._get_share_hashes)
|
||||
return d
|
||||
|
||||
def _get_share_hashes_the_old_way(self):
|
||||
def _get_share_hashes(self, _ignore):
|
||||
""" Tahoe storage servers < v1.3.0 would return an error if you tried
|
||||
to read past the end of the share, so we need to use the offset and
|
||||
read just that much."""
|
||||
read just that much.
|
||||
|
||||
HTTP-based storage protocol also doesn't like reading past the end.
|
||||
"""
|
||||
offset = self._offsets['share_hashes']
|
||||
size = self._offsets['uri_extension'] - offset
|
||||
if size % (2+HASH_SIZE) != 0:
|
||||
@ -477,32 +454,29 @@ class ReadBucketProxy(object):
|
||||
d.addCallback(_unpack_share_hashes)
|
||||
return d
|
||||
|
||||
def _get_uri_extension_the_old_way(self, unused=None):
|
||||
def _get_uri_extension(self, unused=None):
|
||||
""" Tahoe storage servers < v1.3.0 would return an error if you tried
|
||||
to read past the end of the share, so we need to fetch the UEB size
|
||||
and then read just that much."""
|
||||
and then read just that much.
|
||||
|
||||
HTTP-based storage protocol also doesn't like reading past the end.
|
||||
"""
|
||||
offset = self._offsets['uri_extension']
|
||||
d = self._read(offset, self._fieldsize)
|
||||
def _got_length(data):
|
||||
if len(data) != self._fieldsize:
|
||||
raise LayoutInvalid("not enough bytes to encode URI length -- should be %d bytes long, not %d " % (self._fieldsize, len(data),))
|
||||
length = struct.unpack(self._fieldstruct, data)[0]
|
||||
if length >= 2**31:
|
||||
# URI extension blocks are around 419 bytes long, so this
|
||||
# must be corrupted. Anyway, the foolscap interface schema
|
||||
# for "read" will not allow >= 2**31 bytes length.
|
||||
if length >= 2000:
|
||||
# URI extension blocks are around 419 bytes long; in previous
|
||||
# versions of the code 1000 was used as a default catchall. So
|
||||
# 2000 or more must be corrupted.
|
||||
raise RidiculouslyLargeURIExtensionBlock(length)
|
||||
|
||||
return self._read(offset+self._fieldsize, length)
|
||||
d.addCallback(_got_length)
|
||||
return d
|
||||
|
||||
def _get_uri_extension(self, unused=None):
|
||||
if hasattr(self, '_ueb_data'):
|
||||
return self._ueb_data
|
||||
else:
|
||||
return self._get_uri_extension_the_old_way()
|
||||
|
||||
def get_uri_extension(self):
|
||||
d = self._start_if_needed()
|
||||
d.addCallback(self._get_uri_extension)
|
||||
|
@ -242,31 +242,26 @@ class UploadResults(object):
|
||||
def get_verifycapstr(self):
|
||||
return self._verifycapstr
|
||||
|
||||
# our current uri_extension is 846 bytes for small files, a few bytes
|
||||
# more for larger ones (since the filesize is encoded in decimal in a
|
||||
# few places). Ask for a little bit more just in case we need it. If
|
||||
# the extension changes size, we can change EXTENSION_SIZE to
|
||||
# allocate a more accurate amount of space.
|
||||
EXTENSION_SIZE = 1000
|
||||
# TODO: actual extensions are closer to 419 bytes, so we can probably lower
|
||||
# this.
|
||||
|
||||
def pretty_print_shnum_to_servers(s):
|
||||
return ', '.join([ "sh%s: %s" % (k, '+'.join([idlib.shortnodeid_b2a(x) for x in v])) for k, v in s.items() ])
|
||||
|
||||
|
||||
class ServerTracker(object):
|
||||
def __init__(self, server,
|
||||
sharesize, blocksize, num_segments, num_share_hashes,
|
||||
storage_index,
|
||||
bucket_renewal_secret, bucket_cancel_secret):
|
||||
bucket_renewal_secret, bucket_cancel_secret,
|
||||
uri_extension_size):
|
||||
self._server = server
|
||||
self.buckets = {} # k: shareid, v: IRemoteBucketWriter
|
||||
self.sharesize = sharesize
|
||||
self.uri_extension_size = uri_extension_size
|
||||
|
||||
wbp = layout.make_write_bucket_proxy(None, None, sharesize,
|
||||
blocksize, num_segments,
|
||||
num_share_hashes,
|
||||
EXTENSION_SIZE)
|
||||
uri_extension_size)
|
||||
self.wbp_class = wbp.__class__ # to create more of them
|
||||
self.allocated_size = wbp.get_allocated_size()
|
||||
self.blocksize = blocksize
|
||||
@ -314,7 +309,7 @@ class ServerTracker(object):
|
||||
self.blocksize,
|
||||
self.num_segments,
|
||||
self.num_share_hashes,
|
||||
EXTENSION_SIZE)
|
||||
self.uri_extension_size)
|
||||
b[sharenum] = bp
|
||||
self.buckets.update(b)
|
||||
return (alreadygot, set(b.keys()))
|
||||
@ -487,7 +482,7 @@ class Tahoe2ServerSelector(log.PrefixingLogMixin):
|
||||
def get_shareholders(self, storage_broker, secret_holder,
|
||||
storage_index, share_size, block_size,
|
||||
num_segments, total_shares, needed_shares,
|
||||
min_happiness):
|
||||
min_happiness, uri_extension_size):
|
||||
"""
|
||||
@return: (upload_trackers, already_serverids), where upload_trackers
|
||||
is a set of ServerTracker instances that have agreed to hold
|
||||
@ -529,7 +524,8 @@ class Tahoe2ServerSelector(log.PrefixingLogMixin):
|
||||
# figure out how much space to ask for
|
||||
wbp = layout.make_write_bucket_proxy(None, None,
|
||||
share_size, 0, num_segments,
|
||||
num_share_hashes, EXTENSION_SIZE)
|
||||
num_share_hashes,
|
||||
uri_extension_size)
|
||||
allocated_size = wbp.get_allocated_size()
|
||||
|
||||
# decide upon the renewal/cancel secrets, to include them in the
|
||||
@ -554,7 +550,7 @@ class Tahoe2ServerSelector(log.PrefixingLogMixin):
|
||||
def _create_server_tracker(server, renew, cancel):
|
||||
return ServerTracker(
|
||||
server, share_size, block_size, num_segments, num_share_hashes,
|
||||
storage_index, renew, cancel,
|
||||
storage_index, renew, cancel, uri_extension_size
|
||||
)
|
||||
|
||||
readonly_trackers, write_trackers = self._create_trackers(
|
||||
@ -1326,7 +1322,8 @@ class CHKUploader(object):
|
||||
d = server_selector.get_shareholders(storage_broker, secret_holder,
|
||||
storage_index,
|
||||
share_size, block_size,
|
||||
num_segments, n, k, desired)
|
||||
num_segments, n, k, desired,
|
||||
encoder.get_uri_extension_size())
|
||||
def _done(res):
|
||||
self._server_selection_elapsed = time.time() - server_selection_started
|
||||
return res
|
||||
|
@ -55,6 +55,8 @@ from allmydata.util.yamlutil import (
|
||||
from . import (
|
||||
__full_version__,
|
||||
)
|
||||
from .protocol_switch import create_tub_with_https_support
|
||||
|
||||
|
||||
def _common_valid_config():
|
||||
return configutil.ValidConfiguration({
|
||||
@ -695,7 +697,7 @@ def create_connection_handlers(config, i2p_provider, tor_provider):
|
||||
|
||||
|
||||
def create_tub(tub_options, default_connection_handlers, foolscap_connection_handlers,
|
||||
handler_overrides={}, **kwargs):
|
||||
handler_overrides={}, force_foolscap=False, **kwargs):
|
||||
"""
|
||||
Create a Tub with the right options and handlers. It will be
|
||||
ephemeral unless the caller provides certFile= in kwargs
|
||||
@ -705,8 +707,17 @@ def create_tub(tub_options, default_connection_handlers, foolscap_connection_han
|
||||
|
||||
:param dict tub_options: every key-value pair in here will be set in
|
||||
the new Tub via `Tub.setOption`
|
||||
|
||||
:param bool force_foolscap: If True, only allow Foolscap, not just HTTPS
|
||||
storage protocol.
|
||||
"""
|
||||
tub = Tub(**kwargs)
|
||||
# We listen simultaneously for both Foolscap and HTTPS on the same port,
|
||||
# so we have to create a special Foolscap Tub for that to work:
|
||||
if force_foolscap:
|
||||
tub = Tub(**kwargs)
|
||||
else:
|
||||
tub = create_tub_with_https_support(**kwargs)
|
||||
|
||||
for (name, value) in list(tub_options.items()):
|
||||
tub.setOption(name, value)
|
||||
handlers = default_connection_handlers.copy()
|
||||
@ -896,14 +907,20 @@ def create_main_tub(config, tub_options,
|
||||
|
||||
# FIXME? "node.pem" was the CERTFILE option/thing
|
||||
certfile = config.get_private_path("node.pem")
|
||||
|
||||
tub = create_tub(
|
||||
tub_options,
|
||||
default_connection_handlers,
|
||||
foolscap_connection_handlers,
|
||||
# TODO eventually we will want the default to be False, but for now we
|
||||
# don't want to enable HTTP by default.
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3934
|
||||
force_foolscap=config.get_config(
|
||||
"storage", "force_foolscap", default=True, boolean=True
|
||||
),
|
||||
handler_overrides=handler_overrides,
|
||||
certFile=certfile,
|
||||
)
|
||||
|
||||
if portlocation is None:
|
||||
log.msg("Tub is not listening")
|
||||
else:
|
||||
|
210
src/allmydata/protocol_switch.py
Normal file
210
src/allmydata/protocol_switch.py
Normal file
@ -0,0 +1,210 @@
|
||||
"""
|
||||
Support for listening with both HTTPS and Foolscap on the same port.
|
||||
|
||||
The goal is to make the transition from Foolscap to HTTPS-based protocols as
|
||||
simple as possible, with no extra configuration needed. Listening on the same
|
||||
port means a user upgrading Tahoe-LAFS will automatically get HTTPS working
|
||||
with no additional changes.
|
||||
|
||||
Use ``create_tub_with_https_support()`` creates a new ``Tub`` that has its
|
||||
``negotiationClass`` modified to be a new subclass tied to that specific
|
||||
``Tub`` instance. Calling ``tub.negotiationClass.add_storage_server(...)``
|
||||
then adds relevant information for a storage server once it becomes available
|
||||
later in the configuration process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import chain
|
||||
|
||||
from twisted.internet.protocol import Protocol
|
||||
from twisted.internet.interfaces import IDelayedCall
|
||||
from twisted.internet.ssl import CertificateOptions
|
||||
from twisted.web.server import Site
|
||||
from twisted.protocols.tls import TLSMemoryBIOFactory
|
||||
from twisted.internet import reactor
|
||||
|
||||
from hyperlink import DecodedURL
|
||||
from foolscap.negotiate import Negotiation
|
||||
from foolscap.api import Tub
|
||||
|
||||
from .storage.http_server import HTTPServer, build_nurl
|
||||
from .storage.server import StorageServer
|
||||
|
||||
|
||||
class _PretendToBeNegotiation(type):
|
||||
"""
|
||||
Metaclass that allows ``_FoolscapOrHttps`` to pretend to be a
|
||||
``Negotiation`` instance, since Foolscap does some checks like
|
||||
``assert isinstance(protocol, tub.negotiationClass)`` in its internals,
|
||||
and sometimes that ``protocol`` is a ``_FoolscapOrHttps`` instance, but
|
||||
sometimes it's a ``Negotiation`` instance.
|
||||
"""
|
||||
|
||||
def __instancecheck__(self, instance):
|
||||
return issubclass(instance.__class__, self) or isinstance(instance, Negotiation)
|
||||
|
||||
|
||||
class _FoolscapOrHttps(Protocol, metaclass=_PretendToBeNegotiation):
|
||||
"""
|
||||
Based on initial query, decide whether we're talking Foolscap or HTTP.
|
||||
|
||||
Additionally, pretends to be a ``foolscap.negotiate.Negotiation`` instance,
|
||||
since these are created by Foolscap's ``Tub``, by setting this to be the
|
||||
tub's ``negotiationClass``.
|
||||
|
||||
Do not instantiate directly, use ``create_tub_with_https_support(...)``
|
||||
instead. The way this class works is that a new subclass is created for a
|
||||
specific ``Tub`` instance.
|
||||
"""
|
||||
|
||||
# These are class attributes; they will be set by
|
||||
# create_tub_with_https_support() and add_storage_server().
|
||||
|
||||
# The Twisted HTTPS protocol factory wrapping the storage server HTTP API:
|
||||
https_factory: TLSMemoryBIOFactory
|
||||
# The tub that created us:
|
||||
tub: Tub
|
||||
|
||||
@classmethod
|
||||
def add_storage_server(
|
||||
cls, storage_server: StorageServer, swissnum: bytes
|
||||
) -> set[DecodedURL]:
|
||||
"""
|
||||
Update a ``_FoolscapOrHttps`` subclass for a specific ``Tub`` instance
|
||||
with the class attributes it requires for a specific storage server.
|
||||
|
||||
Returns the resulting NURLs.
|
||||
"""
|
||||
# We need to be a subclass:
|
||||
assert cls != _FoolscapOrHttps
|
||||
# The tub instance must already be set:
|
||||
assert hasattr(cls, "tub")
|
||||
assert isinstance(cls.tub, Tub)
|
||||
|
||||
# Tub.myCertificate is a twisted.internet.ssl.PrivateCertificate
|
||||
# instance.
|
||||
certificate_options = CertificateOptions(
|
||||
privateKey=cls.tub.myCertificate.privateKey.original,
|
||||
certificate=cls.tub.myCertificate.original,
|
||||
)
|
||||
|
||||
http_storage_server = HTTPServer(storage_server, swissnum)
|
||||
cls.https_factory = TLSMemoryBIOFactory(
|
||||
certificate_options,
|
||||
False,
|
||||
Site(http_storage_server.get_resource()),
|
||||
)
|
||||
|
||||
storage_nurls = set()
|
||||
# Individual hints can be in the form
|
||||
# "tcp:host:port,tcp:host:port,tcp:host:port".
|
||||
for location_hint in chain.from_iterable(
|
||||
hints.split(",") for hints in cls.tub.locationHints
|
||||
):
|
||||
if location_hint.startswith("tcp:"):
|
||||
_, hostname, port = location_hint.split(":")
|
||||
port = int(port)
|
||||
storage_nurls.add(
|
||||
build_nurl(
|
||||
hostname,
|
||||
port,
|
||||
str(swissnum, "ascii"),
|
||||
cls.tub.myCertificate.original.to_cryptography(),
|
||||
)
|
||||
)
|
||||
# TODO this is probably where we'll have to support Tor and I2P?
|
||||
# See https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3888#comment:9
|
||||
# for discussion (there will be separate tickets added for those at
|
||||
# some point.)
|
||||
return storage_nurls
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._foolscap: Negotiation = Negotiation(*args, **kwargs)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name in {"_foolscap", "_buffer", "transport", "__class__", "_timeout"}:
|
||||
object.__setattr__(self, name, value)
|
||||
else:
|
||||
setattr(self._foolscap, name, value)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._foolscap, name)
|
||||
|
||||
def _convert_to_negotiation(self):
|
||||
"""
|
||||
Convert self to a ``Negotiation`` instance.
|
||||
"""
|
||||
self.__class__ = Negotiation # type: ignore
|
||||
self.__dict__ = self._foolscap.__dict__
|
||||
|
||||
def initClient(self, *args, **kwargs):
|
||||
# After creation, a Negotiation instance either has initClient() or
|
||||
# initServer() called. Since this is a client, we're never going to do
|
||||
# HTTP, so we can immediately become a Negotiation instance.
|
||||
assert not hasattr(self, "_buffer")
|
||||
self._convert_to_negotiation()
|
||||
return self.initClient(*args, **kwargs)
|
||||
|
||||
def connectionMade(self):
|
||||
self._buffer: bytes = b""
|
||||
self._timeout: IDelayedCall = reactor.callLater(
|
||||
30, self.transport.abortConnection
|
||||
)
|
||||
|
||||
def connectionLost(self, reason):
|
||||
if self._timeout.active():
|
||||
self._timeout.cancel()
|
||||
|
||||
def dataReceived(self, data: bytes) -> None:
|
||||
"""Handle incoming data.
|
||||
|
||||
Once we've decided which protocol we are, update self.__class__, at
|
||||
which point all methods will be called on the new class.
|
||||
"""
|
||||
self._buffer += data
|
||||
if len(self._buffer) < 8:
|
||||
return
|
||||
|
||||
# Check if it looks like a Foolscap request. If so, it can handle this
|
||||
# and later data, otherwise assume HTTPS.
|
||||
self._timeout.cancel()
|
||||
if self._buffer.startswith(b"GET /id/"):
|
||||
# We're a Foolscap Negotiation server protocol instance:
|
||||
transport = self.transport
|
||||
buf = self._buffer
|
||||
self._convert_to_negotiation()
|
||||
self.makeConnection(transport)
|
||||
self.dataReceived(buf)
|
||||
return
|
||||
else:
|
||||
# We're a HTTPS protocol instance, serving the storage protocol:
|
||||
assert self.transport is not None
|
||||
protocol = self.https_factory.buildProtocol(self.transport.getPeer())
|
||||
protocol.makeConnection(self.transport)
|
||||
protocol.dataReceived(self._buffer)
|
||||
|
||||
# Update the factory so it knows we're transforming to a new
|
||||
# protocol object (we'll do that next)
|
||||
value = self.https_factory.protocols.pop(protocol)
|
||||
self.https_factory.protocols[self] = value
|
||||
|
||||
# Transform self into the TLS protocol 🪄
|
||||
self.__class__ = protocol.__class__
|
||||
self.__dict__ = protocol.__dict__
|
||||
|
||||
|
||||
def create_tub_with_https_support(**kwargs) -> Tub:
|
||||
"""
|
||||
Create a new Tub that also supports HTTPS.
|
||||
|
||||
This involves creating a new protocol switch class for the specific ``Tub``
|
||||
instance.
|
||||
"""
|
||||
the_tub = Tub(**kwargs)
|
||||
|
||||
class FoolscapOrHttpForTub(_FoolscapOrHttps):
|
||||
tub = the_tub
|
||||
|
||||
the_tub.negotiationClass = FoolscapOrHttpForTub # type: ignore
|
||||
return the_tub
|
@ -47,11 +47,6 @@ if _default_nodedir:
|
||||
NODEDIR_HELP += " [default for most commands: " + quote_local_unicode_path(_default_nodedir) + "]"
|
||||
|
||||
|
||||
# XXX all this 'dispatch' stuff needs to be unified + fixed up
|
||||
_control_node_dispatch = {
|
||||
"run": tahoe_run.run,
|
||||
}
|
||||
|
||||
process_control_commands = [
|
||||
("run", None, tahoe_run.RunOptions, "run a node without daemonizing"),
|
||||
] # type: SubCommands
|
||||
@ -195,6 +190,7 @@ def parse_or_exit(config, argv, stdout, stderr):
|
||||
return config
|
||||
|
||||
def dispatch(config,
|
||||
reactor,
|
||||
stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr):
|
||||
command = config.subCommand
|
||||
so = config.subOptions
|
||||
@ -206,8 +202,8 @@ def dispatch(config,
|
||||
|
||||
if command in create_dispatch:
|
||||
f = create_dispatch[command]
|
||||
elif command in _control_node_dispatch:
|
||||
f = _control_node_dispatch[command]
|
||||
elif command == "run":
|
||||
f = lambda config: tahoe_run.run(reactor, config)
|
||||
elif command in debug.dispatch:
|
||||
f = debug.dispatch[command]
|
||||
elif command in admin.dispatch:
|
||||
@ -361,7 +357,7 @@ def _run_with_reactor(reactor, config, argv, stdout, stderr):
|
||||
stderr,
|
||||
)
|
||||
d.addCallback(_maybe_enable_eliot_logging, reactor)
|
||||
d.addCallback(dispatch, stdout=stdout, stderr=stderr)
|
||||
d.addCallback(dispatch, reactor, stdout=stdout, stderr=stderr)
|
||||
def _show_exception(f):
|
||||
# when task.react() notices a non-SystemExit exception, it does
|
||||
# log.err() with the failure and then exits with rc=1. We want this
|
||||
|
@ -19,6 +19,7 @@ import os, sys
|
||||
from allmydata.scripts.common import BasedirOptions
|
||||
from twisted.scripts import twistd
|
||||
from twisted.python import usage
|
||||
from twisted.python.filepath import FilePath
|
||||
from twisted.python.reflect import namedAny
|
||||
from twisted.internet.defer import maybeDeferred
|
||||
from twisted.application.service import Service
|
||||
@ -27,6 +28,13 @@ from allmydata.scripts.default_nodedir import _default_nodedir
|
||||
from allmydata.util.encodingutil import listdir_unicode, quote_local_unicode_path
|
||||
from allmydata.util.configutil import UnknownConfigError
|
||||
from allmydata.util.deferredutil import HookMixin
|
||||
from allmydata.util.pid import (
|
||||
parse_pidfile,
|
||||
check_pid_process,
|
||||
cleanup_pidfile,
|
||||
ProcessInTheWay,
|
||||
InvalidPidFile,
|
||||
)
|
||||
from allmydata.storage.crawler import (
|
||||
MigratePickleFileError,
|
||||
)
|
||||
@ -35,35 +43,34 @@ from allmydata.node import (
|
||||
PrivacyError,
|
||||
)
|
||||
|
||||
|
||||
def get_pidfile(basedir):
|
||||
"""
|
||||
Returns the path to the PID file.
|
||||
:param basedir: the node's base directory
|
||||
:returns: the path to the PID file
|
||||
"""
|
||||
return os.path.join(basedir, u"twistd.pid")
|
||||
return os.path.join(basedir, u"running.process")
|
||||
|
||||
|
||||
def get_pid_from_pidfile(pidfile):
|
||||
"""
|
||||
Tries to read and return the PID stored in the node's PID file
|
||||
(twistd.pid).
|
||||
|
||||
:param pidfile: try to read this PID file
|
||||
:returns: A numeric PID on success, ``None`` if PID file absent or
|
||||
inaccessible, ``-1`` if PID file invalid.
|
||||
"""
|
||||
try:
|
||||
with open(pidfile, "r") as f:
|
||||
pid = f.read()
|
||||
pid, _ = parse_pidfile(pidfile)
|
||||
except EnvironmentError:
|
||||
return None
|
||||
|
||||
try:
|
||||
pid = int(pid)
|
||||
except ValueError:
|
||||
except InvalidPidFile:
|
||||
return -1
|
||||
|
||||
return pid
|
||||
|
||||
|
||||
def identify_node_type(basedir):
|
||||
"""
|
||||
:return unicode: None or one of: 'client' or 'introducer'.
|
||||
@ -179,7 +186,7 @@ class DaemonizeTheRealService(Service, HookMixin):
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stderr.write("\nUnknown error\n")
|
||||
self.stderr.write("\nUnknown error, here's the traceback:\n")
|
||||
reason.printTraceback(self.stderr)
|
||||
reactor.stop()
|
||||
|
||||
@ -206,7 +213,7 @@ class DaemonizeTahoeNodePlugin(object):
|
||||
return DaemonizeTheRealService(self.nodetype, self.basedir, so)
|
||||
|
||||
|
||||
def run(config, runApp=twistd.runApp):
|
||||
def run(reactor, config, runApp=twistd.runApp):
|
||||
"""
|
||||
Runs a Tahoe-LAFS node in the foreground.
|
||||
|
||||
@ -227,10 +234,15 @@ def run(config, runApp=twistd.runApp):
|
||||
print("%s is not a recognizable node directory" % quoted_basedir, file=err)
|
||||
return 1
|
||||
|
||||
twistd_args = ["--nodaemon", "--rundir", basedir]
|
||||
twistd_args = [
|
||||
# ensure twistd machinery does not daemonize.
|
||||
"--nodaemon",
|
||||
"--rundir", basedir,
|
||||
]
|
||||
if sys.platform != "win32":
|
||||
pidfile = get_pidfile(basedir)
|
||||
twistd_args.extend(["--pidfile", pidfile])
|
||||
# turn off Twisted's pid-file to use our own -- but not on
|
||||
# windows, because twistd doesn't know about pidfiles there
|
||||
twistd_args.extend(["--pidfile", None])
|
||||
twistd_args.extend(config.twistd_args)
|
||||
twistd_args.append("DaemonizeTahoeNode") # point at our DaemonizeTahoeNodePlugin
|
||||
|
||||
@ -246,10 +258,18 @@ def run(config, runApp=twistd.runApp):
|
||||
return 1
|
||||
twistd_config.loadedPlugins = {"DaemonizeTahoeNode": DaemonizeTahoeNodePlugin(nodetype, basedir)}
|
||||
|
||||
# handle invalid PID file (twistd might not start otherwise)
|
||||
if sys.platform != "win32" and get_pid_from_pidfile(pidfile) == -1:
|
||||
print("found invalid PID file in %s - deleting it" % basedir, file=err)
|
||||
os.remove(pidfile)
|
||||
# our own pid-style file contains PID and process creation time
|
||||
pidfile = FilePath(get_pidfile(config['basedir']))
|
||||
try:
|
||||
check_pid_process(pidfile)
|
||||
except (ProcessInTheWay, InvalidPidFile) as e:
|
||||
print("ERROR: {}".format(e), file=err)
|
||||
return 1
|
||||
else:
|
||||
reactor.addSystemEventTrigger(
|
||||
"after", "shutdown",
|
||||
lambda: cleanup_pidfile(pidfile)
|
||||
)
|
||||
|
||||
# We always pass --nodaemon so twistd.runApp does not daemonize.
|
||||
print("running node in %s" % (quoted_basedir,), file=out)
|
||||
|
@ -4,10 +4,12 @@ HTTP client that talks to the HTTP storage server.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Union, Optional, Sequence, Mapping
|
||||
from typing import Union, Optional, Sequence, Mapping, BinaryIO
|
||||
from base64 import b64encode
|
||||
from io import BytesIO
|
||||
from os import SEEK_END
|
||||
|
||||
from attrs import define, asdict, frozen
|
||||
from attrs import define, asdict, frozen, field
|
||||
|
||||
# TODO Make sure to import Python version?
|
||||
from cbor2 import loads, dumps
|
||||
@ -17,8 +19,12 @@ from werkzeug.datastructures import Range, ContentRange
|
||||
from twisted.web.http_headers import Headers
|
||||
from twisted.web import http
|
||||
from twisted.web.iweb import IPolicyForHTTPS
|
||||
from twisted.internet.defer import inlineCallbacks, returnValue, fail, Deferred
|
||||
from twisted.internet.interfaces import IOpenSSLClientConnectionCreator
|
||||
from twisted.internet.defer import inlineCallbacks, returnValue, fail, Deferred, succeed
|
||||
from twisted.internet.interfaces import (
|
||||
IOpenSSLClientConnectionCreator,
|
||||
IReactorTime,
|
||||
IDelayedCall,
|
||||
)
|
||||
from twisted.internet.ssl import CertificateOptions
|
||||
from twisted.web.client import Agent, HTTPConnectionPool
|
||||
from zope.interface import implementer
|
||||
@ -28,6 +34,7 @@ from treq.client import HTTPClient
|
||||
from treq.testing import StubTreq
|
||||
from OpenSSL import SSL
|
||||
from cryptography.hazmat.bindings.openssl.binding import Binding
|
||||
from werkzeug.http import parse_content_range_header
|
||||
|
||||
from .http_common import (
|
||||
swissnum_auth_header,
|
||||
@ -80,54 +87,94 @@ _SCHEMAS = {
|
||||
"allocate_buckets": Schema(
|
||||
"""
|
||||
response = {
|
||||
already-have: #6.258([* uint])
|
||||
allocated: #6.258([* uint])
|
||||
already-have: #6.258([0*256 uint])
|
||||
allocated: #6.258([0*256 uint])
|
||||
}
|
||||
"""
|
||||
),
|
||||
"immutable_write_share_chunk": Schema(
|
||||
"""
|
||||
response = {
|
||||
required: [* {begin: uint, end: uint}]
|
||||
required: [0* {begin: uint, end: uint}]
|
||||
}
|
||||
"""
|
||||
),
|
||||
"list_shares": Schema(
|
||||
"""
|
||||
response = #6.258([* uint])
|
||||
response = #6.258([0*256 uint])
|
||||
"""
|
||||
),
|
||||
"mutable_read_test_write": Schema(
|
||||
"""
|
||||
response = {
|
||||
"success": bool,
|
||||
"data": {* share_number: [* bstr]}
|
||||
"data": {0*256 share_number: [0* bstr]}
|
||||
}
|
||||
share_number = uint
|
||||
"""
|
||||
),
|
||||
"mutable_list_shares": Schema(
|
||||
"""
|
||||
response = #6.258([0*256 uint])
|
||||
"""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _decode_cbor(response, schema: Schema):
|
||||
"""Given HTTP response, return decoded CBOR body."""
|
||||
@define
|
||||
class _LengthLimitedCollector:
|
||||
"""
|
||||
Collect data using ``treq.collect()``, with limited length.
|
||||
"""
|
||||
|
||||
def got_content(data):
|
||||
schema.validate_cbor(data)
|
||||
return loads(data)
|
||||
remaining_length: int
|
||||
timeout_on_silence: IDelayedCall
|
||||
f: BytesIO = field(factory=BytesIO)
|
||||
|
||||
if response.code > 199 and response.code < 300:
|
||||
content_type = get_content_type(response.headers)
|
||||
if content_type == CBOR_MIME_TYPE:
|
||||
# TODO limit memory usage
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3872
|
||||
return treq.content(response).addCallback(got_content)
|
||||
else:
|
||||
raise ClientException(-1, "Server didn't send CBOR")
|
||||
else:
|
||||
return treq.content(response).addCallback(
|
||||
lambda data: fail(ClientException(response.code, response.phrase, data))
|
||||
)
|
||||
def __call__(self, data: bytes):
|
||||
self.timeout_on_silence.reset(60)
|
||||
self.remaining_length -= len(data)
|
||||
if self.remaining_length < 0:
|
||||
raise ValueError("Response length was too long")
|
||||
self.f.write(data)
|
||||
|
||||
|
||||
def limited_content(
|
||||
response,
|
||||
clock: IReactorTime,
|
||||
max_length: int = 30 * 1024 * 1024,
|
||||
) -> Deferred[BinaryIO]:
|
||||
"""
|
||||
Like ``treq.content()``, but limit data read from the response to a set
|
||||
length. If the response is longer than the max allowed length, the result
|
||||
fails with a ``ValueError``.
|
||||
|
||||
A potentially useful future improvement would be using a temporary file to
|
||||
store the content; since filesystem buffering means that would use memory
|
||||
for small responses and disk for large responses.
|
||||
|
||||
This will time out if no data is received for 60 seconds; so long as a
|
||||
trickle of data continues to arrive, it will continue to run.
|
||||
"""
|
||||
d = succeed(None)
|
||||
timeout = clock.callLater(60, d.cancel)
|
||||
collector = _LengthLimitedCollector(max_length, timeout)
|
||||
|
||||
# Make really sure everything gets called in Deferred context, treq might
|
||||
# call collector directly...
|
||||
d.addCallback(lambda _: treq.collect(response, collector))
|
||||
|
||||
def done(_):
|
||||
timeout.cancel()
|
||||
collector.f.seek(0)
|
||||
return collector.f
|
||||
|
||||
def failed(f):
|
||||
if timeout.active():
|
||||
timeout.cancel()
|
||||
return f
|
||||
|
||||
return d.addCallbacks(done, failed)
|
||||
|
||||
|
||||
@define
|
||||
@ -229,42 +276,67 @@ class _StorageClientHTTPSPolicy:
|
||||
)
|
||||
|
||||
|
||||
@define
|
||||
@define(hash=True)
|
||||
class StorageClient(object):
|
||||
"""
|
||||
Low-level HTTP client that talks to the HTTP storage server.
|
||||
"""
|
||||
|
||||
# If set, we're doing unit testing and we should call this with
|
||||
# HTTPConnectionPool we create.
|
||||
TEST_MODE_REGISTER_HTTP_POOL = None
|
||||
|
||||
@classmethod
|
||||
def start_test_mode(cls, callback):
|
||||
"""Switch to testing mode.
|
||||
|
||||
In testing mode we register the pool with test system using the given
|
||||
callback so it can Do Things, most notably killing off idle HTTP
|
||||
connections at test shutdown and, in some tests, in the midddle of the
|
||||
test.
|
||||
"""
|
||||
cls.TEST_MODE_REGISTER_HTTP_POOL = callback
|
||||
|
||||
@classmethod
|
||||
def stop_test_mode(cls):
|
||||
"""Stop testing mode."""
|
||||
cls.TEST_MODE_REGISTER_HTTP_POOL = None
|
||||
|
||||
# The URL is a HTTPS URL ("https://..."). To construct from a NURL, use
|
||||
# ``StorageClient.from_nurl()``.
|
||||
_base_url: DecodedURL
|
||||
_swissnum: bytes
|
||||
_treq: Union[treq, StubTreq, HTTPClient]
|
||||
_clock: IReactorTime
|
||||
|
||||
@classmethod
|
||||
def from_nurl(
|
||||
cls, nurl: DecodedURL, reactor, persistent: bool = True
|
||||
cls,
|
||||
nurl: DecodedURL,
|
||||
reactor,
|
||||
) -> StorageClient:
|
||||
"""
|
||||
Create a ``StorageClient`` for the given NURL.
|
||||
|
||||
``persistent`` indicates whether to use persistent HTTP connections.
|
||||
"""
|
||||
assert nurl.fragment == "v=1"
|
||||
assert nurl.scheme == "pb"
|
||||
swissnum = nurl.path[0].encode("ascii")
|
||||
certificate_hash = nurl.user.encode("ascii")
|
||||
pool = HTTPConnectionPool(reactor)
|
||||
|
||||
if cls.TEST_MODE_REGISTER_HTTP_POOL is not None:
|
||||
cls.TEST_MODE_REGISTER_HTTP_POOL(pool)
|
||||
|
||||
treq_client = HTTPClient(
|
||||
Agent(
|
||||
reactor,
|
||||
_StorageClientHTTPSPolicy(expected_spki_hash=certificate_hash),
|
||||
pool=HTTPConnectionPool(reactor, persistent=persistent),
|
||||
pool=pool,
|
||||
)
|
||||
)
|
||||
|
||||
https_url = DecodedURL().replace(scheme="https", host=nurl.host, port=nurl.port)
|
||||
return cls(https_url, swissnum, treq_client)
|
||||
return cls(https_url, swissnum, treq_client, reactor)
|
||||
|
||||
def relative_url(self, path):
|
||||
"""Get a URL relative to the base URL."""
|
||||
@ -290,7 +362,8 @@ class StorageClient(object):
|
||||
write_enabler_secret=None,
|
||||
headers=None,
|
||||
message_to_serialize=None,
|
||||
**kwargs
|
||||
timeout: float = 60,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Like ``treq.request()``, but with optional secrets that get translated
|
||||
@ -298,6 +371,8 @@ class StorageClient(object):
|
||||
|
||||
If ``message_to_serialize`` is set, it will be serialized (by default
|
||||
with CBOR) and set as the request body.
|
||||
|
||||
Default timeout is 60 seconds.
|
||||
"""
|
||||
headers = self._get_headers(headers)
|
||||
|
||||
@ -329,27 +404,75 @@ class StorageClient(object):
|
||||
kwargs["data"] = dumps(message_to_serialize)
|
||||
headers.addRawHeader("Content-Type", CBOR_MIME_TYPE)
|
||||
|
||||
return self._treq.request(method, url, headers=headers, **kwargs)
|
||||
return self._treq.request(
|
||||
method, url, headers=headers, timeout=timeout, **kwargs
|
||||
)
|
||||
|
||||
def decode_cbor(self, response, schema: Schema):
|
||||
"""Given HTTP response, return decoded CBOR body."""
|
||||
|
||||
def got_content(f: BinaryIO):
|
||||
data = f.read()
|
||||
schema.validate_cbor(data)
|
||||
return loads(data)
|
||||
|
||||
if response.code > 199 and response.code < 300:
|
||||
content_type = get_content_type(response.headers)
|
||||
if content_type == CBOR_MIME_TYPE:
|
||||
return limited_content(response, self._clock).addCallback(got_content)
|
||||
else:
|
||||
raise ClientException(-1, "Server didn't send CBOR")
|
||||
else:
|
||||
return treq.content(response).addCallback(
|
||||
lambda data: fail(ClientException(response.code, response.phrase, data))
|
||||
)
|
||||
|
||||
|
||||
@define(hash=True)
|
||||
class StorageClientGeneral(object):
|
||||
"""
|
||||
High-level HTTP APIs that aren't immutable- or mutable-specific.
|
||||
"""
|
||||
|
||||
def __init__(self, client): # type: (StorageClient) -> None
|
||||
self._client = client
|
||||
_client: StorageClient
|
||||
|
||||
@inlineCallbacks
|
||||
def get_version(self):
|
||||
"""
|
||||
Return the version metadata for the server.
|
||||
"""
|
||||
url = self._client.relative_url("/v1/version")
|
||||
url = self._client.relative_url("/storage/v1/version")
|
||||
response = yield self._client.request("GET", url)
|
||||
decoded_response = yield _decode_cbor(response, _SCHEMAS["get_version"])
|
||||
decoded_response = yield self._client.decode_cbor(
|
||||
response, _SCHEMAS["get_version"]
|
||||
)
|
||||
returnValue(decoded_response)
|
||||
|
||||
@inlineCallbacks
|
||||
def add_or_renew_lease(
|
||||
self, storage_index: bytes, renew_secret: bytes, cancel_secret: bytes
|
||||
) -> Deferred[None]:
|
||||
"""
|
||||
Add or renew a lease.
|
||||
|
||||
If the renewal secret matches an existing lease, it is renewed.
|
||||
Otherwise a new lease is added.
|
||||
"""
|
||||
url = self._client.relative_url(
|
||||
"/storage/v1/lease/{}".format(_encode_si(storage_index))
|
||||
)
|
||||
response = yield self._client.request(
|
||||
"PUT",
|
||||
url,
|
||||
lease_renew_secret=renew_secret,
|
||||
lease_cancel_secret=cancel_secret,
|
||||
)
|
||||
|
||||
if response.code == http.NO_CONTENT:
|
||||
return
|
||||
else:
|
||||
raise ClientException(response.code)
|
||||
|
||||
|
||||
@define
|
||||
class UploadProgress(object):
|
||||
@ -375,35 +498,98 @@ def read_share_chunk(
|
||||
"""
|
||||
Download a chunk of data from a share.
|
||||
|
||||
TODO https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3857 Failed
|
||||
downloads should be transparently retried and redownloaded by the
|
||||
implementation a few times so that if a failure percolates up, the
|
||||
caller can assume the failure isn't a short-term blip.
|
||||
TODO https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3857 Failed downloads
|
||||
should be transparently retried and redownloaded by the implementation a
|
||||
few times so that if a failure percolates up, the caller can assume the
|
||||
failure isn't a short-term blip.
|
||||
|
||||
NOTE: the underlying HTTP protocol is much more flexible than this API,
|
||||
so a future refactor may expand this in order to simplify the calling
|
||||
code and perhaps download data more efficiently. But then again maybe
|
||||
the HTTP protocol will be simplified, see
|
||||
https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3777
|
||||
NOTE: the underlying HTTP protocol is somewhat more flexible than this API,
|
||||
insofar as it doesn't always require a range. In practice a range is
|
||||
always provided by the current callers.
|
||||
"""
|
||||
url = client.relative_url(
|
||||
"/v1/{}/{}/{}".format(share_type, _encode_si(storage_index), share_number)
|
||||
"/storage/v1/{}/{}/{}".format(
|
||||
share_type, _encode_si(storage_index), share_number
|
||||
)
|
||||
)
|
||||
# The default 60 second timeout is for getting the response, so it doesn't
|
||||
# include the time it takes to download the body... so we will will deal
|
||||
# with that later, via limited_content().
|
||||
response = yield client.request(
|
||||
"GET",
|
||||
url,
|
||||
headers=Headers(
|
||||
# Ranges in HTTP are _inclusive_, Python's convention is exclusive,
|
||||
# but Range constructor does that the conversion for us.
|
||||
{"range": [Range("bytes", [(offset, offset + length)]).to_header()]}
|
||||
),
|
||||
unbuffered=True, # Don't buffer the response in memory.
|
||||
)
|
||||
|
||||
if response.code == http.NO_CONTENT:
|
||||
return b""
|
||||
|
||||
if response.code == http.PARTIAL_CONTENT:
|
||||
body = yield response.content()
|
||||
returnValue(body)
|
||||
content_range = parse_content_range_header(
|
||||
response.headers.getRawHeaders("content-range")[0] or ""
|
||||
)
|
||||
if (
|
||||
content_range is None
|
||||
or content_range.stop is None
|
||||
or content_range.start is None
|
||||
):
|
||||
raise ValueError(
|
||||
"Content-Range was missing, invalid, or in format we don't support"
|
||||
)
|
||||
supposed_length = content_range.stop - content_range.start
|
||||
if supposed_length > length:
|
||||
raise ValueError("Server sent more than we asked for?!")
|
||||
# It might also send less than we asked for. That's (probably) OK, e.g.
|
||||
# if we went past the end of the file.
|
||||
body = yield limited_content(response, client._clock, supposed_length)
|
||||
body.seek(0, SEEK_END)
|
||||
actual_length = body.tell()
|
||||
if actual_length != supposed_length:
|
||||
# Most likely a mutable that got changed out from under us, but
|
||||
# conceivably could be a bug...
|
||||
raise ValueError(
|
||||
f"Length of response sent from server ({actual_length}) "
|
||||
+ f"didn't match Content-Range header ({supposed_length})"
|
||||
)
|
||||
body.seek(0)
|
||||
return body.read()
|
||||
else:
|
||||
# Technically HTTP allows sending an OK with full body under these
|
||||
# circumstances, but the server is not designed to do that so we ignore
|
||||
# that possibility for now...
|
||||
raise ClientException(response.code)
|
||||
|
||||
|
||||
@define
|
||||
@async_to_deferred
|
||||
async def advise_corrupt_share(
|
||||
client: StorageClient,
|
||||
share_type: str,
|
||||
storage_index: bytes,
|
||||
share_number: int,
|
||||
reason: str,
|
||||
):
|
||||
assert isinstance(reason, str)
|
||||
url = client.relative_url(
|
||||
"/storage/v1/{}/{}/{}/corrupt".format(
|
||||
share_type, _encode_si(storage_index), share_number
|
||||
)
|
||||
)
|
||||
message = {"reason": reason}
|
||||
response = await client.request("POST", url, message_to_serialize=message)
|
||||
if response.code == http.OK:
|
||||
return
|
||||
else:
|
||||
raise ClientException(
|
||||
response.code,
|
||||
)
|
||||
|
||||
|
||||
@define(hash=True)
|
||||
class StorageClientImmutables(object):
|
||||
"""
|
||||
APIs for interacting with immutables.
|
||||
@ -434,7 +620,9 @@ class StorageClientImmutables(object):
|
||||
Result fires when creating the storage index succeeded, if creating the
|
||||
storage index failed the result will fire with an exception.
|
||||
"""
|
||||
url = self._client.relative_url("/v1/immutable/" + _encode_si(storage_index))
|
||||
url = self._client.relative_url(
|
||||
"/storage/v1/immutable/" + _encode_si(storage_index)
|
||||
)
|
||||
message = {"share-numbers": share_numbers, "allocated-size": allocated_size}
|
||||
|
||||
response = yield self._client.request(
|
||||
@ -445,7 +633,9 @@ class StorageClientImmutables(object):
|
||||
upload_secret=upload_secret,
|
||||
message_to_serialize=message,
|
||||
)
|
||||
decoded_response = yield _decode_cbor(response, _SCHEMAS["allocate_buckets"])
|
||||
decoded_response = yield self._client.decode_cbor(
|
||||
response, _SCHEMAS["allocate_buckets"]
|
||||
)
|
||||
returnValue(
|
||||
ImmutableCreateResult(
|
||||
already_have=decoded_response["already-have"],
|
||||
@ -459,7 +649,9 @@ class StorageClientImmutables(object):
|
||||
) -> Deferred[None]:
|
||||
"""Abort the upload."""
|
||||
url = self._client.relative_url(
|
||||
"/v1/immutable/{}/{}/abort".format(_encode_si(storage_index), share_number)
|
||||
"/storage/v1/immutable/{}/{}/abort".format(
|
||||
_encode_si(storage_index), share_number
|
||||
)
|
||||
)
|
||||
response = yield self._client.request(
|
||||
"PUT",
|
||||
@ -491,7 +683,9 @@ class StorageClientImmutables(object):
|
||||
been uploaded.
|
||||
"""
|
||||
url = self._client.relative_url(
|
||||
"/v1/immutable/{}/{}".format(_encode_si(storage_index), share_number)
|
||||
"/storage/v1/immutable/{}/{}".format(
|
||||
_encode_si(storage_index), share_number
|
||||
)
|
||||
)
|
||||
response = yield self._client.request(
|
||||
"PATCH",
|
||||
@ -517,7 +711,9 @@ class StorageClientImmutables(object):
|
||||
raise ClientException(
|
||||
response.code,
|
||||
)
|
||||
body = yield _decode_cbor(response, _SCHEMAS["immutable_write_share_chunk"])
|
||||
body = yield self._client.decode_cbor(
|
||||
response, _SCHEMAS["immutable_write_share_chunk"]
|
||||
)
|
||||
remaining = RangeMap()
|
||||
for chunk in body["required"]:
|
||||
remaining.set(True, chunk["begin"], chunk["end"])
|
||||
@ -534,49 +730,23 @@ class StorageClientImmutables(object):
|
||||
)
|
||||
|
||||
@inlineCallbacks
|
||||
def list_shares(self, storage_index): # type: (bytes,) -> Deferred[set[int]]
|
||||
def list_shares(self, storage_index: bytes) -> Deferred[set[int]]:
|
||||
"""
|
||||
Return the set of shares for a given storage index.
|
||||
"""
|
||||
url = self._client.relative_url(
|
||||
"/v1/immutable/{}/shares".format(_encode_si(storage_index))
|
||||
"/storage/v1/immutable/{}/shares".format(_encode_si(storage_index))
|
||||
)
|
||||
response = yield self._client.request(
|
||||
"GET",
|
||||
url,
|
||||
)
|
||||
if response.code == http.OK:
|
||||
body = yield _decode_cbor(response, _SCHEMAS["list_shares"])
|
||||
body = yield self._client.decode_cbor(response, _SCHEMAS["list_shares"])
|
||||
returnValue(set(body))
|
||||
else:
|
||||
raise ClientException(response.code)
|
||||
|
||||
@inlineCallbacks
|
||||
def add_or_renew_lease(
|
||||
self, storage_index: bytes, renew_secret: bytes, cancel_secret: bytes
|
||||
):
|
||||
"""
|
||||
Add or renew a lease.
|
||||
|
||||
If the renewal secret matches an existing lease, it is renewed.
|
||||
Otherwise a new lease is added.
|
||||
"""
|
||||
url = self._client.relative_url(
|
||||
"/v1/lease/{}".format(_encode_si(storage_index))
|
||||
)
|
||||
response = yield self._client.request(
|
||||
"PUT",
|
||||
url,
|
||||
lease_renew_secret=renew_secret,
|
||||
lease_cancel_secret=cancel_secret,
|
||||
)
|
||||
|
||||
if response.code == http.NO_CONTENT:
|
||||
return
|
||||
else:
|
||||
raise ClientException(response.code)
|
||||
|
||||
@inlineCallbacks
|
||||
def advise_corrupt_share(
|
||||
self,
|
||||
storage_index: bytes,
|
||||
@ -584,20 +754,9 @@ class StorageClientImmutables(object):
|
||||
reason: str,
|
||||
):
|
||||
"""Indicate a share has been corrupted, with a human-readable message."""
|
||||
assert isinstance(reason, str)
|
||||
url = self._client.relative_url(
|
||||
"/v1/immutable/{}/{}/corrupt".format(
|
||||
_encode_si(storage_index), share_number
|
||||
)
|
||||
return advise_corrupt_share(
|
||||
self._client, "immutable", storage_index, share_number, reason
|
||||
)
|
||||
message = {"reason": reason}
|
||||
response = yield self._client.request("POST", url, message_to_serialize=message)
|
||||
if response.code == http.OK:
|
||||
return
|
||||
else:
|
||||
raise ClientException(
|
||||
response.code,
|
||||
)
|
||||
|
||||
|
||||
@frozen
|
||||
@ -631,8 +790,8 @@ class ReadVector:
|
||||
class TestWriteVectors:
|
||||
"""Test and write vectors for a specific share."""
|
||||
|
||||
test_vectors: Sequence[TestVector]
|
||||
write_vectors: Sequence[WriteVector]
|
||||
test_vectors: Sequence[TestVector] = field(factory=list)
|
||||
write_vectors: Sequence[WriteVector] = field(factory=list)
|
||||
new_length: Optional[int] = None
|
||||
|
||||
def asdict(self) -> dict:
|
||||
@ -681,9 +840,8 @@ class StorageClientMutables:
|
||||
Given a mapping between share numbers and test/write vectors, the tests
|
||||
are done and if they are valid the writes are done.
|
||||
"""
|
||||
# TODO unit test all the things
|
||||
url = self._client.relative_url(
|
||||
"/v1/mutable/{}/read-test-write".format(_encode_si(storage_index))
|
||||
"/storage/v1/mutable/{}/read-test-write".format(_encode_si(storage_index))
|
||||
)
|
||||
message = {
|
||||
"test-write-vectors": {
|
||||
@ -701,7 +859,9 @@ class StorageClientMutables:
|
||||
message_to_serialize=message,
|
||||
)
|
||||
if response.code == http.OK:
|
||||
result = await _decode_cbor(response, _SCHEMAS["mutable_read_test_write"])
|
||||
result = await self._client.decode_cbor(
|
||||
response, _SCHEMAS["mutable_read_test_write"]
|
||||
)
|
||||
return ReadTestWriteResult(success=result["success"], reads=result["data"])
|
||||
else:
|
||||
raise ClientException(response.code, (await response.content()))
|
||||
@ -712,11 +872,37 @@ class StorageClientMutables:
|
||||
share_number: int,
|
||||
offset: int,
|
||||
length: int,
|
||||
) -> bytes:
|
||||
) -> Deferred[bytes]:
|
||||
"""
|
||||
Download a chunk of data from a share.
|
||||
"""
|
||||
# TODO unit test all the things
|
||||
return read_share_chunk(
|
||||
self._client, "mutable", storage_index, share_number, offset, length
|
||||
)
|
||||
|
||||
@async_to_deferred
|
||||
async def list_shares(self, storage_index: bytes) -> set[int]:
|
||||
"""
|
||||
List the share numbers for a given storage index.
|
||||
"""
|
||||
url = self._client.relative_url(
|
||||
"/storage/v1/mutable/{}/shares".format(_encode_si(storage_index))
|
||||
)
|
||||
response = await self._client.request("GET", url)
|
||||
if response.code == http.OK:
|
||||
return await self._client.decode_cbor(
|
||||
response, _SCHEMAS["mutable_list_shares"]
|
||||
)
|
||||
else:
|
||||
raise ClientException(response.code)
|
||||
|
||||
def advise_corrupt_share(
|
||||
self,
|
||||
storage_index: bytes,
|
||||
share_number: int,
|
||||
reason: str,
|
||||
):
|
||||
"""Indicate a share has been corrupted, with a human-readable message."""
|
||||
return advise_corrupt_share(
|
||||
self._client, "mutable", storage_index, share_number, reason
|
||||
)
|
||||
|
@ -2,23 +2,31 @@
|
||||
HTTP server for storage.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Set, Tuple, Any
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Set, Tuple, Any, Callable, Union, cast
|
||||
from functools import wraps
|
||||
from base64 import b64decode
|
||||
import binascii
|
||||
from tempfile import TemporaryFile
|
||||
|
||||
from cryptography.x509 import Certificate as CryptoCertificate
|
||||
from zope.interface import implementer
|
||||
from klein import Klein
|
||||
from twisted.web import http
|
||||
from twisted.internet.interfaces import IListeningPort, IStreamServerEndpoint
|
||||
from twisted.internet.interfaces import (
|
||||
IListeningPort,
|
||||
IStreamServerEndpoint,
|
||||
IPullProducer,
|
||||
)
|
||||
from twisted.internet.address import IPv4Address, IPv6Address
|
||||
from twisted.internet.defer import Deferred
|
||||
from twisted.internet.ssl import CertificateOptions, Certificate, PrivateCertificate
|
||||
from twisted.web.server import Site
|
||||
from twisted.web.server import Site, Request
|
||||
from twisted.protocols.tls import TLSMemoryBIOFactory
|
||||
from twisted.python.filepath import FilePath
|
||||
|
||||
import attr
|
||||
from attrs import define, field, Factory
|
||||
from werkzeug.http import (
|
||||
parse_range_header,
|
||||
parse_content_range_header,
|
||||
@ -31,7 +39,7 @@ from cryptography.x509 import load_pem_x509_certificate
|
||||
|
||||
|
||||
# TODO Make sure to use pure Python versions?
|
||||
from cbor2 import dumps, loads
|
||||
from cbor2 import dump, loads
|
||||
from pycddl import Schema, ValidationError as CDDLValidationError
|
||||
from .server import StorageServer
|
||||
from .http_common import (
|
||||
@ -46,6 +54,7 @@ from .common import si_a2b
|
||||
from .immutable import BucketWriter, ConflictingWriteError
|
||||
from ..util.hashutil import timing_safe_compare
|
||||
from ..util.base32 import rfc3548_alphabet
|
||||
from allmydata.interfaces import BadWriteEnablerError
|
||||
|
||||
|
||||
class ClientSecretsException(Exception):
|
||||
@ -135,31 +144,31 @@ def _authorized_route(app, required_secrets, *route_args, **route_kwargs):
|
||||
return decorator
|
||||
|
||||
|
||||
@attr.s
|
||||
@define
|
||||
class StorageIndexUploads(object):
|
||||
"""
|
||||
In-progress upload to storage index.
|
||||
"""
|
||||
|
||||
# Map share number to BucketWriter
|
||||
shares = attr.ib(factory=dict) # type: Dict[int,BucketWriter]
|
||||
shares: dict[int, BucketWriter] = Factory(dict)
|
||||
|
||||
# Map share number to the upload secret (different shares might have
|
||||
# different upload secrets).
|
||||
upload_secrets = attr.ib(factory=dict) # type: Dict[int,bytes]
|
||||
upload_secrets: dict[int, bytes] = Factory(dict)
|
||||
|
||||
|
||||
@attr.s
|
||||
@define
|
||||
class UploadsInProgress(object):
|
||||
"""
|
||||
Keep track of uploads for storage indexes.
|
||||
"""
|
||||
|
||||
# Map storage index to corresponding uploads-in-progress
|
||||
_uploads = attr.ib(type=Dict[bytes, StorageIndexUploads], factory=dict)
|
||||
_uploads: dict[bytes, StorageIndexUploads] = Factory(dict)
|
||||
|
||||
# Map BucketWriter to (storage index, share number)
|
||||
_bucketwriters = attr.ib(type=Dict[BucketWriter, Tuple[bytes, int]], factory=dict)
|
||||
_bucketwriters: dict[BucketWriter, Tuple[bytes, int]] = Factory(dict)
|
||||
|
||||
def add_write_bucket(
|
||||
self,
|
||||
@ -186,7 +195,12 @@ class UploadsInProgress(object):
|
||||
|
||||
def remove_write_bucket(self, bucket: BucketWriter):
|
||||
"""Stop tracking the given ``BucketWriter``."""
|
||||
storage_index, share_number = self._bucketwriters.pop(bucket)
|
||||
try:
|
||||
storage_index, share_number = self._bucketwriters.pop(bucket)
|
||||
except KeyError:
|
||||
# This is probably a BucketWriter created by Foolscap, so just
|
||||
# ignore it.
|
||||
return
|
||||
uploads_index = self._uploads[storage_index]
|
||||
uploads_index.shares.pop(share_number)
|
||||
uploads_index.upload_secrets.pop(share_number)
|
||||
@ -238,11 +252,15 @@ class _HTTPError(Exception):
|
||||
# Tags are of the form #6.nnn, where the number is documented at
|
||||
# https://www.iana.org/assignments/cbor-tags/cbor-tags.xhtml. Notably, #6.258
|
||||
# indicates a set.
|
||||
#
|
||||
# Somewhat arbitrary limits are set to reduce e.g. number of shares, number of
|
||||
# vectors, etc.. These may need to be iterated on in future revisions of the
|
||||
# code.
|
||||
_SCHEMAS = {
|
||||
"allocate_buckets": Schema(
|
||||
"""
|
||||
request = {
|
||||
share-numbers: #6.258([* uint])
|
||||
share-numbers: #6.258([0*256 uint])
|
||||
allocated-size: uint
|
||||
}
|
||||
"""
|
||||
@ -258,13 +276,13 @@ _SCHEMAS = {
|
||||
"""
|
||||
request = {
|
||||
"test-write-vectors": {
|
||||
* share_number: {
|
||||
"test": [* {"offset": uint, "size": uint, "specimen": bstr}]
|
||||
"write": [* {"offset": uint, "data": bstr}]
|
||||
"new-length": uint // null
|
||||
0*256 share_number : {
|
||||
"test": [0*30 {"offset": uint, "size": uint, "specimen": bstr}]
|
||||
"write": [0*30 {"offset": uint, "data": bstr}]
|
||||
"new-length": uint / null
|
||||
}
|
||||
}
|
||||
"read-vector": [* {"offset": uint, "size": uint}]
|
||||
"read-vector": [0*30 {"offset": uint, "size": uint}]
|
||||
}
|
||||
share_number = uint
|
||||
"""
|
||||
@ -272,6 +290,179 @@ _SCHEMAS = {
|
||||
}
|
||||
|
||||
|
||||
# Callable that takes offset and length, returns the data at that range.
|
||||
ReadData = Callable[[int, int], bytes]
|
||||
|
||||
|
||||
@implementer(IPullProducer)
|
||||
@define
|
||||
class _ReadAllProducer:
|
||||
"""
|
||||
Producer that calls a read function repeatedly to read all the data, and
|
||||
writes to a request.
|
||||
"""
|
||||
|
||||
request: Request
|
||||
read_data: ReadData
|
||||
result: Deferred = Factory(Deferred)
|
||||
start: int = field(default=0)
|
||||
|
||||
@classmethod
|
||||
def produce_to(cls, request: Request, read_data: ReadData) -> Deferred:
|
||||
"""
|
||||
Create and register the producer, returning ``Deferred`` that should be
|
||||
returned from a HTTP server endpoint.
|
||||
"""
|
||||
producer = cls(request, read_data)
|
||||
request.registerProducer(producer, False)
|
||||
return producer.result
|
||||
|
||||
def resumeProducing(self):
|
||||
data = self.read_data(self.start, 65536)
|
||||
if not data:
|
||||
self.request.unregisterProducer()
|
||||
d = self.result
|
||||
del self.result
|
||||
d.callback(b"")
|
||||
return
|
||||
self.request.write(data)
|
||||
self.start += len(data)
|
||||
|
||||
def pauseProducing(self):
|
||||
pass
|
||||
|
||||
def stopProducing(self):
|
||||
pass
|
||||
|
||||
|
||||
@implementer(IPullProducer)
|
||||
@define
|
||||
class _ReadRangeProducer:
|
||||
"""
|
||||
Producer that calls a read function to read a range of data, and writes to
|
||||
a request.
|
||||
"""
|
||||
|
||||
request: Request
|
||||
read_data: ReadData
|
||||
result: Deferred
|
||||
start: int
|
||||
remaining: int
|
||||
|
||||
def resumeProducing(self):
|
||||
to_read = min(self.remaining, 65536)
|
||||
data = self.read_data(self.start, to_read)
|
||||
assert len(data) <= to_read
|
||||
|
||||
if not data and self.remaining > 0:
|
||||
d, self.result = self.result, None
|
||||
d.errback(
|
||||
ValueError(
|
||||
f"Should be {self.remaining} bytes left, but we got an empty read"
|
||||
)
|
||||
)
|
||||
self.stopProducing()
|
||||
return
|
||||
|
||||
if len(data) > self.remaining:
|
||||
d, self.result = self.result, None
|
||||
d.errback(
|
||||
ValueError(
|
||||
f"Should be {self.remaining} bytes left, but we got more than that ({len(data)})!"
|
||||
)
|
||||
)
|
||||
self.stopProducing()
|
||||
return
|
||||
|
||||
self.start += len(data)
|
||||
self.remaining -= len(data)
|
||||
assert self.remaining >= 0
|
||||
|
||||
self.request.write(data)
|
||||
|
||||
if self.remaining == 0:
|
||||
self.stopProducing()
|
||||
|
||||
def pauseProducing(self):
|
||||
pass
|
||||
|
||||
def stopProducing(self):
|
||||
if self.request is not None:
|
||||
self.request.unregisterProducer()
|
||||
self.request = None
|
||||
if self.result is not None:
|
||||
d = self.result
|
||||
self.result = None
|
||||
d.callback(b"")
|
||||
|
||||
|
||||
def read_range(
|
||||
request: Request, read_data: ReadData, share_length: int
|
||||
) -> Union[Deferred, bytes]:
|
||||
"""
|
||||
Read an optional ``Range`` header, reads data appropriately via the given
|
||||
callable, writes the data to the request.
|
||||
|
||||
Only parses a subset of ``Range`` headers that we support: must be set,
|
||||
bytes only, only a single range, the end must be explicitly specified.
|
||||
Raises a ``_HTTPError(http.REQUESTED_RANGE_NOT_SATISFIABLE)`` if parsing is
|
||||
not possible or the header isn't set.
|
||||
|
||||
Takes a function that will do the actual reading given the start offset and
|
||||
a length to read.
|
||||
|
||||
The resulting data is written to the request.
|
||||
"""
|
||||
|
||||
def read_data_with_error_handling(offset: int, length: int) -> bytes:
|
||||
try:
|
||||
return read_data(offset, length)
|
||||
except _HTTPError as e:
|
||||
request.setResponseCode(e.code)
|
||||
# Empty read means we're done.
|
||||
return b""
|
||||
|
||||
if request.getHeader("range") is None:
|
||||
return _ReadAllProducer.produce_to(request, read_data_with_error_handling)
|
||||
|
||||
range_header = parse_range_header(request.getHeader("range"))
|
||||
if (
|
||||
range_header is None # failed to parse
|
||||
or range_header.units != "bytes"
|
||||
or len(range_header.ranges) > 1 # more than one range
|
||||
or range_header.ranges[0][1] is None # range without end
|
||||
):
|
||||
raise _HTTPError(http.REQUESTED_RANGE_NOT_SATISFIABLE)
|
||||
|
||||
offset, end = range_header.ranges[0]
|
||||
# If we're being ask to read beyond the length of the share, just read
|
||||
# less:
|
||||
end = min(end, share_length)
|
||||
if offset >= end:
|
||||
# Basically we'd need to return an empty body. However, the
|
||||
# Content-Range header can't actually represent empty lengths... so
|
||||
# (mis)use 204 response code to indicate that.
|
||||
raise _HTTPError(http.NO_CONTENT)
|
||||
|
||||
request.setResponseCode(http.PARTIAL_CONTENT)
|
||||
|
||||
# Actual conversion from Python's exclusive ranges to inclusive ranges is
|
||||
# handled by werkzeug.
|
||||
request.setHeader(
|
||||
"content-range",
|
||||
ContentRange("bytes", offset, end).to_header(),
|
||||
)
|
||||
|
||||
d = Deferred()
|
||||
request.registerProducer(
|
||||
_ReadRangeProducer(
|
||||
request, read_data_with_error_handling, d, offset, end - offset
|
||||
),
|
||||
False,
|
||||
)
|
||||
return d
|
||||
|
||||
|
||||
class HTTPServer(object):
|
||||
"""
|
||||
A HTTP interface to the storage server.
|
||||
@ -323,9 +514,14 @@ class HTTPServer(object):
|
||||
accept = parse_accept_header(accept_headers[0])
|
||||
if accept.best == CBOR_MIME_TYPE:
|
||||
request.setHeader("Content-Type", CBOR_MIME_TYPE)
|
||||
# TODO if data is big, maybe want to use a temporary file eventually...
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3872
|
||||
return dumps(data)
|
||||
f = TemporaryFile()
|
||||
dump(data, f)
|
||||
|
||||
def read_data(offset: int, length: int) -> bytes:
|
||||
f.seek(offset)
|
||||
return f.read(length)
|
||||
|
||||
return _ReadAllProducer.produce_to(request, read_data)
|
||||
else:
|
||||
# TODO Might want to optionally send JSON someday:
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3861
|
||||
@ -334,12 +530,18 @@ class HTTPServer(object):
|
||||
def _read_encoded(self, request, schema: Schema) -> Any:
|
||||
"""
|
||||
Read encoded request body data, decoding it with CBOR by default.
|
||||
|
||||
Somewhat arbitrarily, limit body size to 1MB; this may be too low, we
|
||||
may want to customize per query type, but this is the starting point
|
||||
for now.
|
||||
"""
|
||||
content_type = get_content_type(request.requestHeaders)
|
||||
if content_type == CBOR_MIME_TYPE:
|
||||
# TODO limit memory usage, client could send arbitrarily large data...
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3872
|
||||
message = request.content.read()
|
||||
# Read 1 byte more than 1MB. We expect length to be 1MB or
|
||||
# less; if it's more assume it's not a legitimate message.
|
||||
message = request.content.read(1024 * 1024 + 1)
|
||||
if len(message) > 1024 * 1024:
|
||||
raise _HTTPError(http.REQUEST_ENTITY_TOO_LARGE)
|
||||
schema.validate_cbor(message)
|
||||
result = loads(message)
|
||||
return result
|
||||
@ -348,7 +550,7 @@ class HTTPServer(object):
|
||||
|
||||
##### Generic APIs #####
|
||||
|
||||
@_authorized_route(_app, set(), "/v1/version", methods=["GET"])
|
||||
@_authorized_route(_app, set(), "/storage/v1/version", methods=["GET"])
|
||||
def version(self, request, authorization):
|
||||
"""Return version information."""
|
||||
return self._send_encoded(request, self._storage_server.get_version())
|
||||
@ -358,7 +560,7 @@ class HTTPServer(object):
|
||||
@_authorized_route(
|
||||
_app,
|
||||
{Secrets.LEASE_RENEW, Secrets.LEASE_CANCEL, Secrets.UPLOAD},
|
||||
"/v1/immutable/<storage_index:storage_index>",
|
||||
"/storage/v1/immutable/<storage_index:storage_index>",
|
||||
methods=["POST"],
|
||||
)
|
||||
def allocate_buckets(self, request, authorization, storage_index):
|
||||
@ -388,16 +590,13 @@ class HTTPServer(object):
|
||||
|
||||
return self._send_encoded(
|
||||
request,
|
||||
{
|
||||
"already-have": set(already_got),
|
||||
"allocated": set(sharenum_to_bucket),
|
||||
},
|
||||
{"already-have": set(already_got), "allocated": set(sharenum_to_bucket)},
|
||||
)
|
||||
|
||||
@_authorized_route(
|
||||
_app,
|
||||
{Secrets.UPLOAD},
|
||||
"/v1/immutable/<storage_index:storage_index>/<int(signed=False):share_number>/abort",
|
||||
"/storage/v1/immutable/<storage_index:storage_index>/<int(signed=False):share_number>/abort",
|
||||
methods=["PUT"],
|
||||
)
|
||||
def abort_share_upload(self, request, authorization, storage_index, share_number):
|
||||
@ -428,7 +627,7 @@ class HTTPServer(object):
|
||||
@_authorized_route(
|
||||
_app,
|
||||
{Secrets.UPLOAD},
|
||||
"/v1/immutable/<storage_index:storage_index>/<int(signed=False):share_number>",
|
||||
"/storage/v1/immutable/<storage_index:storage_index>/<int(signed=False):share_number>",
|
||||
methods=["PATCH"],
|
||||
)
|
||||
def write_share_data(self, request, authorization, storage_index, share_number):
|
||||
@ -438,20 +637,24 @@ class HTTPServer(object):
|
||||
request.setResponseCode(http.REQUESTED_RANGE_NOT_SATISFIABLE)
|
||||
return b""
|
||||
|
||||
offset = content_range.start
|
||||
|
||||
# TODO limit memory usage
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3872
|
||||
data = request.content.read(content_range.stop - content_range.start + 1)
|
||||
bucket = self._uploads.get_write_bucket(
|
||||
storage_index, share_number, authorization[Secrets.UPLOAD]
|
||||
)
|
||||
offset = content_range.start
|
||||
remaining = content_range.stop - content_range.start
|
||||
finished = False
|
||||
|
||||
try:
|
||||
finished = bucket.write(offset, data)
|
||||
except ConflictingWriteError:
|
||||
request.setResponseCode(http.CONFLICT)
|
||||
return b""
|
||||
while remaining > 0:
|
||||
data = request.content.read(min(remaining, 65536))
|
||||
assert data, "uploaded data length doesn't match range"
|
||||
|
||||
try:
|
||||
finished = bucket.write(offset, data)
|
||||
except ConflictingWriteError:
|
||||
request.setResponseCode(http.CONFLICT)
|
||||
return b""
|
||||
remaining -= len(data)
|
||||
offset += len(data)
|
||||
|
||||
if finished:
|
||||
bucket.close()
|
||||
@ -467,7 +670,7 @@ class HTTPServer(object):
|
||||
@_authorized_route(
|
||||
_app,
|
||||
set(),
|
||||
"/v1/immutable/<storage_index:storage_index>/shares",
|
||||
"/storage/v1/immutable/<storage_index:storage_index>/shares",
|
||||
methods=["GET"],
|
||||
)
|
||||
def list_shares(self, request, authorization, storage_index):
|
||||
@ -480,7 +683,7 @@ class HTTPServer(object):
|
||||
@_authorized_route(
|
||||
_app,
|
||||
set(),
|
||||
"/v1/immutable/<storage_index:storage_index>/<int(signed=False):share_number>",
|
||||
"/storage/v1/immutable/<storage_index:storage_index>/<int(signed=False):share_number>",
|
||||
methods=["GET"],
|
||||
)
|
||||
def read_share_chunk(self, request, authorization, storage_index, share_number):
|
||||
@ -491,54 +694,17 @@ class HTTPServer(object):
|
||||
request.setResponseCode(http.NOT_FOUND)
|
||||
return b""
|
||||
|
||||
if request.getHeader("range") is None:
|
||||
# Return the whole thing.
|
||||
start = 0
|
||||
while True:
|
||||
# TODO should probably yield to event loop occasionally...
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3872
|
||||
data = bucket.read(start, start + 65536)
|
||||
if not data:
|
||||
request.finish()
|
||||
return
|
||||
request.write(data)
|
||||
start += len(data)
|
||||
|
||||
range_header = parse_range_header(request.getHeader("range"))
|
||||
if (
|
||||
range_header is None
|
||||
or range_header.units != "bytes"
|
||||
or len(range_header.ranges) > 1 # more than one range
|
||||
or range_header.ranges[0][1] is None # range without end
|
||||
):
|
||||
request.setResponseCode(http.REQUESTED_RANGE_NOT_SATISFIABLE)
|
||||
return b""
|
||||
|
||||
offset, end = range_header.ranges[0]
|
||||
|
||||
# TODO limit memory usage
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3872
|
||||
data = bucket.read(offset, end - offset)
|
||||
|
||||
request.setResponseCode(http.PARTIAL_CONTENT)
|
||||
if len(data):
|
||||
# For empty bodies the content-range header makes no sense since
|
||||
# the end of the range is inclusive.
|
||||
request.setHeader(
|
||||
"content-range",
|
||||
ContentRange("bytes", offset, offset + len(data)).to_header(),
|
||||
)
|
||||
return data
|
||||
return read_range(request, bucket.read, bucket.get_length())
|
||||
|
||||
@_authorized_route(
|
||||
_app,
|
||||
{Secrets.LEASE_RENEW, Secrets.LEASE_CANCEL},
|
||||
"/v1/lease/<storage_index:storage_index>",
|
||||
"/storage/v1/lease/<storage_index:storage_index>",
|
||||
methods=["PUT"],
|
||||
)
|
||||
def add_or_renew_lease(self, request, authorization, storage_index):
|
||||
"""Update the lease for an immutable share."""
|
||||
if not self._storage_server.get_buckets(storage_index):
|
||||
"""Update the lease for an immutable or mutable share."""
|
||||
if not list(self._storage_server.get_shares(storage_index)):
|
||||
raise _HTTPError(http.NOT_FOUND)
|
||||
|
||||
# Checking of the renewal secret is done by the backend.
|
||||
@ -554,7 +720,7 @@ class HTTPServer(object):
|
||||
@_authorized_route(
|
||||
_app,
|
||||
set(),
|
||||
"/v1/immutable/<storage_index:storage_index>/<int(signed=False):share_number>/corrupt",
|
||||
"/storage/v1/immutable/<storage_index:storage_index>/<int(signed=False):share_number>/corrupt",
|
||||
methods=["POST"],
|
||||
)
|
||||
def advise_corrupt_share_immutable(
|
||||
@ -575,79 +741,99 @@ class HTTPServer(object):
|
||||
@_authorized_route(
|
||||
_app,
|
||||
{Secrets.LEASE_RENEW, Secrets.LEASE_CANCEL, Secrets.WRITE_ENABLER},
|
||||
"/v1/mutable/<storage_index:storage_index>/read-test-write",
|
||||
"/storage/v1/mutable/<storage_index:storage_index>/read-test-write",
|
||||
methods=["POST"],
|
||||
)
|
||||
def mutable_read_test_write(self, request, authorization, storage_index):
|
||||
"""Read/test/write combined operation for mutables."""
|
||||
# TODO unit tests
|
||||
rtw_request = self._read_encoded(request, _SCHEMAS["mutable_read_test_write"])
|
||||
secrets = (
|
||||
authorization[Secrets.WRITE_ENABLER],
|
||||
authorization[Secrets.LEASE_RENEW],
|
||||
authorization[Secrets.LEASE_CANCEL],
|
||||
)
|
||||
success, read_data = self._storage_server.slot_testv_and_readv_and_writev(
|
||||
storage_index,
|
||||
secrets,
|
||||
{
|
||||
k: (
|
||||
[(d["offset"], d["size"], b"eq", d["specimen"]) for d in v["test"]],
|
||||
[(d["offset"], d["data"]) for d in v["write"]],
|
||||
v["new-length"],
|
||||
)
|
||||
for (k, v) in rtw_request["test-write-vectors"].items()
|
||||
},
|
||||
[(d["offset"], d["size"]) for d in rtw_request["read-vector"]],
|
||||
)
|
||||
try:
|
||||
success, read_data = self._storage_server.slot_testv_and_readv_and_writev(
|
||||
storage_index,
|
||||
secrets,
|
||||
{
|
||||
k: (
|
||||
[
|
||||
(d["offset"], d["size"], b"eq", d["specimen"])
|
||||
for d in v["test"]
|
||||
],
|
||||
[(d["offset"], d["data"]) for d in v["write"]],
|
||||
v["new-length"],
|
||||
)
|
||||
for (k, v) in rtw_request["test-write-vectors"].items()
|
||||
},
|
||||
[(d["offset"], d["size"]) for d in rtw_request["read-vector"]],
|
||||
)
|
||||
except BadWriteEnablerError:
|
||||
raise _HTTPError(http.UNAUTHORIZED)
|
||||
return self._send_encoded(request, {"success": success, "data": read_data})
|
||||
|
||||
@_authorized_route(
|
||||
_app,
|
||||
set(),
|
||||
"/v1/mutable/<storage_index:storage_index>/<int(signed=False):share_number>",
|
||||
"/storage/v1/mutable/<storage_index:storage_index>/<int(signed=False):share_number>",
|
||||
methods=["GET"],
|
||||
)
|
||||
def read_mutable_chunk(self, request, authorization, storage_index, share_number):
|
||||
"""Read a chunk from a mutable."""
|
||||
if request.getHeader("range") is None:
|
||||
# TODO in follow-up ticket
|
||||
raise NotImplementedError()
|
||||
|
||||
# TODO reduce duplication with immutable reads?
|
||||
# TODO unit tests, perhaps shared if possible
|
||||
range_header = parse_range_header(request.getHeader("range"))
|
||||
if (
|
||||
range_header is None
|
||||
or range_header.units != "bytes"
|
||||
or len(range_header.ranges) > 1 # more than one range
|
||||
or range_header.ranges[0][1] is None # range without end
|
||||
):
|
||||
request.setResponseCode(http.REQUESTED_RANGE_NOT_SATISFIABLE)
|
||||
return b""
|
||||
|
||||
offset, end = range_header.ranges[0]
|
||||
|
||||
# TODO limit memory usage
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3872
|
||||
data = self._storage_server.slot_readv(
|
||||
storage_index, [share_number], [(offset, end - offset)]
|
||||
)[share_number][0]
|
||||
|
||||
# TODO reduce duplication?
|
||||
request.setResponseCode(http.PARTIAL_CONTENT)
|
||||
if len(data):
|
||||
# For empty bodies the content-range header makes no sense since
|
||||
# the end of the range is inclusive.
|
||||
request.setHeader(
|
||||
"content-range",
|
||||
ContentRange("bytes", offset, offset + len(data)).to_header(),
|
||||
try:
|
||||
share_length = self._storage_server.get_mutable_share_length(
|
||||
storage_index, share_number
|
||||
)
|
||||
return data
|
||||
except KeyError:
|
||||
raise _HTTPError(http.NOT_FOUND)
|
||||
|
||||
def read_data(offset, length):
|
||||
try:
|
||||
return self._storage_server.slot_readv(
|
||||
storage_index, [share_number], [(offset, length)]
|
||||
)[share_number][0]
|
||||
except KeyError:
|
||||
raise _HTTPError(http.NOT_FOUND)
|
||||
|
||||
return read_range(request, read_data, share_length)
|
||||
|
||||
@_authorized_route(
|
||||
_app,
|
||||
set(),
|
||||
"/storage/v1/mutable/<storage_index:storage_index>/shares",
|
||||
methods=["GET"],
|
||||
)
|
||||
def enumerate_mutable_shares(self, request, authorization, storage_index):
|
||||
"""List mutable shares for a storage index."""
|
||||
shares = self._storage_server.enumerate_mutable_shares(storage_index)
|
||||
return self._send_encoded(request, shares)
|
||||
|
||||
@_authorized_route(
|
||||
_app,
|
||||
set(),
|
||||
"/storage/v1/mutable/<storage_index:storage_index>/<int(signed=False):share_number>/corrupt",
|
||||
methods=["POST"],
|
||||
)
|
||||
def advise_corrupt_share_mutable(
|
||||
self, request, authorization, storage_index, share_number
|
||||
):
|
||||
"""Indicate that given share is corrupt, with a text reason."""
|
||||
if share_number not in {
|
||||
shnum for (shnum, _) in self._storage_server.get_shares(storage_index)
|
||||
}:
|
||||
raise _HTTPError(http.NOT_FOUND)
|
||||
|
||||
info = self._read_encoded(request, _SCHEMAS["advise_corrupt_share"])
|
||||
self._storage_server.advise_corrupt_share(
|
||||
b"mutable", storage_index, share_number, info["reason"].encode("utf-8")
|
||||
)
|
||||
return b""
|
||||
|
||||
|
||||
@implementer(IStreamServerEndpoint)
|
||||
@attr.s
|
||||
@define
|
||||
class _TLSEndpointWrapper(object):
|
||||
"""
|
||||
Wrap an existing endpoint with the server-side storage TLS policy. This is
|
||||
@ -655,8 +841,8 @@ class _TLSEndpointWrapper(object):
|
||||
example there's Tor and i2p.
|
||||
"""
|
||||
|
||||
endpoint = attr.ib(type=IStreamServerEndpoint)
|
||||
context_factory = attr.ib(type=CertificateOptions)
|
||||
endpoint: IStreamServerEndpoint
|
||||
context_factory: CertificateOptions
|
||||
|
||||
@classmethod
|
||||
def from_paths(
|
||||
@ -681,6 +867,29 @@ class _TLSEndpointWrapper(object):
|
||||
)
|
||||
|
||||
|
||||
def build_nurl(
|
||||
hostname: str, port: int, swissnum: str, certificate: CryptoCertificate
|
||||
) -> DecodedURL:
|
||||
"""
|
||||
Construct a HTTPS NURL, given the hostname, port, server swissnum, and x509
|
||||
certificate for the server. Clients can then connect to the server using
|
||||
this NURL.
|
||||
"""
|
||||
return DecodedURL().replace(
|
||||
fragment="v=1", # how we know this NURL is HTTP-based (i.e. not Foolscap)
|
||||
host=hostname,
|
||||
port=port,
|
||||
path=(swissnum,),
|
||||
userinfo=(
|
||||
str(
|
||||
get_spki_hash(certificate),
|
||||
"ascii",
|
||||
),
|
||||
),
|
||||
scheme="pb",
|
||||
)
|
||||
|
||||
|
||||
def listen_tls(
|
||||
server: HTTPServer,
|
||||
hostname: str,
|
||||
@ -700,22 +909,15 @@ def listen_tls(
|
||||
"""
|
||||
endpoint = _TLSEndpointWrapper.from_paths(endpoint, private_key_path, cert_path)
|
||||
|
||||
def build_nurl(listening_port: IListeningPort) -> DecodedURL:
|
||||
nurl = DecodedURL().replace(
|
||||
fragment="v=1", # how we know this NURL is HTTP-based (i.e. not Foolscap)
|
||||
host=hostname,
|
||||
port=listening_port.getHost().port,
|
||||
path=(str(server._swissnum, "ascii"),),
|
||||
userinfo=(
|
||||
str(
|
||||
get_spki_hash(load_pem_x509_certificate(cert_path.getContent())),
|
||||
"ascii",
|
||||
),
|
||||
),
|
||||
scheme="pb",
|
||||
def get_nurl(listening_port: IListeningPort) -> DecodedURL:
|
||||
address = cast(Union[IPv4Address, IPv6Address], listening_port.getHost())
|
||||
return build_nurl(
|
||||
hostname,
|
||||
address.port,
|
||||
str(server._swissnum, "ascii"),
|
||||
load_pem_x509_certificate(cert_path.getContent()),
|
||||
)
|
||||
return nurl
|
||||
|
||||
return endpoint.listen(Site(server.get_resource())).addCallback(
|
||||
lambda listening_port: (build_nurl(listening_port), listening_port)
|
||||
lambda listening_port: (get_nurl(listening_port), listening_port)
|
||||
)
|
||||
|
@ -199,8 +199,16 @@ class ShareFile(object):
|
||||
raise UnknownImmutableContainerVersionError(filename, version)
|
||||
self._num_leases = num_leases
|
||||
self._lease_offset = filesize - (num_leases * self.LEASE_SIZE)
|
||||
self._length = filesize - 0xc - (num_leases * self.LEASE_SIZE)
|
||||
|
||||
self._data_offset = 0xc
|
||||
|
||||
def get_length(self):
|
||||
"""
|
||||
Return the length of the data in the share, if we're reading.
|
||||
"""
|
||||
return self._length
|
||||
|
||||
def unlink(self):
|
||||
os.unlink(self.home)
|
||||
|
||||
@ -389,7 +397,9 @@ class BucketWriter(object):
|
||||
"""
|
||||
Write data at given offset, return whether the upload is complete.
|
||||
"""
|
||||
# Delay the timeout, since we received data:
|
||||
# Delay the timeout, since we received data; if we get an
|
||||
# AlreadyCancelled error, that means there's a bug in the client and
|
||||
# write() was called after close().
|
||||
self._timeout.reset(30 * 60)
|
||||
start = self._clock.seconds()
|
||||
precondition(not self.closed)
|
||||
@ -411,14 +421,18 @@ class BucketWriter(object):
|
||||
self._already_written.set(True, offset, end)
|
||||
self.ss.add_latency("write", self._clock.seconds() - start)
|
||||
self.ss.count("write")
|
||||
return self._is_finished()
|
||||
|
||||
# Return whether the whole thing has been written. See
|
||||
# https://github.com/mlenzen/collections-extended/issues/169 and
|
||||
# https://github.com/mlenzen/collections-extended/issues/172 for why
|
||||
# it's done this way.
|
||||
def _is_finished(self):
|
||||
"""
|
||||
Return whether the whole thing has been written.
|
||||
"""
|
||||
return sum([mr.stop - mr.start for mr in self._already_written.ranges()]) == self._max_size
|
||||
|
||||
def close(self):
|
||||
# This can't actually be enabled, because it's not backwards compatible
|
||||
# with old Foolscap clients.
|
||||
# assert self._is_finished()
|
||||
precondition(not self.closed)
|
||||
self._timeout.cancel()
|
||||
start = self._clock.seconds()
|
||||
@ -544,6 +558,12 @@ class BucketReader(object):
|
||||
self.shnum,
|
||||
reason)
|
||||
|
||||
def get_length(self):
|
||||
"""
|
||||
Return the length of the data in the share.
|
||||
"""
|
||||
return self._share_file.get_length()
|
||||
|
||||
|
||||
@implementer(RIBucketReader)
|
||||
class FoolscapBucketReader(Referenceable): # type: ignore # warner/foolscap#78
|
||||
|
@ -412,11 +412,14 @@ class MutableShareFile(object):
|
||||
datav.append(self._read_share_data(f, offset, length))
|
||||
return datav
|
||||
|
||||
# def remote_get_length(self):
|
||||
# f = open(self.home, 'rb')
|
||||
# data_length = self._read_data_length(f)
|
||||
# f.close()
|
||||
# return data_length
|
||||
def get_length(self):
|
||||
"""
|
||||
Return the length of the data in the share.
|
||||
"""
|
||||
f = open(self.home, 'rb')
|
||||
data_length = self._read_data_length(f)
|
||||
f.close()
|
||||
return data_length
|
||||
|
||||
def check_write_enabler(self, write_enabler, si_s):
|
||||
with open(self.home, 'rb+') as f:
|
||||
|
@ -1,18 +1,9 @@
|
||||
"""
|
||||
Ported to Python 3.
|
||||
"""
|
||||
from __future__ import division
|
||||
from __future__ import absolute_import
|
||||
from __future__ import print_function
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from future.utils import bytes_to_native_str, PY2
|
||||
if PY2:
|
||||
# Omit open() to get native behavior where open("w") always accepts native
|
||||
# strings. Omit bytes so we don't leak future's custom bytes.
|
||||
from future.builtins import filter, map, zip, ascii, chr, hex, input, next, oct, pow, round, super, dict, list, object, range, str, max, min # noqa: F401
|
||||
else:
|
||||
from typing import Dict, Tuple
|
||||
from __future__ import annotations
|
||||
from future.utils import bytes_to_native_str
|
||||
from typing import Dict, Tuple, Iterable
|
||||
|
||||
import os, re
|
||||
|
||||
@ -330,7 +321,7 @@ class StorageServer(service.MultiService):
|
||||
# they asked about: this will save them a lot of work. Add or update
|
||||
# leases for all of them: if they want us to hold shares for this
|
||||
# file, they'll want us to hold leases for this file.
|
||||
for (shnum, fn) in self._get_bucket_shares(storage_index):
|
||||
for (shnum, fn) in self.get_shares(storage_index):
|
||||
alreadygot[shnum] = ShareFile(fn)
|
||||
if renew_leases:
|
||||
self._add_or_renew_leases(alreadygot.values(), lease_info)
|
||||
@ -372,7 +363,7 @@ class StorageServer(service.MultiService):
|
||||
return set(alreadygot), bucketwriters
|
||||
|
||||
def _iter_share_files(self, storage_index):
|
||||
for shnum, filename in self._get_bucket_shares(storage_index):
|
||||
for shnum, filename in self.get_shares(storage_index):
|
||||
with open(filename, 'rb') as f:
|
||||
header = f.read(32)
|
||||
if MutableShareFile.is_valid_header(header):
|
||||
@ -425,10 +416,12 @@ class StorageServer(service.MultiService):
|
||||
"""
|
||||
self._call_on_bucket_writer_close.append(handler)
|
||||
|
||||
def _get_bucket_shares(self, storage_index):
|
||||
"""Return a list of (shnum, pathname) tuples for files that hold
|
||||
def get_shares(self, storage_index) -> Iterable[tuple[int, str]]:
|
||||
"""
|
||||
Return an iterable of (shnum, pathname) tuples for files that hold
|
||||
shares for this storage_index. In each tuple, 'shnum' will always be
|
||||
the integer form of the last component of 'pathname'."""
|
||||
the integer form of the last component of 'pathname'.
|
||||
"""
|
||||
storagedir = os.path.join(self.sharedir, storage_index_to_dir(storage_index))
|
||||
try:
|
||||
for f in os.listdir(storagedir):
|
||||
@ -440,12 +433,15 @@ class StorageServer(service.MultiService):
|
||||
pass
|
||||
|
||||
def get_buckets(self, storage_index):
|
||||
"""
|
||||
Get ``BucketReaders`` for an immutable.
|
||||
"""
|
||||
start = self._clock.seconds()
|
||||
self.count("get")
|
||||
si_s = si_b2a(storage_index)
|
||||
log.msg("storage: get_buckets %r" % si_s)
|
||||
bucketreaders = {} # k: sharenum, v: BucketReader
|
||||
for shnum, filename in self._get_bucket_shares(storage_index):
|
||||
for shnum, filename in self.get_shares(storage_index):
|
||||
bucketreaders[shnum] = BucketReader(self, filename,
|
||||
storage_index, shnum)
|
||||
self.add_latency("get", self._clock.seconds() - start)
|
||||
@ -462,7 +458,7 @@ class StorageServer(service.MultiService):
|
||||
# since all shares get the same lease data, we just grab the leases
|
||||
# from the first share
|
||||
try:
|
||||
shnum, filename = next(self._get_bucket_shares(storage_index))
|
||||
shnum, filename = next(self.get_shares(storage_index))
|
||||
sf = ShareFile(filename)
|
||||
return sf.get_leases()
|
||||
except StopIteration:
|
||||
@ -476,7 +472,7 @@ class StorageServer(service.MultiService):
|
||||
|
||||
:return: An iterable of the leases attached to this slot.
|
||||
"""
|
||||
for _, share_filename in self._get_bucket_shares(storage_index):
|
||||
for _, share_filename in self.get_shares(storage_index):
|
||||
share = MutableShareFile(share_filename)
|
||||
return share.get_leases()
|
||||
return []
|
||||
@ -699,6 +695,21 @@ class StorageServer(service.MultiService):
|
||||
self)
|
||||
return share
|
||||
|
||||
def enumerate_mutable_shares(self, storage_index: bytes) -> set[int]:
|
||||
"""Return all share numbers for the given mutable."""
|
||||
si_dir = storage_index_to_dir(storage_index)
|
||||
# shares exist if there is a file for them
|
||||
bucketdir = os.path.join(self.sharedir, si_dir)
|
||||
if not os.path.isdir(bucketdir):
|
||||
return set()
|
||||
result = set()
|
||||
for sharenum_s in os.listdir(bucketdir):
|
||||
try:
|
||||
result.add(int(sharenum_s))
|
||||
except ValueError:
|
||||
continue
|
||||
return result
|
||||
|
||||
def slot_readv(self, storage_index, shares, readv):
|
||||
start = self._clock.seconds()
|
||||
self.count("readv")
|
||||
@ -736,7 +747,7 @@ class StorageServer(service.MultiService):
|
||||
:return bool: ``True`` if a share with the given number exists at the
|
||||
given storage index, ``False`` otherwise.
|
||||
"""
|
||||
for existing_sharenum, ignored in self._get_bucket_shares(storage_index):
|
||||
for existing_sharenum, ignored in self.get_shares(storage_index):
|
||||
if existing_sharenum == shnum:
|
||||
return True
|
||||
return False
|
||||
@ -783,6 +794,20 @@ class StorageServer(service.MultiService):
|
||||
|
||||
return None
|
||||
|
||||
def get_immutable_share_length(self, storage_index: bytes, share_number: int) -> int:
|
||||
"""Returns the length (in bytes) of an immutable."""
|
||||
si_dir = storage_index_to_dir(storage_index)
|
||||
path = os.path.join(self.sharedir, si_dir, str(share_number))
|
||||
return ShareFile(path).get_length()
|
||||
|
||||
def get_mutable_share_length(self, storage_index: bytes, share_number: int) -> int:
|
||||
"""Returns the length (in bytes) of a mutable."""
|
||||
si_dir = storage_index_to_dir(storage_index)
|
||||
path = os.path.join(self.sharedir, si_dir, str(share_number))
|
||||
if not os.path.exists(path):
|
||||
raise KeyError("No such storage index or share number")
|
||||
return MutableShareFile(path).get_length()
|
||||
|
||||
|
||||
@implementer(RIStorageServer)
|
||||
class FoolscapStorageServer(Referenceable): # type: ignore # warner/foolscap#78
|
||||
|
@ -5,10 +5,6 @@ the foolscap-based server implemented in src/allmydata/storage/*.py .
|
||||
|
||||
Ported to Python 3.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
from __future__ import division
|
||||
from __future__ import print_function
|
||||
from __future__ import unicode_literals
|
||||
|
||||
# roadmap:
|
||||
#
|
||||
@ -34,23 +30,25 @@ from __future__ import unicode_literals
|
||||
#
|
||||
# 6: implement other sorts of IStorageClient classes: S3, etc
|
||||
|
||||
from future.utils import PY2
|
||||
if PY2:
|
||||
from future.builtins import filter, map, zip, ascii, chr, hex, input, next, oct, open, pow, round, super, bytes, dict, list, object, range, str, max, min # noqa: F401
|
||||
from six import ensure_text
|
||||
from __future__ import annotations
|
||||
|
||||
from six import ensure_text
|
||||
from typing import Union
|
||||
import re, time, hashlib
|
||||
from os import urandom
|
||||
# On Python 2 this will be the backport.
|
||||
from configparser import NoSectionError
|
||||
|
||||
import attr
|
||||
from hyperlink import DecodedURL
|
||||
from zope.interface import (
|
||||
Attribute,
|
||||
Interface,
|
||||
implementer,
|
||||
)
|
||||
from twisted.internet import defer
|
||||
from twisted.python.failure import Failure
|
||||
from twisted.web import http
|
||||
from twisted.internet.task import LoopingCall
|
||||
from twisted.internet import defer, reactor
|
||||
from twisted.application import service
|
||||
from twisted.logger import Logger
|
||||
from twisted.plugin import (
|
||||
@ -76,12 +74,15 @@ from allmydata.util.observer import ObserverList
|
||||
from allmydata.util.rrefutil import add_version_to_remote_reference
|
||||
from allmydata.util.hashutil import permute_server_hash
|
||||
from allmydata.util.dictutil import BytesKeyDict, UnicodeKeyDict
|
||||
from allmydata.util.deferredutil import async_to_deferred
|
||||
from allmydata.storage.http_client import (
|
||||
StorageClient, StorageClientImmutables, StorageClientGeneral,
|
||||
ClientException as HTTPClientException, StorageClientMutables,
|
||||
ReadVector, TestWriteVectors, WriteVector, TestVector
|
||||
ReadVector, TestWriteVectors, WriteVector, TestVector, ClientException
|
||||
)
|
||||
|
||||
ANONYMOUS_STORAGE_NURLS = "anonymous-storage-NURLs"
|
||||
|
||||
|
||||
# who is responsible for de-duplication?
|
||||
# both?
|
||||
@ -106,8 +107,8 @@ class StorageClientConfig(object):
|
||||
|
||||
:ivar preferred_peers: An iterable of the server-ids (``bytes``) of the
|
||||
storage servers where share placement is preferred, in order of
|
||||
decreasing preference. See the *[client]peers.preferred*
|
||||
documentation for details.
|
||||
decreasing preference. See the *[client]peers.preferred* documentation
|
||||
for details.
|
||||
|
||||
:ivar dict[unicode, dict[unicode, unicode]] storage_plugins: A mapping from
|
||||
names of ``IFoolscapStoragePlugin`` configured in *tahoe.cfg* to the
|
||||
@ -269,6 +270,10 @@ class StorageFarmBroker(service.MultiService):
|
||||
by the given announcement.
|
||||
"""
|
||||
assert isinstance(server_id, bytes)
|
||||
if len(server["ann"].get(ANONYMOUS_STORAGE_NURLS, [])) > 0:
|
||||
s = HTTPNativeStorageServer(server_id, server["ann"])
|
||||
s.on_status_changed(lambda _: self._got_connection())
|
||||
return s
|
||||
handler_overrides = server.get("connections", {})
|
||||
s = NativeStorageServer(
|
||||
server_id,
|
||||
@ -530,6 +535,45 @@ class IFoolscapStorageServer(Interface):
|
||||
"""
|
||||
|
||||
|
||||
def _parse_announcement(server_id: bytes, furl: bytes, ann: dict) -> tuple[str, bytes, bytes, bytes, bytes]:
|
||||
"""
|
||||
Parse the furl and announcement, return:
|
||||
|
||||
(nickname, permutation_seed, tubid, short_description, long_description)
|
||||
"""
|
||||
m = re.match(br'pb://(\w+)@', furl)
|
||||
assert m, furl
|
||||
tubid_s = m.group(1).lower()
|
||||
tubid = base32.a2b(tubid_s)
|
||||
if "permutation-seed-base32" in ann:
|
||||
seed = ann["permutation-seed-base32"]
|
||||
if isinstance(seed, str):
|
||||
seed = seed.encode("utf-8")
|
||||
ps = base32.a2b(seed)
|
||||
elif re.search(br'^v0-[0-9a-zA-Z]{52}$', server_id):
|
||||
ps = base32.a2b(server_id[3:])
|
||||
else:
|
||||
log.msg("unable to parse serverid '%(server_id)s as pubkey, "
|
||||
"hashing it to get permutation-seed, "
|
||||
"may not converge with other clients",
|
||||
server_id=server_id,
|
||||
facility="tahoe.storage_broker",
|
||||
level=log.UNUSUAL, umid="qu86tw")
|
||||
ps = hashlib.sha256(server_id).digest()
|
||||
permutation_seed = ps
|
||||
|
||||
assert server_id
|
||||
long_description = server_id
|
||||
if server_id.startswith(b"v0-"):
|
||||
# remove v0- prefix from abbreviated name
|
||||
short_description = server_id[3:3+8]
|
||||
else:
|
||||
short_description = server_id[:8]
|
||||
nickname = ann.get("nickname", "")
|
||||
|
||||
return (nickname, permutation_seed, tubid, short_description, long_description)
|
||||
|
||||
|
||||
@implementer(IFoolscapStorageServer)
|
||||
@attr.s(frozen=True)
|
||||
class _FoolscapStorage(object):
|
||||
@ -573,43 +617,13 @@ class _FoolscapStorage(object):
|
||||
The furl will be a Unicode string on Python 3; on Python 2 it will be
|
||||
either a native (bytes) string or a Unicode string.
|
||||
"""
|
||||
furl = furl.encode("utf-8")
|
||||
m = re.match(br'pb://(\w+)@', furl)
|
||||
assert m, furl
|
||||
tubid_s = m.group(1).lower()
|
||||
tubid = base32.a2b(tubid_s)
|
||||
if "permutation-seed-base32" in ann:
|
||||
seed = ann["permutation-seed-base32"]
|
||||
if isinstance(seed, str):
|
||||
seed = seed.encode("utf-8")
|
||||
ps = base32.a2b(seed)
|
||||
elif re.search(br'^v0-[0-9a-zA-Z]{52}$', server_id):
|
||||
ps = base32.a2b(server_id[3:])
|
||||
else:
|
||||
log.msg("unable to parse serverid '%(server_id)s as pubkey, "
|
||||
"hashing it to get permutation-seed, "
|
||||
"may not converge with other clients",
|
||||
server_id=server_id,
|
||||
facility="tahoe.storage_broker",
|
||||
level=log.UNUSUAL, umid="qu86tw")
|
||||
ps = hashlib.sha256(server_id).digest()
|
||||
permutation_seed = ps
|
||||
|
||||
assert server_id
|
||||
long_description = server_id
|
||||
if server_id.startswith(b"v0-"):
|
||||
# remove v0- prefix from abbreviated name
|
||||
short_description = server_id[3:3+8]
|
||||
else:
|
||||
short_description = server_id[:8]
|
||||
nickname = ann.get("nickname", "")
|
||||
|
||||
(nickname, permutation_seed, tubid, short_description, long_description) = _parse_announcement(server_id, furl.encode("utf-8"), ann)
|
||||
return cls(
|
||||
nickname=nickname,
|
||||
permutation_seed=permutation_seed,
|
||||
tubid=tubid,
|
||||
storage_server=storage_server,
|
||||
furl=furl,
|
||||
furl=furl.encode("utf-8"),
|
||||
short_description=short_description,
|
||||
long_description=long_description,
|
||||
)
|
||||
@ -708,6 +722,16 @@ def _storage_from_foolscap_plugin(node_config, config, announcement, get_rref):
|
||||
raise AnnouncementNotMatched(plugin_names)
|
||||
|
||||
|
||||
def _available_space_from_version(version):
|
||||
if version is None:
|
||||
return None
|
||||
protocol_v1_version = version.get(b'http://allmydata.org/tahoe/protocols/storage/v1', BytesKeyDict())
|
||||
available_space = protocol_v1_version.get(b'available-space')
|
||||
if available_space is None:
|
||||
available_space = protocol_v1_version.get(b'maximum-immutable-share-size', None)
|
||||
return available_space
|
||||
|
||||
|
||||
@implementer(IServer)
|
||||
class NativeStorageServer(service.MultiService):
|
||||
"""I hold information about a storage server that we want to connect to.
|
||||
@ -875,13 +899,7 @@ class NativeStorageServer(service.MultiService):
|
||||
|
||||
def get_available_space(self):
|
||||
version = self.get_version()
|
||||
if version is None:
|
||||
return None
|
||||
protocol_v1_version = version.get(b'http://allmydata.org/tahoe/protocols/storage/v1', BytesKeyDict())
|
||||
available_space = protocol_v1_version.get(b'available-space')
|
||||
if available_space is None:
|
||||
available_space = protocol_v1_version.get(b'maximum-immutable-share-size', None)
|
||||
return available_space
|
||||
return _available_space_from_version(version)
|
||||
|
||||
def start_connecting(self, trigger_cb):
|
||||
self._tub = self._tub_maker(self._handler_overrides)
|
||||
@ -943,6 +961,164 @@ class NativeStorageServer(service.MultiService):
|
||||
# used when the broker wants us to hurry up
|
||||
self._reconnector.reset()
|
||||
|
||||
|
||||
@implementer(IServer)
|
||||
class HTTPNativeStorageServer(service.MultiService):
|
||||
"""
|
||||
Like ``NativeStorageServer``, but for HTTP clients.
|
||||
|
||||
The notion of being "connected" is less meaningful for HTTP; we just poll
|
||||
occasionally, and if we've succeeded at last poll, we assume we're
|
||||
"connected".
|
||||
"""
|
||||
|
||||
def __init__(self, server_id: bytes, announcement, reactor=reactor):
|
||||
service.MultiService.__init__(self)
|
||||
assert isinstance(server_id, bytes)
|
||||
self._server_id = server_id
|
||||
self.announcement = announcement
|
||||
self._on_status_changed = ObserverList()
|
||||
self._reactor = reactor
|
||||
furl = announcement["anonymous-storage-FURL"].encode("utf-8")
|
||||
(
|
||||
self._nickname,
|
||||
self._permutation_seed,
|
||||
self._tubid,
|
||||
self._short_description,
|
||||
self._long_description
|
||||
) = _parse_announcement(server_id, furl, announcement)
|
||||
# TODO need some way to do equivalent of Happy Eyeballs for multiple NURLs?
|
||||
# https://tahoe-lafs.org/trac/tahoe-lafs/ticket/3935
|
||||
nurl = DecodedURL.from_text(announcement[ANONYMOUS_STORAGE_NURLS][0])
|
||||
self._istorage_server = _HTTPStorageServer.from_http_client(
|
||||
StorageClient.from_nurl(nurl, reactor)
|
||||
)
|
||||
|
||||
self._connection_status = connection_status.ConnectionStatus.unstarted()
|
||||
self._version = None
|
||||
self._last_connect_time = None
|
||||
self._connecting_deferred = None
|
||||
|
||||
def get_permutation_seed(self):
|
||||
return self._permutation_seed
|
||||
|
||||
def get_name(self):
|
||||
return self._short_description
|
||||
|
||||
def get_longname(self):
|
||||
return self._long_description
|
||||
|
||||
def get_tubid(self):
|
||||
return self._tubid
|
||||
|
||||
def get_lease_seed(self):
|
||||
# Apparently this is what Foolscap version above does?!
|
||||
return self._tubid
|
||||
|
||||
def get_foolscap_write_enabler_seed(self):
|
||||
return self._tubid
|
||||
|
||||
def get_nickname(self):
|
||||
return self._nickname
|
||||
|
||||
def on_status_changed(self, status_changed):
|
||||
"""
|
||||
:param status_changed: a callable taking a single arg (the
|
||||
NativeStorageServer) that is notified when we become connected
|
||||
"""
|
||||
return self._on_status_changed.subscribe(status_changed)
|
||||
|
||||
# Special methods used by copy.copy() and copy.deepcopy(). When those are
|
||||
# used in allmydata.immutable.filenode to copy CheckResults during
|
||||
# repair, we want it to treat the IServer instances as singletons, and
|
||||
# not attempt to duplicate them..
|
||||
def __copy__(self):
|
||||
return self
|
||||
|
||||
def __deepcopy__(self, memodict):
|
||||
return self
|
||||
|
||||
def __repr__(self):
|
||||
return "<HTTPNativeStorageServer for %r>" % self.get_name()
|
||||
|
||||
def get_serverid(self):
|
||||
return self._server_id
|
||||
|
||||
def get_version(self):
|
||||
return self._version
|
||||
|
||||
def get_announcement(self):
|
||||
return self.announcement
|
||||
|
||||
def get_connection_status(self):
|
||||
return self._connection_status
|
||||
|
||||
def is_connected(self):
|
||||
return self._connection_status.connected
|
||||
|
||||
def get_available_space(self):
|
||||
version = self.get_version()
|
||||
return _available_space_from_version(version)
|
||||
|
||||
def start_connecting(self, trigger_cb):
|
||||
self._lc = LoopingCall(self._connect)
|
||||
self._lc.start(1, True)
|
||||
|
||||
def _got_version(self, version):
|
||||
self._last_connect_time = time.time()
|
||||
self._version = version
|
||||
self._connection_status = connection_status.ConnectionStatus(
|
||||
True, "connected", [], self._last_connect_time, self._last_connect_time
|
||||
)
|
||||
self._on_status_changed.notify(self)
|
||||
|
||||
def _failed_to_connect(self, reason):
|
||||
self._connection_status = connection_status.ConnectionStatus(
|
||||
False, f"failure: {reason}", [], self._last_connect_time, self._last_connect_time
|
||||
)
|
||||
self._on_status_changed.notify(self)
|
||||
|
||||
def get_storage_server(self):
|
||||
"""
|
||||
See ``IServer.get_storage_server``.
|
||||
"""
|
||||
if self._connection_status.summary == "unstarted":
|
||||
return None
|
||||
return self._istorage_server
|
||||
|
||||
def stop_connecting(self):
|
||||
self._lc.stop()
|
||||
if self._connecting_deferred is not None:
|
||||
self._connecting_deferred.cancel()
|
||||
|
||||
def try_to_connect(self):
|
||||
self._connect()
|
||||
|
||||
def _connect(self):
|
||||
result = self._istorage_server.get_version()
|
||||
|
||||
def remove_connecting_deferred(result):
|
||||
self._connecting_deferred = None
|
||||
return result
|
||||
|
||||
# Set a short timeout since we're relying on this for server liveness.
|
||||
self._connecting_deferred = result.addTimeout(5, self._reactor).addBoth(
|
||||
remove_connecting_deferred).addCallbacks(
|
||||
self._got_version,
|
||||
self._failed_to_connect
|
||||
)
|
||||
|
||||
def stopService(self):
|
||||
if self._connecting_deferred is not None:
|
||||
self._connecting_deferred.cancel()
|
||||
|
||||
result = service.MultiService.stopService(self)
|
||||
if self._lc.running:
|
||||
self._lc.stop()
|
||||
self._failed_to_connect("shut down")
|
||||
return result
|
||||
|
||||
|
||||
class UnknownServerTypeError(Exception):
|
||||
pass
|
||||
|
||||
@ -1059,7 +1235,7 @@ class _StorageServer(object):
|
||||
|
||||
|
||||
|
||||
@attr.s
|
||||
@attr.s(hash=True)
|
||||
class _FakeRemoteReference(object):
|
||||
"""
|
||||
Emulate a Foolscap RemoteReference, calling a local object instead.
|
||||
@ -1084,7 +1260,7 @@ class _HTTPBucketWriter(object):
|
||||
storage_index = attr.ib(type=bytes)
|
||||
share_number = attr.ib(type=int)
|
||||
upload_secret = attr.ib(type=bytes)
|
||||
finished = attr.ib(type=bool, default=False)
|
||||
finished = attr.ib(type=defer.Deferred[bool], factory=defer.Deferred)
|
||||
|
||||
def abort(self):
|
||||
return self.client.abort_upload(self.storage_index, self.share_number,
|
||||
@ -1096,18 +1272,27 @@ class _HTTPBucketWriter(object):
|
||||
self.storage_index, self.share_number, self.upload_secret, offset, data
|
||||
)
|
||||
if result.finished:
|
||||
self.finished = True
|
||||
self.finished.callback(True)
|
||||
defer.returnValue(None)
|
||||
|
||||
def close(self):
|
||||
# A no-op in HTTP protocol.
|
||||
if not self.finished:
|
||||
return defer.fail(RuntimeError("You didn't finish writing?!"))
|
||||
return defer.succeed(None)
|
||||
# We're not _really_ closed until all writes have succeeded and we
|
||||
# finished writing all the data.
|
||||
return self.finished
|
||||
|
||||
|
||||
def _ignore_404(failure: Failure) -> Union[Failure, None]:
|
||||
"""
|
||||
Useful for advise_corrupt_share(), since it swallows unknown share numbers
|
||||
in Foolscap.
|
||||
"""
|
||||
if failure.check(HTTPClientException) and failure.value.code == http.NOT_FOUND:
|
||||
return None
|
||||
else:
|
||||
return failure
|
||||
|
||||
@attr.s
|
||||
|
||||
@attr.s(hash=True)
|
||||
class _HTTPBucketReader(object):
|
||||
"""
|
||||
Emulate a ``RIBucketReader``, but use HTTP protocol underneath.
|
||||
@ -1125,7 +1310,7 @@ class _HTTPBucketReader(object):
|
||||
return self.client.advise_corrupt_share(
|
||||
self.storage_index, self.share_number,
|
||||
str(reason, "utf-8", errors="backslashreplace")
|
||||
)
|
||||
).addErrback(_ignore_404)
|
||||
|
||||
|
||||
# WORK IN PROGRESS, for now it doesn't actually implement whole thing.
|
||||
@ -1192,16 +1377,23 @@ class _HTTPStorageServer(object):
|
||||
for share_num in share_numbers
|
||||
})
|
||||
|
||||
def add_lease(
|
||||
@async_to_deferred
|
||||
async def add_lease(
|
||||
self,
|
||||
storage_index,
|
||||
renew_secret,
|
||||
cancel_secret
|
||||
):
|
||||
immutable_client = StorageClientImmutables(self._http_client)
|
||||
return immutable_client.add_or_renew_lease(
|
||||
storage_index, renew_secret, cancel_secret
|
||||
)
|
||||
client = StorageClientGeneral(self._http_client)
|
||||
try:
|
||||
await client.add_or_renew_lease(
|
||||
storage_index, renew_secret, cancel_secret
|
||||
)
|
||||
except ClientException as e:
|
||||
if e.code == http.NOT_FOUND:
|
||||
# Silently do nothing, as is the case for the Foolscap client
|
||||
return
|
||||
raise
|
||||
|
||||
def advise_corrupt_share(
|
||||
self,
|
||||
@ -1211,21 +1403,24 @@ class _HTTPStorageServer(object):
|
||||
reason: bytes
|
||||
):
|
||||
if share_type == b"immutable":
|
||||
imm_client = StorageClientImmutables(self._http_client)
|
||||
return imm_client.advise_corrupt_share(
|
||||
storage_index, shnum, str(reason, "utf-8", errors="backslashreplace")
|
||||
)
|
||||
client : Union[StorageClientImmutables, StorageClientMutables] = StorageClientImmutables(self._http_client)
|
||||
elif share_type == b"mutable":
|
||||
client = StorageClientMutables(self._http_client)
|
||||
else:
|
||||
raise NotImplementedError() # future tickets
|
||||
raise ValueError("Unknown share type")
|
||||
return client.advise_corrupt_share(
|
||||
storage_index, shnum, str(reason, "utf-8", errors="backslashreplace")
|
||||
).addErrback(_ignore_404)
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def slot_readv(self, storage_index, shares, readv):
|
||||
mutable_client = StorageClientMutables(self._http_client)
|
||||
pending_reads = {}
|
||||
reads = {}
|
||||
# TODO if shares list is empty, that means list all shares, so we need
|
||||
# If shares list is empty, that means list all shares, so we need
|
||||
# to do a query to get that.
|
||||
assert shares # TODO replace with call to list shares if and only if it's empty
|
||||
if not shares:
|
||||
shares = yield mutable_client.list_shares(storage_index)
|
||||
|
||||
# Start all the queries in parallel:
|
||||
for share_number in shares:
|
||||
@ -1273,8 +1468,13 @@ class _HTTPStorageServer(object):
|
||||
ReadVector(offset=offset, size=size)
|
||||
for (offset, size) in r_vector
|
||||
]
|
||||
client_result = yield mutable_client.read_test_write_chunks(
|
||||
storage_index, we_secret, lr_secret, lc_secret, client_tw_vectors,
|
||||
client_read_vectors,
|
||||
)
|
||||
try:
|
||||
client_result = yield mutable_client.read_test_write_chunks(
|
||||
storage_index, we_secret, lr_secret, lc_secret, client_tw_vectors,
|
||||
client_read_vectors,
|
||||
)
|
||||
except ClientException as e:
|
||||
if e.code == http.UNAUTHORIZED:
|
||||
raise RemoteException("Unauthorized write, possibly you passed the wrong write enabler?")
|
||||
raise
|
||||
return (client_result.success, client_result.reads)
|
||||
|
@ -12,23 +12,19 @@ from future.utils import PY2
|
||||
if PY2:
|
||||
from future.builtins import filter, map, zip, ascii, chr, hex, input, next, oct, open, pow, round, super, bytes, dict, list, object, range, str, max, min # noqa: F401
|
||||
|
||||
import re
|
||||
from six.moves import (
|
||||
StringIO,
|
||||
)
|
||||
|
||||
from testtools import (
|
||||
skipIf,
|
||||
)
|
||||
from hypothesis.strategies import text
|
||||
from hypothesis import given, assume
|
||||
|
||||
from testtools.matchers import (
|
||||
Contains,
|
||||
Equals,
|
||||
HasLength,
|
||||
)
|
||||
|
||||
from twisted.python.runtime import (
|
||||
platform,
|
||||
)
|
||||
from twisted.python.filepath import (
|
||||
FilePath,
|
||||
)
|
||||
@ -44,6 +40,10 @@ from ...scripts.tahoe_run import (
|
||||
RunOptions,
|
||||
run,
|
||||
)
|
||||
from ...util.pid import (
|
||||
check_pid_process,
|
||||
InvalidPidFile,
|
||||
)
|
||||
|
||||
from ...scripts.runner import (
|
||||
parse_options
|
||||
@ -151,7 +151,7 @@ class RunTests(SyncTestCase):
|
||||
"""
|
||||
Tests for ``run``.
|
||||
"""
|
||||
@skipIf(platform.isWindows(), "There are no PID files on Windows.")
|
||||
|
||||
def test_non_numeric_pid(self):
|
||||
"""
|
||||
If the pidfile exists but does not contain a numeric value, a complaint to
|
||||
@ -159,7 +159,7 @@ class RunTests(SyncTestCase):
|
||||
"""
|
||||
basedir = FilePath(self.mktemp()).asTextMode()
|
||||
basedir.makedirs()
|
||||
basedir.child(u"twistd.pid").setContent(b"foo")
|
||||
basedir.child(u"running.process").setContent(b"foo")
|
||||
basedir.child(u"tahoe-client.tac").setContent(b"")
|
||||
|
||||
config = RunOptions()
|
||||
@ -168,17 +168,30 @@ class RunTests(SyncTestCase):
|
||||
config['basedir'] = basedir.path
|
||||
config.twistd_args = []
|
||||
|
||||
reactor = MemoryReactor()
|
||||
|
||||
runs = []
|
||||
result_code = run(config, runApp=runs.append)
|
||||
result_code = run(reactor, config, runApp=runs.append)
|
||||
self.assertThat(
|
||||
config.stderr.getvalue(),
|
||||
Contains("found invalid PID file in"),
|
||||
)
|
||||
self.assertThat(
|
||||
runs,
|
||||
HasLength(1),
|
||||
)
|
||||
self.assertThat(
|
||||
result_code,
|
||||
Equals(0),
|
||||
)
|
||||
# because the pidfile is invalid we shouldn't get to the
|
||||
# .run() call itself.
|
||||
self.assertThat(runs, Equals([]))
|
||||
self.assertThat(result_code, Equals(1))
|
||||
|
||||
good_file_content_re = re.compile(r"\w[0-9]*\w[0-9]*\w")
|
||||
|
||||
@given(text())
|
||||
def test_pidfile_contents(self, content):
|
||||
"""
|
||||
invalid contents for a pidfile raise errors
|
||||
"""
|
||||
assume(not self.good_file_content_re.match(content))
|
||||
pidfile = FilePath("pidfile")
|
||||
pidfile.setContent(content.encode("utf8"))
|
||||
|
||||
with self.assertRaises(InvalidPidFile):
|
||||
with check_pid_process(pidfile):
|
||||
pass
|
||||
|
@ -134,7 +134,7 @@ class CLINodeAPI(object):
|
||||
|
||||
@property
|
||||
def twistd_pid_file(self):
|
||||
return self.basedir.child(u"twistd.pid")
|
||||
return self.basedir.child(u"running.process")
|
||||
|
||||
@property
|
||||
def node_url_file(self):
|
||||
|
@ -5,22 +5,14 @@ in ``allmydata.test.test_system``.
|
||||
Ported to Python 3.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
from __future__ import division
|
||||
from __future__ import print_function
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from future.utils import PY2
|
||||
if PY2:
|
||||
# Don't import bytes since it causes issues on (so far unported) modules on Python 2.
|
||||
from future.builtins import filter, map, zip, ascii, chr, hex, input, next, oct, open, pow, round, super, dict, list, object, range, max, min, str # noqa: F401
|
||||
|
||||
from typing import Optional
|
||||
import os
|
||||
from functools import partial
|
||||
|
||||
from twisted.internet import reactor
|
||||
from twisted.internet import defer
|
||||
from twisted.internet.defer import inlineCallbacks
|
||||
from twisted.internet.task import deferLater
|
||||
from twisted.application import service
|
||||
|
||||
from foolscap.api import flushEventualQueue
|
||||
@ -28,6 +20,12 @@ from foolscap.api import flushEventualQueue
|
||||
from allmydata import client
|
||||
from allmydata.introducer.server import create_introducer
|
||||
from allmydata.util import fileutil, log, pollmixin
|
||||
from allmydata.util.deferredutil import async_to_deferred
|
||||
from allmydata.storage import http_client
|
||||
from allmydata.storage_client import (
|
||||
NativeStorageServer,
|
||||
HTTPNativeStorageServer,
|
||||
)
|
||||
|
||||
from twisted.python.filepath import (
|
||||
FilePath,
|
||||
@ -642,9 +640,51 @@ def _render_section_values(values):
|
||||
))
|
||||
|
||||
|
||||
@async_to_deferred
|
||||
async def spin_until_cleanup_done(value=None, timeout=10):
|
||||
"""
|
||||
At the end of the test, spin until the reactor has no more DelayedCalls
|
||||
and file descriptors (or equivalents) registered. This prevents dirty
|
||||
reactor errors, while also not hard-coding a fixed amount of time, so it
|
||||
can finish faster on faster computers.
|
||||
|
||||
There is also a timeout: if it takes more than 10 seconds (by default) for
|
||||
the remaining reactor state to clean itself up, the presumption is that it
|
||||
will never get cleaned up and the spinning stops.
|
||||
|
||||
Make sure to run as last thing in tearDown.
|
||||
"""
|
||||
def num_fds():
|
||||
if hasattr(reactor, "handles"):
|
||||
# IOCP!
|
||||
return len(reactor.handles)
|
||||
else:
|
||||
# Normal reactor; having internal readers still registered is fine,
|
||||
# that's not our code.
|
||||
return len(
|
||||
set(reactor.getReaders()) - set(reactor._internalReaders)
|
||||
) + len(reactor.getWriters())
|
||||
|
||||
for i in range(timeout * 1000):
|
||||
# There's a single DelayedCall for AsynchronousDeferredRunTest's
|
||||
# timeout...
|
||||
if (len(reactor.getDelayedCalls()) < 2 and num_fds() == 0):
|
||||
break
|
||||
await deferLater(reactor, 0.001)
|
||||
return value
|
||||
|
||||
|
||||
class SystemTestMixin(pollmixin.PollMixin, testutil.StallMixin):
|
||||
|
||||
# If set to True, use Foolscap for storage protocol. If set to False, HTTP
|
||||
# will be used when possible. If set to None, this suggests a bug in the
|
||||
# test code.
|
||||
FORCE_FOOLSCAP_FOR_STORAGE : Optional[bool] = None
|
||||
|
||||
def setUp(self):
|
||||
self._http_client_pools = []
|
||||
http_client.StorageClient.start_test_mode(self._got_new_http_connection_pool)
|
||||
self.addCleanup(http_client.StorageClient.stop_test_mode)
|
||||
self.port_assigner = SameProcessStreamEndpointAssigner()
|
||||
self.port_assigner.setUp()
|
||||
self.addCleanup(self.port_assigner.tearDown)
|
||||
@ -652,10 +692,35 @@ class SystemTestMixin(pollmixin.PollMixin, testutil.StallMixin):
|
||||
self.sparent = service.MultiService()
|
||||
self.sparent.startService()
|
||||
|
||||
def _got_new_http_connection_pool(self, pool):
|
||||
# Register the pool for shutdown later:
|
||||
self._http_client_pools.append(pool)
|
||||
# Disable retries:
|
||||
pool.retryAutomatically = False
|
||||
# Make a much more aggressive timeout for connections, we're connecting
|
||||
# locally after all... and also make sure it's lower than the delay we
|
||||
# add in tearDown, to prevent dirty reactor issues.
|
||||
getConnection = pool.getConnection
|
||||
|
||||
def getConnectionWithTimeout(*args, **kwargs):
|
||||
d = getConnection(*args, **kwargs)
|
||||
d.addTimeout(1, reactor)
|
||||
return d
|
||||
|
||||
pool.getConnection = getConnectionWithTimeout
|
||||
|
||||
def close_idle_http_connections(self):
|
||||
"""Close all HTTP client connections that are just hanging around."""
|
||||
return defer.gatherResults(
|
||||
[pool.closeCachedConnections() for pool in self._http_client_pools]
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
log.msg("shutting down SystemTest services")
|
||||
d = self.sparent.stopService()
|
||||
d.addBoth(flush_but_dont_ignore)
|
||||
d.addBoth(lambda x: self.close_idle_http_connections().addCallback(lambda _: x))
|
||||
d.addBoth(spin_until_cleanup_done)
|
||||
return d
|
||||
|
||||
def getdir(self, subdir):
|
||||
@ -714,21 +779,31 @@ class SystemTestMixin(pollmixin.PollMixin, testutil.StallMixin):
|
||||
:return: A ``Deferred`` that fires when the nodes have connected to
|
||||
each other.
|
||||
"""
|
||||
self.assertIn(
|
||||
self.FORCE_FOOLSCAP_FOR_STORAGE, (True, False),
|
||||
"You forgot to set FORCE_FOOLSCAP_FOR_STORAGE on {}".format(self.__class__)
|
||||
)
|
||||
self.numclients = NUMCLIENTS
|
||||
|
||||
self.introducer = yield self._create_introducer()
|
||||
self.add_service(self.introducer)
|
||||
self.introweb_url = self._get_introducer_web()
|
||||
yield self._set_up_client_nodes()
|
||||
yield self._set_up_client_nodes(self.FORCE_FOOLSCAP_FOR_STORAGE)
|
||||
native_server = next(iter(self.clients[0].storage_broker.get_known_servers()))
|
||||
if self.FORCE_FOOLSCAP_FOR_STORAGE:
|
||||
expected_storage_server_class = NativeStorageServer
|
||||
else:
|
||||
expected_storage_server_class = HTTPNativeStorageServer
|
||||
self.assertIsInstance(native_server, expected_storage_server_class)
|
||||
|
||||
@inlineCallbacks
|
||||
def _set_up_client_nodes(self):
|
||||
def _set_up_client_nodes(self, force_foolscap):
|
||||
q = self.introducer
|
||||
self.introducer_furl = q.introducer_url
|
||||
self.clients = []
|
||||
basedirs = []
|
||||
for i in range(self.numclients):
|
||||
basedirs.append((yield self._set_up_client_node(i)))
|
||||
basedirs.append((yield self._set_up_client_node(i, force_foolscap)))
|
||||
|
||||
# start clients[0], wait for it's tub to be ready (at which point it
|
||||
# will have registered the helper furl).
|
||||
@ -761,7 +836,7 @@ class SystemTestMixin(pollmixin.PollMixin, testutil.StallMixin):
|
||||
# and the helper-using webport
|
||||
self.helper_webish_url = self.clients[3].getServiceNamed("webish").getURL()
|
||||
|
||||
def _generate_config(self, which, basedir):
|
||||
def _generate_config(self, which, basedir, force_foolscap=False):
|
||||
config = {}
|
||||
|
||||
allclients = set(range(self.numclients))
|
||||
@ -791,6 +866,7 @@ class SystemTestMixin(pollmixin.PollMixin, testutil.StallMixin):
|
||||
sethelper = partial(setconf, config, which, "helper")
|
||||
|
||||
setnode("nickname", u"client %d \N{BLACK SMILING FACE}" % (which,))
|
||||
setconf(config, which, "storage", "force_foolscap", str(force_foolscap))
|
||||
|
||||
tub_location_hint, tub_port_endpoint = self.port_assigner.assign(reactor)
|
||||
setnode("tub.port", tub_port_endpoint)
|
||||
@ -808,17 +884,16 @@ class SystemTestMixin(pollmixin.PollMixin, testutil.StallMixin):
|
||||
" furl: %s\n") % self.introducer_furl
|
||||
iyaml_fn = os.path.join(basedir, "private", "introducers.yaml")
|
||||
fileutil.write(iyaml_fn, iyaml)
|
||||
|
||||
return _render_config(config)
|
||||
|
||||
def _set_up_client_node(self, which):
|
||||
def _set_up_client_node(self, which, force_foolscap):
|
||||
basedir = self.getdir("client%d" % (which,))
|
||||
fileutil.make_dirs(os.path.join(basedir, "private"))
|
||||
if len(SYSTEM_TEST_CERTS) > (which + 1):
|
||||
f = open(os.path.join(basedir, "private", "node.pem"), "w")
|
||||
f.write(SYSTEM_TEST_CERTS[which + 1])
|
||||
f.close()
|
||||
config = self._generate_config(which, basedir)
|
||||
config = self._generate_config(which, basedir, force_foolscap)
|
||||
fileutil.write(os.path.join(basedir, 'tahoe.cfg'), config)
|
||||
return basedir
|
||||
|
||||
|
@ -145,6 +145,7 @@ def run_cli_native(verb, *args, **kwargs):
|
||||
)
|
||||
d.addCallback(
|
||||
runner.dispatch,
|
||||
reactor,
|
||||
stdin=stdin,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
|
@ -14,11 +14,17 @@ if PY2:
|
||||
from future.builtins import filter, map, zip, ascii, chr, hex, input, next, oct, open, pow, round, super, bytes, dict, list, object, range, str, max, min # noqa: F401
|
||||
|
||||
from zope.interface import implementer
|
||||
from twisted.trial.unittest import TestCase
|
||||
from twisted.internet.interfaces import IPushProducer, IPullProducer
|
||||
|
||||
from allmydata.util.consumer import MemoryConsumer
|
||||
|
||||
from .common import (
|
||||
SyncTestCase,
|
||||
)
|
||||
from testtools.matchers import (
|
||||
Equals,
|
||||
)
|
||||
|
||||
|
||||
@implementer(IPushProducer)
|
||||
@implementer(IPullProducer)
|
||||
@ -50,7 +56,7 @@ class Producer(object):
|
||||
self.consumer.unregisterProducer()
|
||||
|
||||
|
||||
class MemoryConsumerTests(TestCase):
|
||||
class MemoryConsumerTests(SyncTestCase):
|
||||
"""Tests for MemoryConsumer."""
|
||||
|
||||
def test_push_producer(self):
|
||||
@ -60,14 +66,14 @@ class MemoryConsumerTests(TestCase):
|
||||
consumer = MemoryConsumer()
|
||||
producer = Producer(consumer, [b"abc", b"def", b"ghi"])
|
||||
consumer.registerProducer(producer, True)
|
||||
self.assertEqual(consumer.chunks, [b"abc"])
|
||||
self.assertThat(consumer.chunks, Equals([b"abc"]))
|
||||
producer.iterate()
|
||||
producer.iterate()
|
||||
self.assertEqual(consumer.chunks, [b"abc", b"def", b"ghi"])
|
||||
self.assertEqual(consumer.done, False)
|
||||
self.assertThat(consumer.chunks, Equals([b"abc", b"def", b"ghi"]))
|
||||
self.assertFalse(consumer.done)
|
||||
producer.iterate()
|
||||
self.assertEqual(consumer.chunks, [b"abc", b"def", b"ghi"])
|
||||
self.assertEqual(consumer.done, True)
|
||||
self.assertThat(consumer.chunks, Equals([b"abc", b"def", b"ghi"]))
|
||||
self.assertTrue(consumer.done)
|
||||
|
||||
def test_pull_producer(self):
|
||||
"""
|
||||
@ -76,8 +82,8 @@ class MemoryConsumerTests(TestCase):
|
||||
consumer = MemoryConsumer()
|
||||
producer = Producer(consumer, [b"abc", b"def", b"ghi"])
|
||||
consumer.registerProducer(producer, False)
|
||||
self.assertEqual(consumer.chunks, [b"abc", b"def", b"ghi"])
|
||||
self.assertEqual(consumer.done, True)
|
||||
self.assertThat(consumer.chunks, Equals([b"abc", b"def", b"ghi"]))
|
||||
self.assertTrue(consumer.done)
|
||||
|
||||
|
||||
# download_to_data() is effectively tested by some of the filenode tests, e.g.
|
||||
|
@ -15,26 +15,16 @@ from typing import Set
|
||||
from random import Random
|
||||
from unittest import SkipTest
|
||||
|
||||
from twisted.internet.defer import inlineCallbacks, returnValue, succeed
|
||||
from twisted.internet.defer import inlineCallbacks, returnValue
|
||||
from twisted.internet.task import Clock
|
||||
from twisted.internet import reactor
|
||||
from twisted.internet.endpoints import serverFromString
|
||||
from twisted.python.filepath import FilePath
|
||||
from foolscap.api import Referenceable, RemoteException
|
||||
|
||||
from allmydata.interfaces import IStorageServer # really, IStorageClient
|
||||
# A better name for this would be IStorageClient...
|
||||
from allmydata.interfaces import IStorageServer
|
||||
|
||||
from .common_system import SystemTestMixin
|
||||
from .common import AsyncTestCase, SameProcessStreamEndpointAssigner
|
||||
from .certs import (
|
||||
generate_certificate,
|
||||
generate_private_key,
|
||||
private_key_to_file,
|
||||
cert_to_file,
|
||||
)
|
||||
from .common import AsyncTestCase
|
||||
from allmydata.storage.server import StorageServer # not a IStorageServer!!
|
||||
from allmydata.storage.http_server import HTTPServer, listen_tls
|
||||
from allmydata.storage.http_client import StorageClient
|
||||
from allmydata.storage_client import _HTTPStorageServer
|
||||
|
||||
|
||||
# Use random generator with known seed, so results are reproducible if tests
|
||||
@ -446,6 +436,17 @@ class IStorageServerImmutableAPIsTestsMixin(object):
|
||||
b"immutable", storage_index, 0, b"ono"
|
||||
)
|
||||
|
||||
@inlineCallbacks
|
||||
def test_advise_corrupt_share_unknown_share_number(self):
|
||||
"""
|
||||
Calling ``advise_corrupt_share()`` on an immutable share, with an
|
||||
unknown share number, does not result in error.
|
||||
"""
|
||||
storage_index, _, _ = yield self.create_share()
|
||||
yield self.storage_client.advise_corrupt_share(
|
||||
b"immutable", storage_index, 999, b"ono"
|
||||
)
|
||||
|
||||
@inlineCallbacks
|
||||
def test_allocate_buckets_creates_lease(self):
|
||||
"""
|
||||
@ -459,6 +460,21 @@ class IStorageServerImmutableAPIsTestsMixin(object):
|
||||
lease.get_expiration_time() - self.fake_time() > (31 * 24 * 60 * 60 - 10)
|
||||
)
|
||||
|
||||
@inlineCallbacks
|
||||
def test_add_lease_non_existent(self):
|
||||
"""
|
||||
If the storage index doesn't exist, adding the lease silently does nothing.
|
||||
"""
|
||||
storage_index = new_storage_index()
|
||||
self.assertEqual(list(self.server.get_leases(storage_index)), [])
|
||||
|
||||
renew_secret = new_secret()
|
||||
cancel_secret = new_secret()
|
||||
|
||||
# Add a lease:
|
||||
yield self.storage_client.add_lease(storage_index, renew_secret, cancel_secret)
|
||||
self.assertEqual(list(self.server.get_leases(storage_index)), [])
|
||||
|
||||
@inlineCallbacks
|
||||
def test_add_lease_renewal(self):
|
||||
"""
|
||||
@ -854,6 +870,23 @@ class IStorageServerMutableAPIsTestsMixin(object):
|
||||
{0: [b"abcdefg"], 1: [b"0123456"], 2: [b"9876543"]},
|
||||
)
|
||||
|
||||
@inlineCallbacks
|
||||
def test_slot_readv_unknown_storage_index(self):
|
||||
"""
|
||||
With unknown storage index, ``IStorageServer.slot_readv()`` returns
|
||||
empty dict.
|
||||
"""
|
||||
storage_index = new_storage_index()
|
||||
reads = yield self.storage_client.slot_readv(
|
||||
storage_index,
|
||||
shares=[],
|
||||
readv=[(0, 7)],
|
||||
)
|
||||
self.assertEqual(
|
||||
reads,
|
||||
{},
|
||||
)
|
||||
|
||||
@inlineCallbacks
|
||||
def create_slot(self):
|
||||
"""Create a slot with sharenum 0."""
|
||||
@ -883,6 +916,19 @@ class IStorageServerMutableAPIsTestsMixin(object):
|
||||
b"mutable", storage_index, 0, b"ono"
|
||||
)
|
||||
|
||||
@inlineCallbacks
|
||||
def test_advise_corrupt_share_unknown_share_number(self):
|
||||
"""
|
||||
Calling ``advise_corrupt_share()`` on a mutable share with an unknown
|
||||
share number does not result in error (other behavior is opaque at this
|
||||
level of abstraction).
|
||||
"""
|
||||
secrets, storage_index = yield self.create_slot()
|
||||
|
||||
yield self.storage_client.advise_corrupt_share(
|
||||
b"mutable", storage_index, 999, b"ono"
|
||||
)
|
||||
|
||||
@inlineCallbacks
|
||||
def test_STARAW_create_lease(self):
|
||||
"""
|
||||
@ -998,7 +1044,10 @@ class _SharedMixin(SystemTestMixin):
|
||||
SKIP_TESTS = set() # type: Set[str]
|
||||
|
||||
def _get_istorage_server(self):
|
||||
raise NotImplementedError("implement in subclass")
|
||||
native_server = next(iter(self.clients[0].storage_broker.get_known_servers()))
|
||||
client = native_server.get_storage_server()
|
||||
self.assertTrue(IStorageServer.providedBy(client))
|
||||
return client
|
||||
|
||||
@inlineCallbacks
|
||||
def setUp(self):
|
||||
@ -1021,7 +1070,7 @@ class _SharedMixin(SystemTestMixin):
|
||||
self._clock = Clock()
|
||||
self._clock.advance(123456)
|
||||
self.server._clock = self._clock
|
||||
self.storage_client = yield self._get_istorage_server()
|
||||
self.storage_client = self._get_istorage_server()
|
||||
|
||||
def fake_time(self):
|
||||
"""Return the current fake, test-controlled, time."""
|
||||
@ -1037,74 +1086,29 @@ class _SharedMixin(SystemTestMixin):
|
||||
yield SystemTestMixin.tearDown(self)
|
||||
|
||||
|
||||
class _FoolscapMixin(_SharedMixin):
|
||||
"""Run tests on Foolscap version of ``IStorageServer``."""
|
||||
|
||||
def _get_native_server(self):
|
||||
return next(iter(self.clients[0].storage_broker.get_known_servers()))
|
||||
|
||||
def _get_istorage_server(self):
|
||||
client = self._get_native_server().get_storage_server()
|
||||
self.assertTrue(IStorageServer.providedBy(client))
|
||||
return succeed(client)
|
||||
|
||||
|
||||
class _HTTPMixin(_SharedMixin):
|
||||
"""Run tests on the HTTP version of ``IStorageServer``."""
|
||||
|
||||
def setUp(self):
|
||||
self._port_assigner = SameProcessStreamEndpointAssigner()
|
||||
self._port_assigner.setUp()
|
||||
self.addCleanup(self._port_assigner.tearDown)
|
||||
return _SharedMixin.setUp(self)
|
||||
|
||||
@inlineCallbacks
|
||||
def _get_istorage_server(self):
|
||||
swissnum = b"1234"
|
||||
http_storage_server = HTTPServer(self.server, swissnum)
|
||||
|
||||
# Listen on randomly assigned port, using self-signed cert:
|
||||
private_key = generate_private_key()
|
||||
certificate = generate_certificate(private_key)
|
||||
_, endpoint_string = self._port_assigner.assign(reactor)
|
||||
nurl, listening_port = yield listen_tls(
|
||||
http_storage_server,
|
||||
"127.0.0.1",
|
||||
serverFromString(reactor, endpoint_string),
|
||||
private_key_to_file(FilePath(self.mktemp()), private_key),
|
||||
cert_to_file(FilePath(self.mktemp()), certificate),
|
||||
)
|
||||
self.addCleanup(listening_port.stopListening)
|
||||
|
||||
# Create HTTP client with non-persistent connections, so we don't leak
|
||||
# state across tests:
|
||||
returnValue(
|
||||
_HTTPStorageServer.from_http_client(
|
||||
StorageClient.from_nurl(nurl, reactor, persistent=False)
|
||||
)
|
||||
)
|
||||
|
||||
# Eventually should also:
|
||||
# self.assertTrue(IStorageServer.providedBy(client))
|
||||
|
||||
|
||||
class FoolscapSharedAPIsTests(
|
||||
_FoolscapMixin, IStorageServerSharedAPIsTestsMixin, AsyncTestCase
|
||||
_SharedMixin, IStorageServerSharedAPIsTestsMixin, AsyncTestCase
|
||||
):
|
||||
"""Foolscap-specific tests for shared ``IStorageServer`` APIs."""
|
||||
|
||||
FORCE_FOOLSCAP_FOR_STORAGE = True
|
||||
|
||||
|
||||
class HTTPSharedAPIsTests(
|
||||
_HTTPMixin, IStorageServerSharedAPIsTestsMixin, AsyncTestCase
|
||||
_SharedMixin, IStorageServerSharedAPIsTestsMixin, AsyncTestCase
|
||||
):
|
||||
"""HTTP-specific tests for shared ``IStorageServer`` APIs."""
|
||||
|
||||
FORCE_FOOLSCAP_FOR_STORAGE = False
|
||||
|
||||
|
||||
class FoolscapImmutableAPIsTests(
|
||||
_FoolscapMixin, IStorageServerImmutableAPIsTestsMixin, AsyncTestCase
|
||||
_SharedMixin, IStorageServerImmutableAPIsTestsMixin, AsyncTestCase
|
||||
):
|
||||
"""Foolscap-specific tests for immutable ``IStorageServer`` APIs."""
|
||||
|
||||
FORCE_FOOLSCAP_FOR_STORAGE = True
|
||||
|
||||
def test_disconnection(self):
|
||||
"""
|
||||
If we disconnect in the middle of writing to a bucket, all data is
|
||||
@ -1127,32 +1131,29 @@ class FoolscapImmutableAPIsTests(
|
||||
"""
|
||||
current = self.storage_client
|
||||
yield self.bounce_client(0)
|
||||
self.storage_client = self._get_native_server().get_storage_server()
|
||||
self.storage_client = self._get_istorage_server()
|
||||
assert self.storage_client is not current
|
||||
|
||||
|
||||
class HTTPImmutableAPIsTests(
|
||||
_HTTPMixin, IStorageServerImmutableAPIsTestsMixin, AsyncTestCase
|
||||
_SharedMixin, IStorageServerImmutableAPIsTestsMixin, AsyncTestCase
|
||||
):
|
||||
"""HTTP-specific tests for immutable ``IStorageServer`` APIs."""
|
||||
|
||||
FORCE_FOOLSCAP_FOR_STORAGE = False
|
||||
|
||||
|
||||
class FoolscapMutableAPIsTests(
|
||||
_FoolscapMixin, IStorageServerMutableAPIsTestsMixin, AsyncTestCase
|
||||
_SharedMixin, IStorageServerMutableAPIsTestsMixin, AsyncTestCase
|
||||
):
|
||||
"""Foolscap-specific tests for mutable ``IStorageServer`` APIs."""
|
||||
|
||||
FORCE_FOOLSCAP_FOR_STORAGE = True
|
||||
|
||||
|
||||
class HTTPMutableAPIsTests(
|
||||
_HTTPMixin, IStorageServerMutableAPIsTestsMixin, AsyncTestCase
|
||||
_SharedMixin, IStorageServerMutableAPIsTestsMixin, AsyncTestCase
|
||||
):
|
||||
"""HTTP-specific tests for mutable ``IStorageServer`` APIs."""
|
||||
|
||||
# TODO will be implemented in later tickets
|
||||
SKIP_TESTS = {
|
||||
"test_STARAW_write_enabler_must_match",
|
||||
"test_add_lease_renewal",
|
||||
"test_add_new_lease",
|
||||
"test_advise_corrupt_share",
|
||||
"test_slot_readv_no_shares",
|
||||
}
|
||||
FORCE_FOOLSCAP_FOR_STORAGE = False
|
||||
|
43
src/allmydata/test/test_protocol_switch.py
Normal file
43
src/allmydata/test/test_protocol_switch.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""
|
||||
Unit tests for ``allmydata.protocol_switch``.
|
||||
|
||||
By its nature, most of the testing needs to be end-to-end; essentially any test
|
||||
that uses real Foolscap (``test_system.py``, integration tests) ensures
|
||||
Foolscap still works. ``test_istorageserver.py`` tests the HTTP support.
|
||||
"""
|
||||
|
||||
from foolscap.negotiate import Negotiation
|
||||
|
||||
from .common import TestCase
|
||||
from ..protocol_switch import _PretendToBeNegotiation
|
||||
|
||||
|
||||
class UtilityTests(TestCase):
|
||||
"""Tests for utilities in the protocol switch code."""
|
||||
|
||||
def test_metaclass(self):
|
||||
"""
|
||||
A class that has the ``_PretendToBeNegotiation`` metaclass will support
|
||||
``isinstance()``'s normal semantics on its own instances, but will also
|
||||
indicate that ``Negotiation`` instances are its instances.
|
||||
"""
|
||||
|
||||
class Parent(metaclass=_PretendToBeNegotiation):
|
||||
pass
|
||||
|
||||
class Child(Parent):
|
||||
pass
|
||||
|
||||
class Other:
|
||||
pass
|
||||
|
||||
p = Parent()
|
||||
self.assertIsInstance(p, Parent)
|
||||
self.assertIsInstance(Negotiation(), Parent)
|
||||
self.assertNotIsInstance(Other(), Parent)
|
||||
|
||||
c = Child()
|
||||
self.assertIsInstance(c, Child)
|
||||
self.assertIsInstance(c, Parent)
|
||||
self.assertIsInstance(Negotiation(), Child)
|
||||
self.assertNotIsInstance(Other(), Child)
|
@ -251,6 +251,12 @@ class Verifier(GridTestMixin, unittest.TestCase, RepairTestMixin):
|
||||
self.judge_invisible_corruption)
|
||||
|
||||
def test_corrupt_ueb(self):
|
||||
# Note that in some rare situations this might fail, specifically if
|
||||
# the length of the UEB is corrupted to be a value that is bigger than
|
||||
# the size but less than 2000, it might not get caught... But that's
|
||||
# mostly because in that case it doesn't meaningfully corrupt it. See
|
||||
# _get_uri_extension_the_old_way() in layout.py for where the 2000
|
||||
# number comes from.
|
||||
self.basedir = "repairer/Verifier/corrupt_ueb"
|
||||
return self._help_test_verify(common._corrupt_uri_extension,
|
||||
self.judge_invisible_corruption)
|
||||
@ -717,7 +723,7 @@ class Repairer(GridTestMixin, unittest.TestCase, RepairTestMixin,
|
||||
ss = self.g.servers_by_number[0]
|
||||
# we want to delete the share corresponding to the server
|
||||
# we're making not-respond
|
||||
share = next(ss._get_bucket_shares(self.c0_filenode.get_storage_index()))[0]
|
||||
share = next(ss.get_shares(self.c0_filenode.get_storage_index()))[0]
|
||||
self.delete_shares_numbered(self.uri, [share])
|
||||
return self.c0_filenode.check_and_repair(Monitor())
|
||||
d.addCallback(_then)
|
||||
|
@ -42,16 +42,19 @@ from twisted.trial import unittest
|
||||
|
||||
from twisted.internet import reactor
|
||||
from twisted.python import usage
|
||||
from twisted.python.runtime import platform
|
||||
from twisted.internet.defer import (
|
||||
inlineCallbacks,
|
||||
DeferredList,
|
||||
)
|
||||
from twisted.python.filepath import FilePath
|
||||
from twisted.python.runtime import (
|
||||
platform,
|
||||
)
|
||||
from allmydata.util import fileutil, pollmixin
|
||||
from allmydata.util.encodingutil import unicode_to_argv
|
||||
from allmydata.util.pid import (
|
||||
check_pid_process,
|
||||
_pidfile_to_lockpath,
|
||||
ProcessInTheWay,
|
||||
)
|
||||
from allmydata.test import common_util
|
||||
import allmydata
|
||||
from allmydata.scripts.runner import (
|
||||
@ -418,9 +421,7 @@ class RunNode(common_util.SignalMixin, unittest.TestCase, pollmixin.PollMixin):
|
||||
|
||||
tahoe.active()
|
||||
|
||||
# We don't keep track of PIDs in files on Windows.
|
||||
if not platform.isWindows():
|
||||
self.assertTrue(tahoe.twistd_pid_file.exists())
|
||||
self.assertTrue(tahoe.twistd_pid_file.exists())
|
||||
self.assertTrue(tahoe.node_url_file.exists())
|
||||
|
||||
# rm this so we can detect when the second incarnation is ready
|
||||
@ -493,9 +494,7 @@ class RunNode(common_util.SignalMixin, unittest.TestCase, pollmixin.PollMixin):
|
||||
# change on restart
|
||||
storage_furl = fileutil.read(tahoe.storage_furl_file.path)
|
||||
|
||||
# We don't keep track of PIDs in files on Windows.
|
||||
if not platform.isWindows():
|
||||
self.assertTrue(tahoe.twistd_pid_file.exists())
|
||||
self.assertTrue(tahoe.twistd_pid_file.exists())
|
||||
|
||||
# rm this so we can detect when the second incarnation is ready
|
||||
tahoe.node_url_file.remove()
|
||||
@ -513,22 +512,23 @@ class RunNode(common_util.SignalMixin, unittest.TestCase, pollmixin.PollMixin):
|
||||
fileutil.read(tahoe.storage_furl_file.path),
|
||||
)
|
||||
|
||||
if not platform.isWindows():
|
||||
self.assertTrue(
|
||||
tahoe.twistd_pid_file.exists(),
|
||||
"PID file ({}) didn't exist when we expected it to. "
|
||||
"These exist: {}".format(
|
||||
tahoe.twistd_pid_file,
|
||||
tahoe.twistd_pid_file.parent().listdir(),
|
||||
),
|
||||
)
|
||||
self.assertTrue(
|
||||
tahoe.twistd_pid_file.exists(),
|
||||
"PID file ({}) didn't exist when we expected it to. "
|
||||
"These exist: {}".format(
|
||||
tahoe.twistd_pid_file,
|
||||
tahoe.twistd_pid_file.parent().listdir(),
|
||||
),
|
||||
)
|
||||
yield tahoe.stop_and_wait()
|
||||
|
||||
# twistd.pid should be gone by now -- except on Windows, where
|
||||
# killing a subprocess immediately exits with no chance for
|
||||
# any shutdown code (that is, no Twisted shutdown hooks can
|
||||
# run).
|
||||
if not platform.isWindows():
|
||||
# twistd.pid should be gone by now.
|
||||
self.assertFalse(tahoe.twistd_pid_file.exists())
|
||||
|
||||
|
||||
def _remove(self, res, file):
|
||||
fileutil.remove(file)
|
||||
return res
|
||||
@ -610,8 +610,9 @@ class RunNode(common_util.SignalMixin, unittest.TestCase, pollmixin.PollMixin):
|
||||
),
|
||||
)
|
||||
|
||||
# It should not be running (but windows shutdown can't run
|
||||
# code so the PID file still exists there).
|
||||
if not platform.isWindows():
|
||||
# It should not be running.
|
||||
self.assertFalse(tahoe.twistd_pid_file.exists())
|
||||
|
||||
# Wait for the operation to *complete*. If we got this far it's
|
||||
@ -621,3 +622,42 @@ class RunNode(common_util.SignalMixin, unittest.TestCase, pollmixin.PollMixin):
|
||||
# What's left is a perfect indicator that the process has exited and
|
||||
# we won't get blamed for leaving the reactor dirty.
|
||||
yield client_running
|
||||
|
||||
|
||||
class PidFileLocking(SyncTestCase):
|
||||
"""
|
||||
Direct tests for allmydata.util.pid functions
|
||||
"""
|
||||
|
||||
def test_locking(self):
|
||||
"""
|
||||
Fail to create a pidfile if another process has the lock already.
|
||||
"""
|
||||
# this can't just be "our" process because the locking library
|
||||
# allows the same process to acquire a lock multiple times.
|
||||
pidfile = FilePath(self.mktemp())
|
||||
lockfile = _pidfile_to_lockpath(pidfile)
|
||||
|
||||
with open("other_lock.py", "w") as f:
|
||||
f.write(
|
||||
"\n".join([
|
||||
"import filelock, time, sys",
|
||||
"with filelock.FileLock(sys.argv[1], timeout=1):",
|
||||
" sys.stdout.write('.\\n')",
|
||||
" sys.stdout.flush()",
|
||||
" time.sleep(10)",
|
||||
])
|
||||
)
|
||||
proc = Popen(
|
||||
[sys.executable, "other_lock.py", lockfile.path],
|
||||
stdout=PIPE,
|
||||
stderr=PIPE,
|
||||
)
|
||||
# make sure our subprocess has had time to acquire the lock
|
||||
# for sure (from the "." it prints)
|
||||
proc.stdout.read(2)
|
||||
|
||||
# acquiring the same lock should fail; it is locked by the subprocess
|
||||
with self.assertRaises(ProcessInTheWay):
|
||||
check_pid_process(pidfile)
|
||||
proc.terminate()
|
||||
|
@ -463,7 +463,7 @@ class BucketProxy(unittest.TestCase):
|
||||
block_size=10,
|
||||
num_segments=5,
|
||||
num_share_hashes=3,
|
||||
uri_extension_size_max=500)
|
||||
uri_extension_size=500)
|
||||
self.failUnless(interfaces.IStorageBucketWriter.providedBy(bp), bp)
|
||||
|
||||
def _do_test_readwrite(self, name, header_size, wbp_class, rbp_class):
|
||||
@ -494,7 +494,7 @@ class BucketProxy(unittest.TestCase):
|
||||
block_size=25,
|
||||
num_segments=4,
|
||||
num_share_hashes=3,
|
||||
uri_extension_size_max=len(uri_extension))
|
||||
uri_extension_size=len(uri_extension))
|
||||
|
||||
d = bp.put_header()
|
||||
d.addCallback(lambda res: bp.put_block(0, b"a"*25))
|
||||
@ -688,6 +688,19 @@ class Server(unittest.TestCase):
|
||||
writer.abort()
|
||||
self.failUnlessEqual(ss.allocated_size(), 0)
|
||||
|
||||
def test_immutable_length(self):
|
||||
"""
|
||||
``get_immutable_share_length()`` returns the length of an immutable
|
||||
share, as does ``BucketWriter.get_length()``..
|
||||
"""
|
||||
ss = self.create("test_immutable_length")
|
||||
_, writers = self.allocate(ss, b"allocate", [22], 75)
|
||||
bucket = writers[22]
|
||||
bucket.write(0, b"X" * 75)
|
||||
bucket.close()
|
||||
self.assertEqual(ss.get_immutable_share_length(b"allocate", 22), 75)
|
||||
self.assertEqual(ss.get_buckets(b"allocate")[22].get_length(), 75)
|
||||
|
||||
def test_allocate(self):
|
||||
ss = self.create("test_allocate")
|
||||
|
||||
@ -766,7 +779,7 @@ class Server(unittest.TestCase):
|
||||
writer.close()
|
||||
|
||||
# It should have a lease granted at the current time.
|
||||
shares = dict(ss._get_bucket_shares(storage_index))
|
||||
shares = dict(ss.get_shares(storage_index))
|
||||
self.assertEqual(
|
||||
[first_lease],
|
||||
list(
|
||||
@ -789,7 +802,7 @@ class Server(unittest.TestCase):
|
||||
writer.close()
|
||||
|
||||
# The first share's lease expiration time is unchanged.
|
||||
shares = dict(ss._get_bucket_shares(storage_index))
|
||||
shares = dict(ss.get_shares(storage_index))
|
||||
self.assertEqual(
|
||||
[first_lease],
|
||||
list(
|
||||
@ -1315,6 +1328,64 @@ class MutableServer(unittest.TestCase):
|
||||
self.failUnless(isinstance(readv_data, dict))
|
||||
self.failUnlessEqual(len(readv_data), 0)
|
||||
|
||||
def test_enumerate_mutable_shares(self):
|
||||
"""
|
||||
``StorageServer.enumerate_mutable_shares()`` returns a set of share
|
||||
numbers for the given storage index, or an empty set if it does not
|
||||
exist at all.
|
||||
"""
|
||||
ss = self.create("test_enumerate_mutable_shares")
|
||||
|
||||
# Initially, nothing exists:
|
||||
empty = ss.enumerate_mutable_shares(b"si1")
|
||||
|
||||
self.allocate(ss, b"si1", b"we1", b"le1", [0, 1, 4, 2], 12)
|
||||
shares0_1_2_4 = ss.enumerate_mutable_shares(b"si1")
|
||||
|
||||
# Remove share 2, by setting size to 0:
|
||||
secrets = (self.write_enabler(b"we1"),
|
||||
self.renew_secret(b"le1"),
|
||||
self.cancel_secret(b"le1"))
|
||||
ss.slot_testv_and_readv_and_writev(b"si1", secrets, {2: ([], [], 0)}, [])
|
||||
shares0_1_4 = ss.enumerate_mutable_shares(b"si1")
|
||||
self.assertEqual(
|
||||
(empty, shares0_1_2_4, shares0_1_4),
|
||||
(set(), {0, 1, 2, 4}, {0, 1, 4})
|
||||
)
|
||||
|
||||
def test_mutable_share_length(self):
|
||||
"""``get_mutable_share_length()`` returns the length of the share."""
|
||||
ss = self.create("test_mutable_share_length")
|
||||
self.allocate(ss, b"si1", b"we1", b"le1", [16], 23)
|
||||
ss.slot_testv_and_readv_and_writev(
|
||||
b"si1", (self.write_enabler(b"we1"),
|
||||
self.renew_secret(b"le1"),
|
||||
self.cancel_secret(b"le1")),
|
||||
{16: ([], [(0, b"x" * 23)], None)},
|
||||
[]
|
||||
)
|
||||
self.assertEqual(ss.get_mutable_share_length(b"si1", 16), 23)
|
||||
|
||||
def test_mutable_share_length_unknown(self):
|
||||
"""
|
||||
``get_mutable_share_length()`` raises a ``KeyError`` on unknown shares.
|
||||
"""
|
||||
ss = self.create("test_mutable_share_length_unknown")
|
||||
self.allocate(ss, b"si1", b"we1", b"le1", [16], 23)
|
||||
ss.slot_testv_and_readv_and_writev(
|
||||
b"si1", (self.write_enabler(b"we1"),
|
||||
self.renew_secret(b"le1"),
|
||||
self.cancel_secret(b"le1")),
|
||||
{16: ([], [(0, b"x" * 23)], None)},
|
||||
[]
|
||||
)
|
||||
with self.assertRaises(KeyError):
|
||||
# Wrong share number.
|
||||
ss.get_mutable_share_length(b"si1", 17)
|
||||
with self.assertRaises(KeyError):
|
||||
# Wrong storage index
|
||||
ss.get_mutable_share_length(b"unknown", 16)
|
||||
|
||||
def test_bad_magic(self):
|
||||
ss = self.create("test_bad_magic")
|
||||
self.allocate(ss, b"si1", b"we1", next(self._lease_secret), set([0]), 10)
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -12,7 +12,7 @@ from cryptography import x509
|
||||
|
||||
from twisted.internet.endpoints import serverFromString
|
||||
from twisted.internet import reactor
|
||||
from twisted.internet.task import deferLater
|
||||
from twisted.internet.defer import maybeDeferred
|
||||
from twisted.web.server import Site
|
||||
from twisted.web.static import Data
|
||||
from twisted.web.client import Agent, HTTPConnectionPool, ResponseNeverReceived
|
||||
@ -30,6 +30,7 @@ from ..storage.http_common import get_spki_hash
|
||||
from ..storage.http_client import _StorageClientHTTPSPolicy
|
||||
from ..storage.http_server import _TLSEndpointWrapper
|
||||
from ..util.deferredutil import async_to_deferred
|
||||
from .common_system import spin_until_cleanup_done
|
||||
|
||||
|
||||
class HTTPSNurlTests(SyncTestCase):
|
||||
@ -87,6 +88,10 @@ class PinningHTTPSValidation(AsyncTestCase):
|
||||
self.addCleanup(self._port_assigner.tearDown)
|
||||
return AsyncTestCase.setUp(self)
|
||||
|
||||
def tearDown(self):
|
||||
d = maybeDeferred(AsyncTestCase.tearDown, self)
|
||||
return d.addCallback(lambda _: spin_until_cleanup_done())
|
||||
|
||||
@asynccontextmanager
|
||||
async def listen(self, private_key_path: FilePath, cert_path: FilePath):
|
||||
"""
|
||||
@ -107,9 +112,6 @@ class PinningHTTPSValidation(AsyncTestCase):
|
||||
yield f"https://127.0.0.1:{listening_port.getHost().port}/"
|
||||
finally:
|
||||
await listening_port.stopListening()
|
||||
# Make sure all server connections are closed :( No idea why this
|
||||
# is necessary when it's not for IStorageServer HTTPS tests.
|
||||
await deferLater(reactor, 0.01)
|
||||
|
||||
def request(self, url: str, expected_certificate: x509.Certificate):
|
||||
"""
|
||||
|
@ -161,7 +161,7 @@ class BucketCounter(unittest.TestCase, pollmixin.PollMixin):
|
||||
html = renderSynchronously(w)
|
||||
s = remove_tags(html)
|
||||
self.failUnlessIn(b"Total buckets: 0 (the number of", s)
|
||||
self.failUnless(b"Next crawl in 59 minutes" in s or "Next crawl in 60 minutes" in s, s)
|
||||
self.failUnless(b"Next crawl in 59 minutes" in s or b"Next crawl in 60 minutes" in s, s)
|
||||
d.addCallback(_check2)
|
||||
return d
|
||||
|
||||
|
@ -117,11 +117,17 @@ class CountingDataUploadable(upload.Data):
|
||||
|
||||
|
||||
class SystemTest(SystemTestMixin, RunBinTahoeMixin, unittest.TestCase):
|
||||
|
||||
"""Foolscap integration-y tests."""
|
||||
FORCE_FOOLSCAP_FOR_STORAGE = True
|
||||
timeout = 180
|
||||
|
||||
@property
|
||||
def basedir(self):
|
||||
return "system/SystemTest/{}-foolscap-{}".format(
|
||||
self.id().split(".")[-1], self.FORCE_FOOLSCAP_FOR_STORAGE
|
||||
)
|
||||
|
||||
def test_connections(self):
|
||||
self.basedir = "system/SystemTest/test_connections"
|
||||
d = self.set_up_nodes()
|
||||
self.extra_node = None
|
||||
d.addCallback(lambda res: self.add_extra_node(self.numclients))
|
||||
@ -149,11 +155,9 @@ class SystemTest(SystemTestMixin, RunBinTahoeMixin, unittest.TestCase):
|
||||
del test_connections
|
||||
|
||||
def test_upload_and_download_random_key(self):
|
||||
self.basedir = "system/SystemTest/test_upload_and_download_random_key"
|
||||
return self._test_upload_and_download(convergence=None)
|
||||
|
||||
def test_upload_and_download_convergent(self):
|
||||
self.basedir = "system/SystemTest/test_upload_and_download_convergent"
|
||||
return self._test_upload_and_download(convergence=b"some convergence string")
|
||||
|
||||
def _test_upload_and_download(self, convergence):
|
||||
@ -516,7 +520,6 @@ class SystemTest(SystemTestMixin, RunBinTahoeMixin, unittest.TestCase):
|
||||
|
||||
|
||||
def test_mutable(self):
|
||||
self.basedir = "system/SystemTest/test_mutable"
|
||||
DATA = b"initial contents go here." # 25 bytes % 3 != 0
|
||||
DATA_uploadable = MutableData(DATA)
|
||||
NEWDATA = b"new contents yay"
|
||||
@ -746,7 +749,6 @@ class SystemTest(SystemTestMixin, RunBinTahoeMixin, unittest.TestCase):
|
||||
# plaintext_hash check.
|
||||
|
||||
def test_filesystem(self):
|
||||
self.basedir = "system/SystemTest/test_filesystem"
|
||||
self.data = LARGE_DATA
|
||||
d = self.set_up_nodes()
|
||||
def _new_happy_semantics(ign):
|
||||
@ -1713,7 +1715,6 @@ class SystemTest(SystemTestMixin, RunBinTahoeMixin, unittest.TestCase):
|
||||
def test_filesystem_with_cli_in_subprocess(self):
|
||||
# We do this in a separate test so that test_filesystem doesn't skip if we can't run bin/tahoe.
|
||||
|
||||
self.basedir = "system/SystemTest/test_filesystem_with_cli_in_subprocess"
|
||||
d = self.set_up_nodes()
|
||||
def _new_happy_semantics(ign):
|
||||
for c in self.clients:
|
||||
@ -1794,9 +1795,21 @@ class SystemTest(SystemTestMixin, RunBinTahoeMixin, unittest.TestCase):
|
||||
|
||||
|
||||
class Connections(SystemTestMixin, unittest.TestCase):
|
||||
FORCE_FOOLSCAP_FOR_STORAGE = True
|
||||
|
||||
def test_rref(self):
|
||||
self.basedir = "system/Connections/rref"
|
||||
# The way the listening port is created is via
|
||||
# SameProcessStreamEndpointAssigner (allmydata.test.common), which then
|
||||
# makes an endpoint string parsed by AdoptedServerPort. The latter does
|
||||
# dup(fd), which results in the filedescriptor staying alive _until the
|
||||
# test ends_. That means that when we disown the service, we still have
|
||||
# the listening port there on the OS level! Just the resulting
|
||||
# connections aren't handled. So this test relies on aggressive
|
||||
# timeouts in the HTTP client and presumably some equivalent in
|
||||
# Foolscap, since connection refused does _not_ happen.
|
||||
self.basedir = "system/Connections/rref-foolscap-{}".format(
|
||||
self.FORCE_FOOLSCAP_FOR_STORAGE
|
||||
)
|
||||
d = self.set_up_nodes(2)
|
||||
def _start(ign):
|
||||
self.c0 = self.clients[0]
|
||||
@ -1812,9 +1825,13 @@ class Connections(SystemTestMixin, unittest.TestCase):
|
||||
|
||||
# now shut down the server
|
||||
d.addCallback(lambda ign: self.clients[1].disownServiceParent())
|
||||
|
||||
# kill any persistent http connections that might continue to work
|
||||
d.addCallback(lambda ign: self.close_idle_http_connections())
|
||||
|
||||
# and wait for the client to notice
|
||||
def _poll():
|
||||
return len(self.c0.storage_broker.get_connected_servers()) < 2
|
||||
return len(self.c0.storage_broker.get_connected_servers()) == 1
|
||||
d.addCallback(lambda ign: self.poll(_poll))
|
||||
|
||||
def _down(ign):
|
||||
@ -1824,3 +1841,16 @@ class Connections(SystemTestMixin, unittest.TestCase):
|
||||
self.assertEqual(storage_server, self.s1_storage_server)
|
||||
d.addCallback(_down)
|
||||
return d
|
||||
|
||||
|
||||
class HTTPSystemTest(SystemTest):
|
||||
"""HTTP storage protocol variant of the system tests."""
|
||||
|
||||
FORCE_FOOLSCAP_FOR_STORAGE = False
|
||||
|
||||
|
||||
|
||||
class HTTPConnections(Connections):
|
||||
"""HTTP storage protocol variant of the connections tests."""
|
||||
FORCE_FOOLSCAP_FOR_STORAGE = False
|
||||
|
||||
|
@ -46,9 +46,10 @@ from hypothesis.strategies import (
|
||||
binary,
|
||||
)
|
||||
|
||||
from testtools import (
|
||||
TestCase,
|
||||
from .common import (
|
||||
SyncTestCase,
|
||||
)
|
||||
|
||||
from testtools.matchers import (
|
||||
Always,
|
||||
Equals,
|
||||
@ -61,7 +62,7 @@ from testtools.twistedsupport import (
|
||||
)
|
||||
|
||||
|
||||
class FakeWebTest(TestCase):
|
||||
class FakeWebTest(SyncTestCase):
|
||||
"""
|
||||
Test the WebUI verified-fakes infrastucture
|
||||
"""
|
||||
|
@ -983,7 +983,7 @@ class EncodingParameters(GridTestMixin, unittest.TestCase, SetDEPMixin,
|
||||
num_segments = encoder.get_param("num_segments")
|
||||
d = selector.get_shareholders(broker, sh, storage_index,
|
||||
share_size, block_size, num_segments,
|
||||
10, 3, 4)
|
||||
10, 3, 4, encoder.get_uri_extension_size())
|
||||
def _have_shareholders(upload_trackers_and_already_servers):
|
||||
(upload_trackers, already_servers) = upload_trackers_and_already_servers
|
||||
assert servers_to_break <= len(upload_trackers)
|
||||
|
120
src/allmydata/util/pid.py
Normal file
120
src/allmydata/util/pid.py
Normal file
@ -0,0 +1,120 @@
|
||||
import psutil
|
||||
|
||||
# the docs are a little misleading, but this is either WindowsFileLock
|
||||
# or UnixFileLock depending upon the platform we're currently on
|
||||
from filelock import FileLock, Timeout
|
||||
|
||||
|
||||
class ProcessInTheWay(Exception):
|
||||
"""
|
||||
our pidfile points at a running process
|
||||
"""
|
||||
|
||||
|
||||
class InvalidPidFile(Exception):
|
||||
"""
|
||||
our pidfile isn't well-formed
|
||||
"""
|
||||
|
||||
|
||||
class CannotRemovePidFile(Exception):
|
||||
"""
|
||||
something went wrong removing the pidfile
|
||||
"""
|
||||
|
||||
|
||||
def _pidfile_to_lockpath(pidfile):
|
||||
"""
|
||||
internal helper.
|
||||
:returns FilePath: a path to use for file-locking the given pidfile
|
||||
"""
|
||||
return pidfile.sibling("{}.lock".format(pidfile.basename()))
|
||||
|
||||
|
||||
def parse_pidfile(pidfile):
|
||||
"""
|
||||
:param FilePath pidfile:
|
||||
:returns tuple: 2-tuple of pid, creation-time as int, float
|
||||
:raises InvalidPidFile: on error
|
||||
"""
|
||||
with pidfile.open("r") as f:
|
||||
content = f.read().decode("utf8").strip()
|
||||
try:
|
||||
pid, starttime = content.split()
|
||||
pid = int(pid)
|
||||
starttime = float(starttime)
|
||||
except ValueError:
|
||||
raise InvalidPidFile(
|
||||
"found invalid PID file in {}".format(
|
||||
pidfile
|
||||
)
|
||||
)
|
||||
return pid, starttime
|
||||
|
||||
|
||||
def check_pid_process(pidfile):
|
||||
"""
|
||||
If another instance appears to be running already, raise an
|
||||
exception. Otherwise, write our PID + start time to the pidfile
|
||||
and arrange to delete it upon exit.
|
||||
|
||||
:param FilePath pidfile: the file to read/write our PID from.
|
||||
|
||||
:raises ProcessInTheWay: if a running process exists at our PID
|
||||
"""
|
||||
lock_path = _pidfile_to_lockpath(pidfile)
|
||||
|
||||
try:
|
||||
# a short timeout is fine, this lock should only be active
|
||||
# while someone is reading or deleting the pidfile .. and
|
||||
# facilitates testing the locking itself.
|
||||
with FileLock(lock_path.path, timeout=2):
|
||||
# check if we have another instance running already
|
||||
if pidfile.exists():
|
||||
pid, starttime = parse_pidfile(pidfile)
|
||||
try:
|
||||
# if any other process is running at that PID, let the
|
||||
# user decide if this is another legitimate
|
||||
# instance. Automated programs may use the start-time to
|
||||
# help decide this (if the PID is merely recycled, the
|
||||
# start-time won't match).
|
||||
psutil.Process(pid)
|
||||
raise ProcessInTheWay(
|
||||
"A process is already running as PID {}".format(pid)
|
||||
)
|
||||
except psutil.NoSuchProcess:
|
||||
print(
|
||||
"'{pidpath}' refers to {pid} that isn't running".format(
|
||||
pidpath=pidfile.path,
|
||||
pid=pid,
|
||||
)
|
||||
)
|
||||
# nothing is running at that PID so it must be a stale file
|
||||
pidfile.remove()
|
||||
|
||||
# write our PID + start-time to the pid-file
|
||||
proc = psutil.Process()
|
||||
with pidfile.open("w") as f:
|
||||
f.write("{} {}\n".format(proc.pid, proc.create_time()).encode("utf8"))
|
||||
except Timeout:
|
||||
raise ProcessInTheWay(
|
||||
"Another process is still locking {}".format(pidfile.path)
|
||||
)
|
||||
|
||||
|
||||
def cleanup_pidfile(pidfile):
|
||||
"""
|
||||
Remove the pidfile specified (respecting locks). If anything at
|
||||
all goes wrong, `CannotRemovePidFile` is raised.
|
||||
"""
|
||||
lock_path = _pidfile_to_lockpath(pidfile)
|
||||
with FileLock(lock_path.path):
|
||||
try:
|
||||
pidfile.remove()
|
||||
except Exception as e:
|
||||
raise CannotRemovePidFile(
|
||||
"Couldn't remove '{pidfile}': {err}.".format(
|
||||
pidfile=pidfile.path,
|
||||
err=e,
|
||||
)
|
||||
)
|
2
tox.ini
2
tox.ini
@ -97,7 +97,7 @@ setenv =
|
||||
COVERAGE_PROCESS_START=.coveragerc
|
||||
commands =
|
||||
# NOTE: 'run with "py.test --keep-tempdir -s -v integration/" to debug failures'
|
||||
py.test --timeout=1800 --coverage -v {posargs:integration}
|
||||
py.test --timeout=1800 --coverage -s -v {posargs:integration}
|
||||
coverage combine
|
||||
coverage report
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user