diff --git a/.mailmap b/.mailmap index 2330062d6a..c45be7c95f 100644 --- a/.mailmap +++ b/.mailmap @@ -51,7 +51,7 @@ Tom Fifield Tom Fifield Sascha Peilicke Sascha Peilicke Zhenguo Niu Peter Portante -Christian Schwede +Christian Schwede Constantine Peresypkin Madhuri Kumari madhuri Morgan Fainberg @@ -70,3 +70,5 @@ Jing Liuqing Lorcan Browne Eohyung Lee Harshit Chitalia +Richard Hawkins +Sarvesh Ranjan diff --git a/AUTHORS b/AUTHORS index b7d9573de0..be3c5deeb9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -29,6 +29,7 @@ Mehdi Abaakouk (mehdi.abaakouk@enovance.com) Jesse Andrews (anotherjesse@gmail.com) Joe Arnold (joe@swiftstack.com) Ionuț Arțăriși (iartarisi@suse.cz) +Bob Ball (bob.ball@citrix.com) Christian Berendt (berendt@b1-systems.de) Luis de Bethencourt (luis@debethencourt.com) Keshava Bharadwaj (kb.sankethi@gmail.com) @@ -60,10 +61,13 @@ Cedric Dos Santos (cedric.dos.sant@gmail.com) Gerry Drudy (gerry.drudy@hp.com) Morgan Fainberg (morgan.fainberg@gmail.com) ZhiQiang Fan (aji.zqfan@gmail.com) +Mike Fedosin (mfedosin@mirantis.com) +Ricardo Ferreira (ricardo.sff@gmail.com) Flaper Fesp (flaper87@gmail.com) Tom Fifield (tom@openstack.org) Florent Flament (florent.flament-ext@cloudwatt.com) Gaurav B. Gangalwar (gaurav@gluster.com) +Jiangmiao Gao (tolbkni@gmail.com) Alex Gaynor (alex.gaynor@gmail.com) Martin Geisler (martin@geisler.net) Anne Gentle (anne@openstack.org) @@ -71,12 +75,13 @@ Clay Gerrard (clay.gerrard@gmail.com) Filippo Giunchedi (fgiunchedi@wikimedia.org) Mark Gius (launchpad@markgius.com) David Goetz (david.goetz@rackspace.com) +Tushar Gohad (tushar.gohad@intel.com) Jonathan Gonzalez V (jonathan.abdiel@gmail.com) Joe Gordon (jogo@cloudscaling.com) David Hadas (davidh@il.ibm.com) Andrew Hale (andy@wwwdata.eu) Soren Hansen (soren@linux2go.dk) -Richard (Rick) Hawkins (richard.hawkins@rackspace.com) +Richard Hawkins (richard.hawkins@rackspace.com) Gregory Haynes (greg@greghaynes.net) Doug Hellmann (doug.hellmann@dreamhost.com) Dan Hersam (dan.hersam@hp.com) @@ -94,6 +99,7 @@ Paul Jimenez (pj@place.org) Zhang Jinnan (ben.os@99cloud.net) Jason Johnson (jajohnson@softlayer.com) Brian K. Jones (bkjones@gmail.com) +Arnaud JOST (arnaud.jost@ovh.net) Kiyoung Jung (kiyoung.jung@kt.com) Takashi Kajinami (kajinamit@nttdata.co.jp) Matt Kassawara (mkassawara@gmail.com) @@ -104,6 +110,7 @@ Dae S. Kim (dae@velatum.com) Nathan Kinder (nkinder@redhat.com) Eugene Kirpichov (ekirpichov@gmail.com) Leah Klearman (lklrmn@gmail.com) +Martin Kletzander (mkletzan@redhat.com) Steve Kowalik (steven@wedontsleep.org) Sergey Kraynev (skraynev@mirantis.com) Sushil Kumar (sushil.kumar2@globallogic.com) @@ -155,6 +162,7 @@ Constantine Peresypkin (constantine.peresypk@rackspace.com) Dieter Plaetinck (dieter@vimeo.com) Dan Prince (dprince@redhat.com) Felipe Reyes (freyes@tty.cl) +Janie Richling (jrichli@us.ibm.com) Matt Riedemann (mriedem@us.ibm.com) Li Riqiang (lrqrun@gmail.com) Rafael Rivero (rafael@cloudscaling.com) @@ -163,10 +171,11 @@ Aaron Rosen (arosen@nicira.com) Brent Roskos (broskos@internap.com) Shilla Saebi (shilla.saebi@gmail.com) Cristian A Sanchez (cristian.a.sanchez@intel.com) -saranjan (saranjan@cisco.com) -Christian Schwede (info@cschwede.de) +Sarvesh Ranjan (saranjan@cisco.com) +Christian Schwede (christian.schwede@enovance.com) Mark Seger (Mark.Seger@hp.com) Andrew Clay Shafer (acs@parvuscaptus.com) +Mitsuhiro SHIGEMATSU (shigematsu.mitsuhiro@lab.ntt.co.jp) Dhriti Shikhar (dhrish20@gmail.com) Chuck Short (chuck.short@canonical.com) Michael Shuler (mshuler@gmail.com) diff --git a/CHANGELOG b/CHANGELOG index b60b5b2051..1e7bd5ff36 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,63 @@ +swift (2.3.0) + + * Erasure Code support (beta) + + Swift now supports an erasure-code (EC) storage policy type. This allows + deployers to achieve very high durability with less raw capacity as used + in replicated storage. However, EC requires more CPU and network + resources, so it is not good for every use case. EC is great for storing + large, infrequently accessed data in a single region. + + Swift's implementation of erasure codes is meant to be transparent to + end users. There is no API difference between replicated storage and + EC storage. + + To support erasure codes, Swift now depends on PyECLib and + liberasurecode. liberasurecode is a pluggable library that allows for + the actual EC algorithm to be implemented in a library of your choosing. + + As a beta release, EC support is nearly fully feature complete, but it + is lacking support for some features (like multi-range reads) and has + not had a full performance characterization. This feature relies on + ssync for durability. Deployers are urged to do extensive testing and + not deploy production data using an erasure code storage policy. + + Full docs are at http://swift.openstack.org/overview_erasure_code.html + + * Add support for container TempURL Keys. + + * Make more memcache options configurable. connection_timeout, + pool_timeout, tries, and io_timeout are all now configurable. + + * Swift now supports composite tokens. This allows another service to + act on behalf of a user, but only with that user's consent. + See http://swift.openstack.org/overview_auth.html for more details. + + * Multi-region replication was improved. When replicating data to a + different region, only one replica will be pushed per replication + cycle. This gives the remote region a chance to replicate the data + locally instead of pushing more data over the inter-region network. + + * Internal requests from the ratelimit middleware now properly log a + swift_source. See http://swift.openstack.org/logs.html for details. + + * Improved storage policy support for quarantine stats in swift-recon. + + * The proxy log line now includes the request's storage policy index. + + * Ring checker has been added to swift-recon to validate if rings are + built correctly. As part of this feature, storage servers have learned + the OPTIONS verb. + + * Add support of x-remove- headers for container-sync. + + * Rings now support hostnames instead of just IP addresses. + + * Swift now enforces that the API version on a request is valid. Valid + versions are configured via the valid_api_versions setting in swift.conf + + * Various other minor bug fixes and improvements. + swift (2.2.2) * Data placement changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ba46daf99..6a81d6a8c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,29 @@ we won't be able to respond to pull requests submitted through GitHub. Bugs should be filed [on Launchpad](https://bugs.launchpad.net/swift), not in GitHub's issue tracker. + +Swift Design Principles +======================= + + * [The Zen of Python](http://legacy.python.org/dev/peps/pep-0020/) + * Simple Scales + * Minimal dependencies + * Re-use existing tools and libraries when reasonable + * Leverage the economies of scale + * Small, loosely coupled RESTful services + * No single points of failure + * Start with the use case + * ... then design from the cluster operator up + * If you haven't argued about it, you don't have the right answer yet :) + * If it is your first implementation, you probably aren't done yet :) + +Please don't feel offended by difference of opinion. Be prepared to advocate +for your change and iterate on it based on feedback. Reach out to other people +working on the project on +[IRC](http://eavesdrop.openstack.org/irclogs/%23openstack-swift/) or the +[mailing list](http://lists.openstack.org/pipermail/openstack-dev/) - we want +to help. + Recommended workflow ==================== diff --git a/bin/swift-drive-audit b/bin/swift-drive-audit index 589b255f22..ea17357998 100755 --- a/bin/swift-drive-audit +++ b/bin/swift-drive-audit @@ -176,6 +176,7 @@ if __name__ == '__main__': if not devices: logger.error("Error: No devices found!") recon_errors = {} + total_errors = 0 for device in devices: recon_errors[device['mount_point']] = 0 errors = get_errors(error_re, log_file_pattern, minutes, logger) @@ -198,8 +199,10 @@ if __name__ == '__main__': comment_fstab(mount_point) unmounts += 1 recon_errors[mount_point] = count + total_errors += count recon_file = recon_cache_path + "/drive.recon" dump_recon_cache(recon_errors, recon_file, logger) + dump_recon_cache({'drive_audit_errors': total_errors}, recon_file, logger) if unmounts == 0: logger.info("No drives were unmounted") diff --git a/bin/swift-object-reconstructor b/bin/swift-object-reconstructor new file mode 100755 index 0000000000..ee4c5d6436 --- /dev/null +++ b/bin/swift-object-reconstructor @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# Copyright (c) 2010-2012 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from swift.obj.reconstructor import ObjectReconstructor +from swift.common.utils import parse_options +from swift.common.daemon import run_daemon +from optparse import OptionParser + +if __name__ == '__main__': + parser = OptionParser("%prog CONFIG [options]") + parser.add_option('-d', '--devices', + help='Reconstruct only given devices. ' + 'Comma-separated list') + parser.add_option('-p', '--partitions', + help='Reconstruct only given partitions. ' + 'Comma-separated list') + conf_file, options = parse_options(parser=parser, once=True) + run_daemon(ObjectReconstructor, conf_file, **options) diff --git a/doc/manpages/container-server.conf.5 b/doc/manpages/container-server.conf.5 index a6bb699758..93408cf7ad 100644 --- a/doc/manpages/container-server.conf.5 +++ b/doc/manpages/container-server.conf.5 @@ -270,6 +270,10 @@ If you need to use an HTTP Proxy, set it here; defaults to no proxy. Will audit, at most, each container once per interval. The default is 300 seconds. .IP \fBcontainer_time\fR Maximum amount of time to spend syncing each container per pass. The default is 60 seconds. +.IP \fBrequest_retries\fR +Server errors from requests will be retried by default. +.IP \fBinternal_client_conf_path\fR +Internal client config file path. .RE .PD diff --git a/doc/manpages/swift-recon.1 b/doc/manpages/swift-recon.1 index 4311c99add..c635861aca 100644 --- a/doc/manpages/swift-recon.1 +++ b/doc/manpages/swift-recon.1 @@ -62,16 +62,32 @@ Get replication stats Check cluster for unmounted devices .IP "\fB-d, --diskusage\fR" Get disk usage stats +.IP "\fB--top=COUNT\fR" +Also show the top COUNT entries in rank order +.IP "\fB--lowest=COUNT\fR" +Also show the lowest COUNT entries in rank order +.IP "\fB--human-readable\fR" +Use human readable suffix for disk usage stats .IP "\fB-l, --loadstats\fR" Get cluster load average stats .IP "\fB-q, --quarantined\fR" Get cluster quarantine stats +.IP "\fB--validate-servers\fR" +Validate servers on the ring .IP "\fB--md5\fR" -Get md5sum of servers ring and compare to local cop +Get md5sum of servers ring and compare to local copy +.IP "\fB--sockstat\fR" +Get cluster socket usage stats +.IP "\fB--driveaudit\fR" +Get drive audit error stats .IP "\fB--all\fR" Perform all checks. Equivalent to \-arudlq \-\-md5 +.IP "\fB--region=REGION\fR" +Only query servers in specified region .IP "\fB-z ZONE, --zone=ZONE\fR" Only query servers in specified zone +.IP "\fB-t SECONDS, --timeout=SECONDS\fR" +Time to wait for a response from a server .IP "\fB--swiftdir=PATH\fR" Default = /etc/swift .PD diff --git a/doc/saio/bin/remakerings b/doc/saio/bin/remakerings index e95915953c..1452cea739 100755 --- a/doc/saio/bin/remakerings +++ b/doc/saio/bin/remakerings @@ -16,6 +16,16 @@ swift-ring-builder object-1.builder add r1z2-127.0.0.1:6020/sdb2 1 swift-ring-builder object-1.builder add r1z3-127.0.0.1:6030/sdb3 1 swift-ring-builder object-1.builder add r1z4-127.0.0.1:6040/sdb4 1 swift-ring-builder object-1.builder rebalance +swift-ring-builder object-2.builder create 10 6 1 +swift-ring-builder object-2.builder add r1z1-127.0.0.1:6010/sdb1 1 +swift-ring-builder object-2.builder add r1z1-127.0.0.1:6010/sdb5 1 +swift-ring-builder object-2.builder add r1z2-127.0.0.1:6020/sdb2 1 +swift-ring-builder object-2.builder add r1z2-127.0.0.1:6020/sdb6 1 +swift-ring-builder object-2.builder add r1z3-127.0.0.1:6030/sdb3 1 +swift-ring-builder object-2.builder add r1z3-127.0.0.1:6030/sdb7 1 +swift-ring-builder object-2.builder add r1z4-127.0.0.1:6040/sdb4 1 +swift-ring-builder object-2.builder add r1z4-127.0.0.1:6040/sdb8 1 +swift-ring-builder object-2.builder rebalance swift-ring-builder container.builder create 10 3 1 swift-ring-builder container.builder add r1z1-127.0.0.1:6011/sdb1 1 swift-ring-builder container.builder add r1z2-127.0.0.1:6021/sdb2 1 diff --git a/doc/saio/bin/resetswift b/doc/saio/bin/resetswift index dd2692f7da..c7c9d9eae9 100755 --- a/doc/saio/bin/resetswift +++ b/doc/saio/bin/resetswift @@ -9,7 +9,10 @@ sudo mkfs.xfs -f ${SAIO_BLOCK_DEVICE:-/dev/sdb1} sudo mount /mnt/sdb1 sudo mkdir /mnt/sdb1/1 /mnt/sdb1/2 /mnt/sdb1/3 /mnt/sdb1/4 sudo chown ${USER}:${USER} /mnt/sdb1/* -mkdir -p /srv/1/node/sdb1 /srv/2/node/sdb2 /srv/3/node/sdb3 /srv/4/node/sdb4 +mkdir -p /srv/1/node/sdb1 /srv/1/node/sdb5 \ + /srv/2/node/sdb2 /srv/2/node/sdb6 \ + /srv/3/node/sdb3 /srv/3/node/sdb7 \ + /srv/4/node/sdb4 /srv/4/node/sdb8 sudo rm -f /var/log/debug /var/log/messages /var/log/rsyncd.log /var/log/syslog find /var/cache/swift* -type f -name *.recon -exec rm -f {} \; # On Fedora use "systemctl restart " diff --git a/doc/saio/swift/object-server/1.conf b/doc/saio/swift/object-server/1.conf index c0300ee558..178e3fcba3 100644 --- a/doc/saio/swift/object-server/1.conf +++ b/doc/saio/swift/object-server/1.conf @@ -22,6 +22,8 @@ use = egg:swift#recon [object-replicator] vm_test_mode = yes +[object-reconstructor] + [object-updater] [object-auditor] diff --git a/doc/saio/swift/object-server/2.conf b/doc/saio/swift/object-server/2.conf index 71d373a48e..6b611ca25a 100644 --- a/doc/saio/swift/object-server/2.conf +++ b/doc/saio/swift/object-server/2.conf @@ -22,6 +22,8 @@ use = egg:swift#recon [object-replicator] vm_test_mode = yes +[object-reconstructor] + [object-updater] [object-auditor] diff --git a/doc/saio/swift/object-server/3.conf b/doc/saio/swift/object-server/3.conf index 4c103b3041..7352592319 100644 --- a/doc/saio/swift/object-server/3.conf +++ b/doc/saio/swift/object-server/3.conf @@ -22,6 +22,8 @@ use = egg:swift#recon [object-replicator] vm_test_mode = yes +[object-reconstructor] + [object-updater] [object-auditor] diff --git a/doc/saio/swift/object-server/4.conf b/doc/saio/swift/object-server/4.conf index c51d12215e..be1211047b 100644 --- a/doc/saio/swift/object-server/4.conf +++ b/doc/saio/swift/object-server/4.conf @@ -22,6 +22,8 @@ use = egg:swift#recon [object-replicator] vm_test_mode = yes +[object-reconstructor] + [object-updater] [object-auditor] diff --git a/doc/saio/swift/swift.conf b/doc/saio/swift/swift.conf index 4d8b014e8e..25e1002646 100644 --- a/doc/saio/swift/swift.conf +++ b/doc/saio/swift/swift.conf @@ -5,7 +5,16 @@ swift_hash_path_suffix = changeme [storage-policy:0] name = gold +policy_type = replication default = yes [storage-policy:1] name = silver +policy_type = replication + +[storage-policy:2] +name = ec42 +policy_type = erasure_coding +ec_type = jerasure_rs_vand +ec_num_data_fragments = 4 +ec_num_parity_fragments = 2 diff --git a/doc/source/admin_guide.rst b/doc/source/admin_guide.rst index d96353c848..5b7a02850a 100644 --- a/doc/source/admin_guide.rst +++ b/doc/source/admin_guide.rst @@ -88,6 +88,16 @@ attempting to write to or read the builder/ring files while operations are in progress. This can be useful in environments where ring management has been automated but the operator still needs to interact with the rings manually. +If the ring builder is not producing the balances that you are +expecting, you can gain visibility into what it's doing with the +``--debug`` flag.:: + + swift-ring-builder rebalance --debug + +This produces a great deal of output that is mostly useful if you are +either (a) attempting to fix the ring builder, or (b) filing a bug +against the ring builder. + ----------------------- Scripting Ring Creation ----------------------- diff --git a/doc/source/associated_projects.rst b/doc/source/associated_projects.rst index 72ed9c016d..c0f8cf7e5d 100644 --- a/doc/source/associated_projects.rst +++ b/doc/source/associated_projects.rst @@ -104,5 +104,7 @@ Other * `Swiftsync `_ - A massive syncer between two swift clusters. * `Django Swiftbrowser `_ - Simple Django web app to access Openstack Swift. * `Swift-account-stats `_ - Swift-account-stats is a tool to report statistics on Swift usage at tenant and global levels. +* `PyECLib `_ - High Level Erasure Code library used by Swift +* `liberasurecode `_ - Low Level Erasure Code library used by PyECLib * `Swift Browser `_ - JavaScript interface for Swift * `swift-ui `_ - OpenStack Swift web browser diff --git a/doc/source/development_guidelines.rst b/doc/source/development_guidelines.rst index 76b5126be7..241eda6cf5 100644 --- a/doc/source/development_guidelines.rst +++ b/doc/source/development_guidelines.rst @@ -70,6 +70,35 @@ When using the 'in-process test' mode, the optional in-memory object server may be selected by setting the environment variable ``SWIFT_TEST_IN_MEMORY_OBJ`` to a true value. +The 'in-process test' mode searches for ``proxy-server.conf`` and +``swift.conf`` config files from which it copies config options and overrides +some options to suit in process testing. The search will first look for config +files in a ```` that may optionally be specified using +the environment variable:: + + SWIFT_TEST_IN_PROCESS_CONF_DIR= + +If ``SWIFT_TEST_IN_PROCESS_CONF_DIR`` is not set, or if a config file is not +found in ````, the search will then look in the +``etc/`` directory in the source tree. If the config file is still not found, +the corresponding sample config file from ``etc/`` is used (e.g. +``proxy-server.conf-sample`` or ``swift.conf-sample``). + +The environment variable ``SWIFT_TEST_POLICY`` may be set to specify +a particular storage policy *name* that will be used for testing. When set, +this policy must exist in the ``swift.conf`` file and its corresponding ring +file must exist in ```` (if specified) or ``etc/``. The +test setup will set the specified policy to be the default and use its ring +file properties for constructing the test object ring. This allows in-process +testing to be run against various policy types and ring files. + +For example, this command would run the in-process mode functional tests +using config files found in ``$HOME/my_tests`` and policy 'silver':: + + SWIFT_TEST_IN_PROCESS=1 SWIFT_TEST_IN_PROCESS_CONF_DIR=$HOME/my_tests \ + SWIFT_TEST_POLICY=silver tox -e func + + ------------ Coding Style ------------ diff --git a/doc/source/development_saio.rst b/doc/source/development_saio.rst index 338b1420ce..3bd94872dd 100644 --- a/doc/source/development_saio.rst +++ b/doc/source/development_saio.rst @@ -87,8 +87,11 @@ another device when creating the VM, and follow these instructions: sudo chown ${USER}:${USER} /mnt/sdb1/* sudo mkdir /srv for x in {1..4}; do sudo ln -s /mnt/sdb1/$x /srv/$x; done - sudo mkdir -p /srv/1/node/sdb1 /srv/2/node/sdb2 /srv/3/node/sdb3 \ - /srv/4/node/sdb4 /var/run/swift + sudo mkdir -p /srv/1/node/sdb1 /srv/1/node/sdb5 \ + /srv/2/node/sdb2 /srv/2/node/sdb6 \ + /srv/3/node/sdb3 /srv/3/node/sdb7 \ + /srv/4/node/sdb4 /srv/4/node/sdb8 \ + /var/run/swift sudo chown -R ${USER}:${USER} /var/run/swift # **Make sure to include the trailing slash after /srv/$x/** for x in {1..4}; do sudo chown -R ${USER}:${USER} /srv/$x/; done @@ -124,7 +127,11 @@ these instructions: sudo mkdir /mnt/sdb1/1 /mnt/sdb1/2 /mnt/sdb1/3 /mnt/sdb1/4 sudo chown ${USER}:${USER} /mnt/sdb1/* for x in {1..4}; do sudo ln -s /mnt/sdb1/$x /srv/$x; done - sudo mkdir -p /srv/1/node/sdb1 /srv/2/node/sdb2 /srv/3/node/sdb3 /srv/4/node/sdb4 /var/run/swift + sudo mkdir -p /srv/1/node/sdb1 /srv/1/node/sdb5 \ + /srv/2/node/sdb2 /srv/2/node/sdb6 \ + /srv/3/node/sdb3 /srv/3/node/sdb7 \ + /srv/4/node/sdb4 /srv/4/node/sdb8 \ + /var/run/swift sudo chown -R ${USER}:${USER} /var/run/swift # **Make sure to include the trailing slash after /srv/$x/** for x in {1..4}; do sudo chown -R ${USER}:${USER} /srv/$x/; done @@ -402,7 +409,7 @@ Setting up scripts for running Swift #. Copy the SAIO scripts for resetting the environment:: - cd $HOME/swift/doc; cp -r saio/bin $HOME/bin; cd - + cd $HOME/swift/doc; cp saio/bin/* $HOME/bin; cd - chmod +x $HOME/bin/* #. Edit the ``$HOME/bin/resetswift`` script @@ -455,30 +462,41 @@ Setting up scripts for running Swift .. literalinclude:: /../saio/bin/remakerings - You can expect the output from this command to produce the following (note - that 2 object rings are created in order to test storage policies in the - SAIO environment however they map to the same nodes):: + You can expect the output from this command to produce the following. Note + that 3 object rings are created in order to test storage policies and EC in + the SAIO environment. The EC ring is the only one with all 8 devices. + There are also two replication rings, one for 3x replication and another + for 2x replication, but those rings only use 4 devices:: Device d0r1z1-127.0.0.1:6010R127.0.0.1:6010/sdb1_"" with 1.0 weight got id 0 Device d1r1z2-127.0.0.1:6020R127.0.0.1:6020/sdb2_"" with 1.0 weight got id 1 Device d2r1z3-127.0.0.1:6030R127.0.0.1:6030/sdb3_"" with 1.0 weight got id 2 Device d3r1z4-127.0.0.1:6040R127.0.0.1:6040/sdb4_"" with 1.0 weight got id 3 - Reassigned 1024 (100.00%) partitions. Balance is now 0.00. + Reassigned 1024 (100.00%) partitions. Balance is now 0.00. Dispersion is now 0.00 Device d0r1z1-127.0.0.1:6010R127.0.0.1:6010/sdb1_"" with 1.0 weight got id 0 Device d1r1z2-127.0.0.1:6020R127.0.0.1:6020/sdb2_"" with 1.0 weight got id 1 Device d2r1z3-127.0.0.1:6030R127.0.0.1:6030/sdb3_"" with 1.0 weight got id 2 Device d3r1z4-127.0.0.1:6040R127.0.0.1:6040/sdb4_"" with 1.0 weight got id 3 - Reassigned 1024 (100.00%) partitions. Balance is now 0.00. + Reassigned 1024 (100.00%) partitions. Balance is now 0.00. Dispersion is now 0.00 + Device d0r1z1-127.0.0.1:6010R127.0.0.1:6010/sdb1_"" with 1.0 weight got id 0 + Device d1r1z1-127.0.0.1:6010R127.0.0.1:6010/sdb5_"" with 1.0 weight got id 1 + Device d2r1z2-127.0.0.1:6020R127.0.0.1:6020/sdb2_"" with 1.0 weight got id 2 + Device d3r1z2-127.0.0.1:6020R127.0.0.1:6020/sdb6_"" with 1.0 weight got id 3 + Device d4r1z3-127.0.0.1:6030R127.0.0.1:6030/sdb3_"" with 1.0 weight got id 4 + Device d5r1z3-127.0.0.1:6030R127.0.0.1:6030/sdb7_"" with 1.0 weight got id 5 + Device d6r1z4-127.0.0.1:6040R127.0.0.1:6040/sdb4_"" with 1.0 weight got id 6 + Device d7r1z4-127.0.0.1:6040R127.0.0.1:6040/sdb8_"" with 1.0 weight got id 7 + Reassigned 1024 (100.00%) partitions. Balance is now 0.00. Dispersion is now 0.00 Device d0r1z1-127.0.0.1:6011R127.0.0.1:6011/sdb1_"" with 1.0 weight got id 0 Device d1r1z2-127.0.0.1:6021R127.0.0.1:6021/sdb2_"" with 1.0 weight got id 1 Device d2r1z3-127.0.0.1:6031R127.0.0.1:6031/sdb3_"" with 1.0 weight got id 2 Device d3r1z4-127.0.0.1:6041R127.0.0.1:6041/sdb4_"" with 1.0 weight got id 3 - Reassigned 1024 (100.00%) partitions. Balance is now 0.00. + Reassigned 1024 (100.00%) partitions. Balance is now 0.00. Dispersion is now 0.00 Device d0r1z1-127.0.0.1:6012R127.0.0.1:6012/sdb1_"" with 1.0 weight got id 0 Device d1r1z2-127.0.0.1:6022R127.0.0.1:6022/sdb2_"" with 1.0 weight got id 1 Device d2r1z3-127.0.0.1:6032R127.0.0.1:6032/sdb3_"" with 1.0 weight got id 2 Device d3r1z4-127.0.0.1:6042R127.0.0.1:6042/sdb4_"" with 1.0 weight got id 3 - Reassigned 1024 (100.00%) partitions. Balance is now 0.00. + Reassigned 1024 (100.00%) partitions. Balance is now 0.00. Dispersion is now 0.00 #. Read more about Storage Policies and your SAIO :doc:`policies_saio` diff --git a/doc/source/howto_installmultinode.rst b/doc/source/howto_installmultinode.rst index 7b37cb0776..8ab73232d3 100644 --- a/doc/source/howto_installmultinode.rst +++ b/doc/source/howto_installmultinode.rst @@ -2,7 +2,7 @@ Instructions for a Multiple Server Swift Installation ===================================================== -Please refer to the latest offical +Please refer to the latest official `Openstack Installation Guides `_ for the most up-to-date documentation. diff --git a/doc/source/images/ec_overview.png b/doc/source/images/ec_overview.png new file mode 100755 index 0000000000..d44a103177 Binary files /dev/null and b/doc/source/images/ec_overview.png differ diff --git a/doc/source/index.rst b/doc/source/index.rst index 630e6bd70e..45ee1fd0ef 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -56,6 +56,7 @@ Overview and Concepts overview_expiring_objects cors crossdomain + overview_erasure_code overview_backing_store associated_projects diff --git a/doc/source/overview_architecture.rst b/doc/source/overview_architecture.rst index b8c9a32f75..1f3452a55c 100644 --- a/doc/source/overview_architecture.rst +++ b/doc/source/overview_architecture.rst @@ -11,7 +11,10 @@ Proxy Server The Proxy Server is responsible for tying together the rest of the Swift architecture. For each request, it will look up the location of the account, container, or object in the ring (see below) and route the request accordingly. -The public API is also exposed through the Proxy Server. +For Erasure Code type policies, the Proxy Server is also responsible for +encoding and decoding object data. See :doc:`overview_erasure_code` for +complete information on Erasure Code suport. The public API is also exposed +through the Proxy Server. A large number of failures are also handled in the Proxy Server. For example, if a server is unavailable for an object PUT, it will ask the @@ -87,7 +90,8 @@ implementing a particular differentiation. For example, one might have the default policy with 3x replication, and create a second policy which, when applied to new containers only uses 2x replication. Another might add SSDs to a set of storage nodes and create a performance tier -storage policy for certain containers to have their objects stored there. +storage policy for certain containers to have their objects stored there. Yet +another might be the use of Erasure Coding to define a cold-storage tier. This mapping is then exposed on a per-container basis, where each container can be assigned a specific storage policy when it is created, which remains in @@ -156,6 +160,15 @@ item (object, container, or account) is deleted, a tombstone is set as the latest version of the item. The replicator will see the tombstone and ensure that the item is removed from the entire system. +-------------- +Reconstruction +-------------- + +The reconstructor is used by Erasure Code policies and is analogous to the +replicator for Replication type policies. See :doc:`overview_erasure_code` +for complete information on both Erasure Code support as well as the +reconstructor. + -------- Updaters -------- diff --git a/doc/source/overview_erasure_code.rst b/doc/source/overview_erasure_code.rst new file mode 100755 index 0000000000..9927e2ace2 --- /dev/null +++ b/doc/source/overview_erasure_code.rst @@ -0,0 +1,672 @@ +==================== +Erasure Code Support +==================== + + +-------------------------- +Beta: Not production ready +-------------------------- +The erasure code support in Swift is considered "beta" at this point. +Most major functionality is included, but it has not been tested or validated +at large scale. This feature relies on ssync for durability. Deployers are +urged to do extensive testing and not deploy production data using an +erasure code storage policy. + +If any bugs are found during testing, please report them to +https://bugs.launchpad.net/swift + + +------------------------------- +History and Theory of Operation +------------------------------- + +There's a lot of good material out there on Erasure Code (EC) theory, this short +introduction is just meant to provide some basic context to help the reader +better understand the implementation in Swift. + +Erasure Coding for storage applications grew out of Coding Theory as far back as +the 1960s with the Reed-Solomon codes. These codes have been used for years in +applications ranging from CDs to DVDs to general communications and, yes, even +in the space program starting with Voyager! The basic idea is that some amount +of data is broken up into smaller pieces called fragments and coded in such a +way that it can be transmitted with the ability to tolerate the loss of some +number of the coded fragments. That's where the word "erasure" comes in, if you +transmit 14 fragments and only 13 are received then one of them is said to be +"erased". The word "erasure" provides an important distinction with EC; it +isn't about detecting errors, it's about dealing with failures. Another +important element of EC is that the number of erasures that can be tolerated can +be adjusted to meet the needs of the application. + +At a high level EC works by using a specific scheme to break up a single data +buffer into several smaller data buffers then, depending on the scheme, +performing some encoding operation on that data in order to generate additional +information. So you end up with more data than you started with and that extra +data is often called "parity". Note that there are many, many different +encoding techniques that vary both in how they organize and manipulate the data +as well by what means they use to calculate parity. For example, one scheme +might rely on `Galois Field Arithmetic `_ while others may work with only XOR. The number of variations and +details about their differences are well beyond the scope of this introduction, +but we will talk more about a few of them when we get into the implementation of +EC in Swift. + +-------------------------------- +Overview of EC Support in Swift +-------------------------------- + +First and foremost, from an application perspective EC support is totally +transparent. There are no EC related external API; a container is simply created +using a Storage Policy defined to use EC and then interaction with the cluster +is the same as any other durability policy. + +EC is implemented in Swift as a Storage Policy, see :doc:`overview_policies` for +complete details on Storage Policies. Because support is implemented as a +Storage Policy, all of the storage devices associated with your cluster's EC +capability can be isolated. It is entirely possible to share devices between +storage policies, but for EC it may make more sense to not only use separate +devices but possibly even entire nodes dedicated for EC. + +Which direction one chooses depends on why the EC policy is being deployed. If, +for example, there is a production replication policy in place already and the +goal is to add a cold storage tier such that the existing nodes performing +replication are impacted as little as possible, adding a new set of nodes +dedicated to EC might make the most sense but also incurs the most cost. On the +other hand, if EC is being added as a capability to provide additional +durability for a specific set of applications and the existing infrastructure is +well suited for EC (sufficient number of nodes, zones for the EC scheme that is +chosen) then leveraging the existing infrastructure such that the EC ring shares +nodes with the replication ring makes the most sense. These are some of the +main considerations: + +* Layout of existing infrastructure. +* Cost of adding dedicated EC nodes (or just dedicated EC devices). +* Intended usage model(s). + +The Swift code base does not include any of the algorithms necessary to perform +the actual encoding and decoding of data; that is left to external libraries. +The Storage Policies architecture is leveraged to enable EC on a per container +basis -- the object rings are still used to determine the placement of EC data +fragments. Although there are several code paths that are unique to an operation +associated with an EC policy, an external dependency to an Erasure Code library +is what Swift counts on to perform the low level EC functions. The use of an +external library allows for maximum flexibility as there are a significant +number of options out there, each with its owns pros and cons that can vary +greatly from one use case to another. + +--------------------------------------- +PyECLib: External Erasure Code Library +--------------------------------------- + +PyECLib is a Python Erasure Coding Library originally designed and written as +part of the effort to add EC support to the Swift project, however it is an +independent project. The library provides a well-defined and simple Python +interface and internally implements a plug-in architecture allowing it to take +advantage of many well-known C libraries such as: + +* Jerasure and GFComplete at http://jerasure.org. +* Intel(R) ISA-L at http://01.org/intel%C2%AE-storage-acceleration-library-open-source-version. +* Or write your own! + +PyECLib uses a C based library called liberasurecode to implement the plug in +infrastructure; liberasure code is available at: + +* liberasurecode: https://bitbucket.org/tsg-/liberasurecode + +PyECLib itself therefore allows for not only choice but further extensibility as +well. PyECLib also comes with a handy utility to help determine the best +algorithm to use based on the equipment that will be used (processors and server +configurations may vary in performance per algorithm). More on this will be +covered in the configuration section. PyECLib is included as a Swift +requirement. + +For complete details see `PyECLib `_ + +------------------------------ +Storing and Retrieving Objects +------------------------------ + +We will discuss the details of how PUT and GET work in the "Under the Hood" +section later on. The key point here is that all of the erasure code work goes +on behind the scenes; this summary is a high level information overview only. + +The PUT flow looks like this: + +#. The proxy server streams in an object and buffers up "a segment" of data + (size is configurable). +#. The proxy server calls on PyECLib to encode the data into smaller fragments. +#. The proxy streams the encoded fragments out to the storage nodes based on + ring locations. +#. Repeat until the client is done sending data. +#. The client is notified of completion when a quorum is met. + +The GET flow looks like this: + +#. The proxy server makes simultaneous requests to participating nodes. +#. As soon as the proxy has the fragments it needs, it calls on PyECLib to + decode the data. +#. The proxy streams the decoded data it has back to the client. +#. Repeat until the proxy is done sending data back to the client. + +It may sound like, from this high level overview, that using EC is going to +cause an explosion in the number of actual files stored in each node's local +file system. Although it is true that more files will be stored (because an +object is broken into pieces), the implementation works to minimize this where +possible, more details are available in the Under the Hood section. + +------------- +Handoff Nodes +------------- + +In EC policies, similarly to replication, handoff nodes are a set of storage +nodes used to augment the list of primary nodes responsible for storing an +erasure coded object. These handoff nodes are used in the event that one or more +of the primaries are unavailable. Handoff nodes are still selected with an +attempt to achieve maximum separation of the data being placed. + +-------------- +Reconstruction +-------------- + +For an EC policy, reconstruction is analogous to the process of replication for +a replication type policy -- essentially "the reconstructor" replaces "the +replicator" for EC policy types. The basic framework of reconstruction is very +similar to that of replication with a few notable exceptions: + +* Because EC does not actually replicate partitions, it needs to operate at a + finer granularity than what is provided with rsync, therefore EC leverages + much of ssync behind the scenes (you do not need to manually configure ssync). +* Once a pair of nodes has determined the need to replace a missing object + fragment, instead of pushing over a copy like replication would do, the + reconstructor has to read in enough surviving fragments from other nodes and + perform a local reconstruction before it has the correct data to push to the + other node. +* A reconstructor does not talk to all other reconstructors in the set of nodes + responsible for an EC partition, this would be far too chatty, instead each + reconstructor is responsible for sync'ing with the partition's closest two + neighbors (closest meaning left and right on the ring). + +.. note:: + + EC work (encode and decode) takes place both on the proxy nodes, for PUT/GET + operations, as well as on the storage nodes for reconstruction. As with + replication, reconstruction can be the result of rebalancing, bit-rot, drive + failure or reverting data from a hand-off node back to its primary. + +-------------------------- +Performance Considerations +-------------------------- + +Efforts are underway to characterize performance of various Erasure Code +schemes. One of the main goals of the beta release is to perform this +characterization and encourage others to do so and provide meaningful feedback +to the development community. There are many factors that will affect +performance of EC so it is vital that we have multiple characterization +activities happening. + +In general, EC has different performance characteristics than replicated data. +EC requires substantially more CPU to read and write data, and is more suited +for larger objects that are not frequently accessed (eg backups). + +---------------------------- +Using an Erasure Code Policy +---------------------------- + +To use an EC policy, the administrator simply needs to define an EC policy in +`swift.conf` and create/configure the associated object ring. An example of how +an EC policy can be setup is shown below:: + + [storage-policy:2] + name = ec104 + policy_type = erasure_coding + ec_type = jerasure_rs_vand + ec_num_data_fragments = 10 + ec_num_parity_fragments = 4 + ec_object_segment_size = 1048576 + +Let's take a closer look at each configuration parameter: + +* ``name``: This is a standard storage policy parameter. + See :doc:`overview_policies` for details. +* ``policy_type``: Set this to ``erasure_coding`` to indicate that this is an EC + policy. +* ``ec_type``: Set this value according to the available options in the selected + PyECLib back-end. This specifies the EC scheme that is to be used. For + example the option shown here selects Vandermonde Reed-Solomon encoding while + an option of ``flat_xor_hd_3`` would select Flat-XOR based HD combination + codes. See the `PyECLib `_ page for + full details. +* ``ec_num_data_fragments``: The total number of fragments that will be + comprised of data. +* ``ec_num_parity_fragments``: The total number of fragments that will be + comprised of parity. +* ``ec_object_segment_size``: The amount of data that will be buffered up before + feeding a segment into the encoder/decoder. The default value is 1048576. + +When PyECLib encodes an object, it will break it into N fragments. However, what +is important during configuration, is how many of those are data and how many +are parity. So in the example above, PyECLib will actually break an object in +14 different fragments, 10 of them will be made up of actual object data and 4 +of them will be made of parity data (calculations depending on ec_type). + +When deciding which devices to use in the EC policy's object ring, be sure to +carefully consider the performance impacts. Running some performance +benchmarking in a test environment for your configuration is highly recommended +before deployment. Once you have configured your EC policy in `swift.conf` and +created your object ring, your application is ready to start using EC simply by +creating a container with the specified policy name and interacting as usual. + +.. note:: + + It's important to note that once you have deployed a policy and have created + objects with that policy, these configurations options cannot be changed. In + case a change in the configuration is desired, you must create a new policy + and migrate the data to a new container. + +Migrating Between Policies +-------------------------- + +A common usage of EC is to migrate less commonly accessed data from a more +expensive but lower latency policy such as replication. When an application +determines that it wants to move data from a replication policy to an EC policy, +it simply needs to move the data from the replicated container to an EC +container that was created with the target durability policy. + +Region Support +-------------- + +For at least the initial version of EC, it is not recommended that an EC scheme +span beyond a single region, neither performance nor functional validation has +be been done in such a configuration. + +-------------- +Under the Hood +-------------- + +Now that we've explained a little about EC support in Swift and how to +configure/use it, let's explore how EC fits in at the nuts-n-bolts level. + +Terminology +----------- + +The term 'fragment' has been used already to describe the output of the EC +process (a series of fragments) however we need to define some other key terms +here before going any deeper. Without paying special attention to using the +correct terms consistently, it is very easy to get confused in a hurry! + +* **chunk**: HTTP chunks received over wire (term not used to describe any EC + specific operation). +* **segment**: Not to be confused with SLO/DLO use of the word, in EC we call a + segment a series of consecutive HTTP chunks buffered up before performing an + EC operation. +* **fragment**: Data and parity 'fragments' are generated when erasure coding + transformation is applied to a segment. +* **EC archive**: A concatenation of EC fragments; to a storage node this looks + like an object. +* **ec_ndata**: Number of EC data fragments. +* **ec_nparity**: Number of EC parity fragments. + +Middleware +---------- + +Middleware remains unchanged. For most middleware (e.g., SLO/DLO) the fact that +the proxy is fragmenting incoming objects is transparent. For list endpoints, +however, it is a bit different. A caller of list endpoints will get back the +locations of all of the fragments. The caller will be unable to re-assemble the +original object with this information, however the node locations may still +prove to be useful information for some applications. + +On Disk Storage +--------------- + +EC archives are stored on disk in their respective objects-N directory based on +their policy index. See :doc:`overview_policies` for details on per policy +directory information. + +The actual names on disk of EC archives also have one additional piece of data +encoded in the filename, the fragment archive index. + +Each storage policy now must include a transformation function that diskfile +will use to build the filename to store on disk. The functions are implemented +in the diskfile module as policy specific sub classes ``DiskFileManager``. + +This is required for a few reasons. For one, it allows us to store fragment +archives of different indexes on the same storage node which is not typical +however it is possible in many circumstances. Without unique filenames for the +different EC archive files in a set, we would be at risk of overwriting one +archive of index n with another of index m in some scenarios. + +The transformation function for the replication policy is simply a NOP. For +reconstruction, the index is appended to the filename just before the .data +extension. An example filename for a fragment archive storing the 5th fragment +would like this this:: + + 1418673556.92690#5.data + +An additional file is also included for Erasure Code policies called the +``.durable`` file. Its meaning will be covered in detail later, however, its on- +disk format does not require the name transformation function that was just +covered. The .durable for the example above would simply look like this:: + + 1418673556.92690.durable + +And it would be found alongside every fragment specific .data file following a +100% successful PUT operation. + +Proxy Server +------------ + +High Level +========== + +The Proxy Server handles Erasure Coding in a different manner than replication, +therefore there are several code paths unique to EC policies either though sub +classing or simple conditionals. Taking a closer look at the PUT and the GET +paths will help make this clearer. But first, a high level overview of how an +object flows through the system: + +.. image:: images/ec_overview.png + +Note how: + +* Incoming objects are buffered into segments at the proxy. +* Segments are erasure coded into fragments at the proxy. +* The proxy stripes fragments across participating nodes such that the on-disk + stored files that we call a fragment archive is appended with each new + fragment. + +This scheme makes it possible to minimize the number of on-disk files given our +segmenting and fragmenting. + +Multi_Phase Conversation +======================== + +Multi-part MIME document support is used to allow the proxy to engage in a +handshake conversation with the storage node for processing PUT requests. This +is required for a few different reasons. + +#. From the perspective of the storage node, a fragment archive is really just + another object, we need a mechanism to send down the original object etag + after all fragment archives have landed. +#. Without introducing strong consistency semantics, the proxy needs a mechanism + to know when a quorum of fragment archives have actually made it to disk + before it can inform the client of a successful PUT. + +MIME supports a conversation between the proxy and the storage nodes for every +PUT. This provides us with the ability to handle a PUT in one connection and +assure that we have the essence of a 2 phase commit, basically having the proxy +communicate back to the storage nodes once it has confirmation that all fragment +archives in the set have been committed. Note that we still require a quorum of +data elements of the conversation to complete before signaling status to the +client but we can relax that requirement for the commit phase such that only 2 +confirmations to that phase of the conversation are required for success as the +reconstructor will assure propagation of markers that indicate data durability. + +This provides the storage node with a cheap indicator of the last known durable +set of fragment archives for a given object on a successful durable PUT, this is +known as the ``.durable`` file. The presence of a ``.durable`` file means, to +the object server, `there is a set of ts.data files that are durable at +timestamp ts.` Note that the completion of the commit phase of the conversation +is also a signal for the object server to go ahead and immediately delete older +timestamp files for this object. This is critical as we do not want to delete +the older object until the storage node has confirmation from the proxy, via the +multi-phase conversation, that the other nodes have landed enough for a quorum. + +The basic flow looks like this: + + * The Proxy Server erasure codes and streams the object fragments + (ec_ndata + ec_nparity) to the storage nodes. + * The storage nodes store objects as EC archives and upon finishing object + data/metadata write, send a 1st-phase response to proxy. + * Upon quorum of storage nodes responses, the proxy initiates 2nd-phase by + sending commit confirmations to object servers. + * Upon receipt of commit message, object servers store a 0-byte data file as + `.durable` indicating successful PUT, and send a final response to + the proxy server. + * The proxy waits for a minimal number of two object servers to respond with a + success (2xx) status before responding to the client with a successful + status. In this particular case it was decided that two responses was + the mininum amount to know that the file would be propagated in case of + failure from other others and because a greater number would potentially + mean more latency, which should be avoided if possible. + +Here is a high level example of what the conversation looks like:: + + proxy: PUT /p/a/c/o + Transfer-Encoding': 'chunked' + Expect': '100-continue' + X-Backend-Obj-Multiphase-Commit: yes + obj: 100 Continue + X-Obj-Multiphase-Commit: yes + proxy: --MIMEboundary + X-Document: object body + + --MIMEboundary + X-Document: object metadata + Content-MD5: + + --MIMEboundary + + obj: 100 Continue + + proxy: X-Document: put commit + commit_confirmation + --MIMEboundary-- + + obj: 20x + =2 2xx responses> + proxy: 2xx -> client + +A few key points on the .durable file: + +* The .durable file means \"the matching .data file for this has sufficient + fragment archives somewhere, committed, to reconstruct the object\". +* The Proxy Server will never have knowledge, either on GET or HEAD, of the + existence of a .data file on an object server if it does not have a matching + .durable file. +* The object server will never return a .data that does not have a matching + .durable. +* When a proxy does a GET, it will only receive fragment archives that have + enough present somewhere to be reconstructed. + +Partial PUT Failures +==================== + +A partial PUT failure has a few different modes. In one scenario the Proxy +Server is alive through the entire PUT conversation. This is a very +straightforward case. The client will receive a good response if and only if a +quorum of fragment archives were successfully landed on their storage nodes. In +this case the Reconstructor will discover the missing fragment archives, perform +a reconstruction and deliver fragment archives and their matching .durable files +to the nodes. + +The more interesting case is what happens if the proxy dies in the middle of a +conversation. If it turns out that a quorum had been met and the commit phase +of the conversation finished, its as simple as the previous case in that the +reconstructor will repair things. However, if the commit didn't get a change to +happen then some number of the storage nodes have .data files on them (fragment +archives) but none of them knows whether there are enough elsewhere for the +entire object to be reconstructed. In this case the client will not have +received a 2xx response so there is no issue there, however, it is left to the +storage nodes to clean up the stale fragment archives. Work is ongoing in this +area to enable the proxy to play a role in reviving these fragment archives, +however, for the current release, a proxy failure after the start of a +conversation but before the commit message will simply result in a PUT failure. + +GET +=== + +The GET for EC is different enough from replication that subclassing the +`BaseObjectController` to the `ECObjectController` enables an efficient way to +implement the high level steps described earlier: + +#. The proxy server makes simultaneous requests to participating nodes. +#. As soon as the proxy has the fragments it needs, it calls on PyECLib to + decode the data. +#. The proxy streams the decoded data it has back to the client. +#. Repeat until the proxy is done sending data back to the client. + +The GET path will attempt to contact all nodes participating in the EC scheme, +if not enough primaries respond then handoffs will be contacted just as with +replication. Etag and content length headers are updated for the client +response following reconstruction as the individual fragment archives metadata +is valid only for that fragment archive. + +Object Server +------------- + +The Object Server, like the Proxy Server, supports MIME conversations as +described in the proxy section earlier. This includes processing of the commit +message and decoding various sections of the MIME document to extract the footer +which includes things like the entire object etag. + +DiskFile +======== + +Erasure code uses subclassed ``ECDiskFile``, ``ECDiskFileWriter`` and +``ECDiskFileManager`` to impement EC specific handling of on disk files. This +includes things like file name manipulation to include the fragment index in the +filename, determination of valid .data files based on .durable presence, +construction of EC specific hashes.pkl file to include fragment index +information, etc., etc. + +Metadata +-------- + +There are few different categories of metadata that are associated with EC: + +System Metadata: EC has a set of object level system metadata that it +attaches to each of the EC archives. The metadata is for internal use only: + +* ``X-Object-Sysmeta-EC-Etag``: The Etag of the original object. +* ``X-Object-Sysmeta-EC-Content-Length``: The content length of the original + object. +* ``X-Object-Sysmeta-EC-Frag-Index``: The fragment index for the object. +* ``X-Object-Sysmeta-EC-Scheme``: Description of the EC policy used to encode + the object. +* ``X-Object-Sysmeta-EC-Segment-Size``: The segment size used for the object. + +User Metadata: User metadata is unaffected by EC, however, a full copy of the +user metadata is stored with every EC archive. This is required as the +reconstructor needs this information and each reconstructor only communicates +with its closest neighbors on the ring. + +PyECLib Metadata: PyECLib stores a small amount of metadata on a per fragment +basis. This metadata is not documented here as it is opaque to Swift. + +Database Updates +---------------- + +As account and container rings are not associated with a Storage Policy, there +is no change to how these database updates occur when using an EC policy. + +The Reconstructor +----------------- + +The Reconstructor performs analogous functions to the replicator: + +#. Recovery from disk drive failure. +#. Moving data around because of a rebalance. +#. Reverting data back to a primary from a handoff. +#. Recovering fragment archives from bit rot discovered by the auditor. + +However, under the hood it operates quite differently. The following are some +of the key elements in understanding how the reconstructor operates. + +Unlike the replicator, the work that the reconstructor does is not always as +easy to break down into the 2 basic tasks of synchronize or revert (move data +from handoff back to primary) because of the fact that one storage node can +house fragment archives of various indexes and each index really /"belongs/" to +a different node. So, whereas when the replicator is reverting data from a +handoff it has just one node to send its data to, the reconstructor can have +several. Additionally, its not always the case that the processing of a +particular suffix directory means one or the other for the entire directory (as +it does for replication). The scenarios that create these mixed situations can +be pretty complex so we will just focus on what the reconstructor does here and +not a detailed explanation of why. + +Job Construction and Processing +=============================== + +Because of the nature of the work it has to do as described above, the +reconstructor builds jobs for a single job processor. The job itself contains +all of the information needed for the processor to execute the job which may be +a synchronization or a data reversion and there may be a mix of jobs that +perform both of these operations on the same suffix directory. + +Jobs are constructed on a per partition basis and then per fragment index basis. +That is, there will be one job for every fragment index in a partition. +Performing this construction \"up front\" like this helps minimize the +interaction between nodes collecting hashes.pkl information. + +Once a set of jobs for a partition has been constructed, those jobs are sent off +to threads for execution. The single job processor then performs the necessary +actions working closely with ssync to carry out its instructions. For data +reversion, the actual objects themselves are cleaned up via the ssync module and +once that partition's set of jobs is complete, the reconstructor will attempt to +remove the relevant directory structures. + +The scenarios that job construction has to take into account include: + +#. A partition directory with all fragment indexes matching the local node + index. This is the case where everything is where it belongs and we just + need to compare hashes and sync if needed, here we sync with our partners. +#. A partition directory with one local fragment index and mix of others. Here + we need to sync with our partners where fragment indexes matches the + local_id, all others are sync'd with their home nodes and then deleted. +#. A partition directory with no local fragment index and just one or more of + others. Here we sync with just the home nodes for the fragment indexes that + we have and then all the local archives are deleted. This is the basic + handoff reversion case. + +.. note:: + A \"home node\" is the node where the fragment index encoded in the + fragment archive's filename matches the node index of a node in the primary + partition list. + +Node Communication +================== + +The replicators talk to all nodes who have a copy of their object, typically +just 2 other nodes. For EC, having each reconstructor node talk to all nodes +would incur a large amount of overhead as there will typically be a much larger +number of nodes participating in the EC scheme. Therefore, the reconstructor is +built to talk to its adjacent nodes on the ring only. These nodes are typically +referred to as partners. + +Reconstruction +============== + +Reconstruction can be thought of sort of like replication but with an extra step +in the middle. The reconstructor is hard-wired to use ssync to determine what is +missing and desired by the other side. However, before an object is sent over +the wire it needs to be reconstructed from the remaining fragments as the local +fragment is just that - a different fragment index than what the other end is +asking for. + +Thus, there are hooks in ssync for EC based policies. One case would be for +basic reconstruction which, at a high level, looks like this: + +* Determine which nodes need to be contacted to collect other EC archives needed + to perform reconstruction. +* Update the etag and fragment index metadata elements of the newly constructed + fragment archive. +* Establish a connection to the target nodes and give ssync a DiskFileLike class + that it can stream data from. + +The reader in this class gathers fragments from the nodes and uses PyECLib to +reconstruct each segment before yielding data back to ssync. Essentially what +this means is that data is buffered, in memory, on a per segment basis at the +node performing reconstruction and each segment is dynamically reconstructed and +delivered to `ssync_sender` where the `send_put()` method will ship them on +over. The sender is then responsible for deleting the objects as they are sent +in the case of data reversion. + +The Auditor +----------- + +Because the auditor already operates on a per storage policy basis, there are no +specific auditor changes associated with EC. Each EC archive looks like, and is +treated like, a regular object from the perspective of the auditor. Therefore, +if the auditor finds bit-rot in an EC archive, it simply quarantines it and the +reconstructor will take care of the rest just as the replicator does for +replication policies. diff --git a/doc/source/overview_policies.rst b/doc/source/overview_policies.rst index 9cabde6cf9..06c7fc79a2 100755 --- a/doc/source/overview_policies.rst +++ b/doc/source/overview_policies.rst @@ -8,22 +8,22 @@ feature is implemented throughout the entire code base so it is an important concept in understanding Swift architecture. As described in :doc:`overview_ring`, Swift uses modified hashing rings to -determine where data should reside in the cluster. There is a separate ring -for account databases, container databases, and there is also one object -ring per storage policy. Each object ring behaves exactly the same way -and is maintained in the same manner, but with policies, different devices -can belong to different rings with varying levels of replication. By supporting -multiple object rings, Swift allows the application and/or deployer to -essentially segregate the object storage within a single cluster. There are -many reasons why this might be desirable: +determine where data should reside in the cluster. There is a separate ring for +account databases, container databases, and there is also one object ring per +storage policy. Each object ring behaves exactly the same way and is maintained +in the same manner, but with policies, different devices can belong to different +rings. By supporting multiple object rings, Swift allows the application and/or +deployer to essentially segregate the object storage within a single cluster. +There are many reasons why this might be desirable: -* Different levels of replication: If a provider wants to offer, for example, - 2x replication and 3x replication but doesn't want to maintain 2 separate clusters, - they would setup a 2x policy and a 3x policy and assign the nodes to their - respective rings. +* Different levels of durability: If a provider wants to offer, for example, + 2x replication and 3x replication but doesn't want to maintain 2 separate + clusters, they would setup a 2x and a 3x replication policy and assign the + nodes to their respective rings. Furthermore, if a provider wanted to offer a + cold storage tier, they could create an erasure coded policy. -* Performance: Just as SSDs can be used as the exclusive members of an account or - database ring, an SSD-only object ring can be created as well and used to +* Performance: Just as SSDs can be used as the exclusive members of an account + or database ring, an SSD-only object ring can be created as well and used to implement a low-latency/high performance policy. * Collecting nodes into group: Different object rings may have different @@ -36,10 +36,12 @@ many reasons why this might be desirable: .. note:: - Today, choosing a different storage policy allows the use of different - object rings, but future policies (such as Erasure Coding) will also - change some of the actual code paths when processing a request. Also note - that Diskfile refers to backend object storage plug-in architecture. + Today, Swift supports two different policy types: Replication and Erasure + Code. Erasure Code policy is currently a beta release and should not be + used in a Production cluster. See :doc:`overview_erasure_code` for details. + + Also note that Diskfile refers to backend object storage plug-in + architecture. See :doc:`development_ondisk_backends` for details. ----------------------- Containers and Policies @@ -61,31 +63,33 @@ Policy-0 is considered the default). We will be covering the difference between default and Policy-0 in the next section. Policies are assigned when a container is created. Once a container has been -assigned a policy, it cannot be changed (unless it is deleted/recreated). The implications -on data placement/movement for large datasets would make this a task best left for -applications to perform. Therefore, if a container has an existing policy of, -for example 3x replication, and one wanted to migrate that data to a policy that specifies -a different replication level, the application would create another container -specifying the other policy name and then simply move the data from one container -to the other. Policies apply on a per container basis allowing for minimal application -awareness; once a container has been created with a specific policy, all objects stored -in it will be done so in accordance with that policy. If a container with a -specific name is deleted (requires the container be empty) a new container may -be created with the same name without any restriction on storage policy -enforced by the deleted container which previously shared the same name. +assigned a policy, it cannot be changed (unless it is deleted/recreated). The +implications on data placement/movement for large datasets would make this a +task best left for applications to perform. Therefore, if a container has an +existing policy of, for example 3x replication, and one wanted to migrate that +data to an Erasure Code policy, the application would create another container +specifying the other policy parameters and then simply move the data from one +container to the other. Policies apply on a per container basis allowing for +minimal application awareness; once a container has been created with a specific +policy, all objects stored in it will be done so in accordance with that policy. +If a container with a specific name is deleted (requires the container be empty) +a new container may be created with the same name without any restriction on +storage policy enforced by the deleted container which previously shared the +same name. Containers have a many-to-one relationship with policies meaning that any number -of containers can share one policy. There is no limit to how many containers can use -a specific policy. +of containers can share one policy. There is no limit to how many containers +can use a specific policy. -The notion of associating a ring with a container introduces an interesting scenario: -What would happen if 2 containers of the same name were created with different -Storage Policies on either side of a network outage at the same time? Furthermore, -what would happen if objects were placed in those containers, a whole bunch of them, -and then later the network outage was restored? Well, without special care it would -be a big problem as an application could end up using the wrong ring to try and find -an object. Luckily there is a solution for this problem, a daemon known as the -Container Reconciler works tirelessly to identify and rectify this potential scenario. +The notion of associating a ring with a container introduces an interesting +scenario: What would happen if 2 containers of the same name were created with +different Storage Policies on either side of a network outage at the same time? +Furthermore, what would happen if objects were placed in those containers, a +whole bunch of them, and then later the network outage was restored? Well, +without special care it would be a big problem as an application could end up +using the wrong ring to try and find an object. Luckily there is a solution for +this problem, a daemon known as the Container Reconciler works tirelessly to +identify and rectify this potential scenario. -------------------- Container Reconciler @@ -184,9 +188,9 @@ this case we would not use the default as it might not have the same policy as legacy containers. When no other policies are defined, Swift will always choose ``Policy-0`` as the default. -In other words, default means "create using this policy if nothing else is specified" -and ``Policy-0`` means "use the legacy policy if a container doesn't have one" which -really means use ``object.ring.gz`` for lookups. +In other words, default means "create using this policy if nothing else is +specified" and ``Policy-0`` means "use the legacy policy if a container doesn't +have one" which really means use ``object.ring.gz`` for lookups. .. note:: @@ -244,17 +248,19 @@ not mark the policy as deprecated to all nodes. Configuring Policies -------------------- -Policies are configured in ``swift.conf`` and it is important that the deployer have a solid -understanding of the semantics for configuring policies. Recall that a policy must have -a corresponding ring file, so configuring a policy is a two-step process. First, edit -your ``/etc/swift/swift.conf`` file to add your new policy and, second, create the -corresponding policy object ring file. +Policies are configured in ``swift.conf`` and it is important that the deployer +have a solid understanding of the semantics for configuring policies. Recall +that a policy must have a corresponding ring file, so configuring a policy is a +two-step process. First, edit your ``/etc/swift/swift.conf`` file to add your +new policy and, second, create the corresponding policy object ring file. -See :doc:`policies_saio` for a step by step guide on adding a policy to the SAIO setup. +See :doc:`policies_saio` for a step by step guide on adding a policy to the SAIO +setup. -Note that each policy has a section starting with ``[storage-policy:N]`` where N is the -policy index. There's no reason other than readability that these be sequential but there -are a number of rules enforced by Swift when parsing this file: +Note that each policy has a section starting with ``[storage-policy:N]`` where N +is the policy index. There's no reason other than readability that these be +sequential but there are a number of rules enforced by Swift when parsing this +file: * If a policy with index 0 is not declared and no other policies defined, Swift will create one @@ -269,9 +275,11 @@ are a number of rules enforced by Swift when parsing this file: * The policy name 'Policy-0' can only be used for the policy with index 0 * If any policies are defined, exactly one policy must be declared default * Deprecated policies cannot be declared the default + * If no ``policy_type`` is provided, ``replication`` is the default value. -The following is an example of a properly configured ``swift.conf`` file. See :doc:`policies_saio` -for full instructions on setting up an all-in-one with this example configuration.:: +The following is an example of a properly configured ``swift.conf`` file. See +:doc:`policies_saio` for full instructions on setting up an all-in-one with this +example configuration.:: [swift-hash] # random unique strings that can never change (DO NOT LOSE) @@ -280,10 +288,12 @@ for full instructions on setting up an all-in-one with this example configuratio [storage-policy:0] name = gold + policy_type = replication default = yes [storage-policy:1] name = silver + policy_type = replication deprecated = yes Review :ref:`default-policy` and :ref:`deprecate-policy` for more @@ -300,11 +310,14 @@ There are some other considerations when managing policies: the desired policy section, but a deprecated policy may not also be declared the default, and you must specify a default - so you must have policy which is not deprecated at all times. + * The option ``policy_type`` is used to distinguish between different + policy types. The default value is ``replication``. When defining an EC + policy use the value ``erasure_coding``. + * The EC policy has additional required parameters. See + :doc:`overview_erasure_code` for details. -There will be additional parameters for policies as new features are added -(e.g., Erasure Code), but for now only a section name/index and name are -required. Once ``swift.conf`` is configured for a new policy, a new ring must be -created. The ring tools are not policy name aware so it's critical that the +Once ``swift.conf`` is configured for a new policy, a new ring must be created. +The ring tools are not policy name aware so it's critical that the correct policy index be used when creating the new policy's ring file. Additional object rings are created in the same manner as the legacy ring except that '-N' is appended after the word ``object`` where N matches the @@ -404,43 +417,47 @@ Middleware ---------- Middleware can take advantage of policies through the :data:`.POLICIES` global -and by importing :func:`.get_container_info` to gain access to the policy -index associated with the container in question. From the index it -can then use the :data:`.POLICIES` singleton to grab the right ring. For example, +and by importing :func:`.get_container_info` to gain access to the policy index +associated with the container in question. From the index it can then use the +:data:`.POLICIES` singleton to grab the right ring. For example, :ref:`list_endpoints` is policy aware using the means just described. Another example is :ref:`recon` which will report the md5 sums for all of the rings. Proxy Server ------------ -The :ref:`proxy-server` module's role in Storage Policies is essentially to make sure the -correct ring is used as its member element. Before policies, the one object ring -would be instantiated when the :class:`.Application` class was instantiated and could -be overridden by test code via init parameter. With policies, however, there is -no init parameter and the :class:`.Application` class instead depends on the :data:`.POLICIES` -global singleton to retrieve the ring which is instantiated the first time it's -needed. So, instead of an object ring member of the :class:`.Application` class, there is -an accessor function, :meth:`~.Application.get_object_ring`, that gets the ring from :data:`.POLICIES`. +The :ref:`proxy-server` module's role in Storage Policies is essentially to make +sure the correct ring is used as its member element. Before policies, the one +object ring would be instantiated when the :class:`.Application` class was +instantiated and could be overridden by test code via init parameter. With +policies, however, there is no init parameter and the :class:`.Application` +class instead depends on the :data:`.POLICIES` global singleton to retrieve the +ring which is instantiated the first time it's needed. So, instead of an object +ring member of the :class:`.Application` class, there is an accessor function, +:meth:`~.Application.get_object_ring`, that gets the ring from +:data:`.POLICIES`. In general, when any module running on the proxy requires an object ring, it does so via first getting the policy index from the cached container info. The exception is during container creation where it uses the policy name from the -request header to look up policy index from the :data:`.POLICIES` global. Once the -proxy has determined the policy index, it can use the :meth:`~.Application.get_object_ring` method -described earlier to gain access to the correct ring. It then has the responsibility -of passing the index information, not the policy name, on to the back-end servers -via the header ``X-Backend-Storage-Policy-Index``. Going the other way, the proxy also -strips the index out of headers that go back to clients, and makes sure they only -see the friendly policy names. +request header to look up policy index from the :data:`.POLICIES` global. Once +the proxy has determined the policy index, it can use the +:meth:`~.Application.get_object_ring` method described earlier to gain access to +the correct ring. It then has the responsibility of passing the index +information, not the policy name, on to the back-end servers via the header ``X +-Backend-Storage-Policy-Index``. Going the other way, the proxy also strips the +index out of headers that go back to clients, and makes sure they only see the +friendly policy names. On Disk Storage --------------- -Policies each have their own directories on the back-end servers and are identified by -their storage policy indexes. Organizing the back-end directory structures by policy -index helps keep track of things and also allows for sharing of disks between policies -which may or may not make sense depending on the needs of the provider. More -on this later, but for now be aware of the following directory naming convention: +Policies each have their own directories on the back-end servers and are +identified by their storage policy indexes. Organizing the back-end directory +structures by policy index helps keep track of things and also allows for +sharing of disks between policies which may or may not make sense depending on +the needs of the provider. More on this later, but for now be aware of the +following directory naming convention: * ``/objects`` maps to objects associated with Policy-0 * ``/objects-N`` maps to storage policy index #N @@ -466,19 +483,19 @@ policy index and leaves the actual directory naming/structure mechanisms to :class:`.Diskfile` being used will assure that data is properly located in the tree based on its policy. -For the same reason, the :ref:`object-updater` also is policy aware. As previously -described, different policies use different async pending directories so the -updater needs to know how to scan them appropriately. +For the same reason, the :ref:`object-updater` also is policy aware. As +previously described, different policies use different async pending directories +so the updater needs to know how to scan them appropriately. -The :ref:`object-replicator` is policy aware in that, depending on the policy, it may have to -do drastically different things, or maybe not. For example, the difference in -handling a replication job for 2x versus 3x is trivial; however, the difference in -handling replication between 3x and erasure code is most definitely not. In -fact, the term 'replication' really isn't appropriate for some policies -like erasure code; however, the majority of the framework for collecting and -processing jobs is common. Thus, those functions in the replicator are -leveraged for all policies and then there is policy specific code required for -each policy, added when the policy is defined if needed. +The :ref:`object-replicator` is policy aware in that, depending on the policy, +it may have to do drastically different things, or maybe not. For example, the +difference in handling a replication job for 2x versus 3x is trivial; however, +the difference in handling replication between 3x and erasure code is most +definitely not. In fact, the term 'replication' really isn't appropriate for +some policies like erasure code; however, the majority of the framework for +collecting and processing jobs is common. Thus, those functions in the +replicator are leveraged for all policies and then there is policy specific code +required for each policy, added when the policy is defined if needed. The ssync functionality is policy aware for the same reason. Some of the other modules may not obviously be affected, but the back-end directory @@ -487,25 +504,26 @@ parameter. Therefore ssync being policy aware really means passing the policy index along. See :class:`~swift.obj.ssync_sender` and :class:`~swift.obj.ssync_receiver` for more information on ssync. -For :class:`.Diskfile` itself, being policy aware is all about managing the back-end -structure using the provided policy index. In other words, callers who get -a :class:`.Diskfile` instance provide a policy index and :class:`.Diskfile`'s job is to keep data -separated via this index (however it chooses) such that policies can share -the same media/nodes if desired. The included implementation of :class:`.Diskfile` -lays out the directory structure described earlier but that's owned within -:class:`.Diskfile`; external modules have no visibility into that detail. A common -function is provided to map various directory names and/or strings -based on their policy index. For example :class:`.Diskfile` defines :func:`.get_data_dir` -which builds off of a generic :func:`.get_policy_string` to consistently build -policy aware strings for various usage. +For :class:`.Diskfile` itself, being policy aware is all about managing the +back-end structure using the provided policy index. In other words, callers who +get a :class:`.Diskfile` instance provide a policy index and +:class:`.Diskfile`'s job is to keep data separated via this index (however it +chooses) such that policies can share the same media/nodes if desired. The +included implementation of :class:`.Diskfile` lays out the directory structure +described earlier but that's owned within :class:`.Diskfile`; external modules +have no visibility into that detail. A common function is provided to map +various directory names and/or strings based on their policy index. For example +:class:`.Diskfile` defines :func:`.get_data_dir` which builds off of a generic +:func:`.get_policy_string` to consistently build policy aware strings for +various usage. Container Server ---------------- -The :ref:`container-server` plays a very important role in Storage Policies, it is -responsible for handling the assignment of a policy to a container and the -prevention of bad things like changing policies or picking the wrong policy -to use when nothing is specified (recall earlier discussion on Policy-0 versus +The :ref:`container-server` plays a very important role in Storage Policies, it +is responsible for handling the assignment of a policy to a container and the +prevention of bad things like changing policies or picking the wrong policy to +use when nothing is specified (recall earlier discussion on Policy-0 versus default). The :ref:`container-updater` is policy aware, however its job is very simple, to @@ -538,19 +556,19 @@ migrated to be fully compatible with the post-storage-policy queries without having to fall back and retry queries with the legacy schema to service container read requests. -The :ref:`container-sync-daemon` functionality only needs to be policy aware in that it -accesses the object rings. Therefore, it needs to pull the policy index -out of the container information and use it to select the appropriate -object ring from the :data:`.POLICIES` global. +The :ref:`container-sync-daemon` functionality only needs to be policy aware in +that it accesses the object rings. Therefore, it needs to pull the policy index +out of the container information and use it to select the appropriate object +ring from the :data:`.POLICIES` global. Account Server -------------- -The :ref:`account-server`'s role in Storage Policies is really limited to reporting. -When a HEAD request is made on an account (see example provided earlier), -the account server is provided with the storage policy index and builds -the ``object_count`` and ``byte_count`` information for the client on a per -policy basis. +The :ref:`account-server`'s role in Storage Policies is really limited to +reporting. When a HEAD request is made on an account (see example provided +earlier), the account server is provided with the storage policy index and +builds the ``object_count`` and ``byte_count`` information for the client on a +per policy basis. The account servers are able to report per-storage-policy object and byte counts because of some policy specific DB schema changes. A policy specific @@ -564,23 +582,23 @@ pre-storage-policy accounts by altering the DB schema and populating the point in time. The per-storage-policy object and byte counts are not updated with each object -PUT and DELETE request, instead container updates to the account server are performed -asynchronously by the ``swift-container-updater``. +PUT and DELETE request, instead container updates to the account server are +performed asynchronously by the ``swift-container-updater``. .. _upgrade-policy: Upgrading and Confirming Functionality -------------------------------------- -Upgrading to a version of Swift that has Storage Policy support is not difficult, -in fact, the cluster administrator isn't required to make any special configuration -changes to get going. Swift will automatically begin using the existing object -ring as both the default ring and the Policy-0 ring. Adding the declaration of -policy 0 is totally optional and in its absence, the name given to the implicit -policy 0 will be 'Policy-0'. Let's say for testing purposes that you wanted to take -an existing cluster that already has lots of data on it and upgrade to Swift with -Storage Policies. From there you want to go ahead and create a policy and test a -few things out. All you need to do is: +Upgrading to a version of Swift that has Storage Policy support is not +difficult, in fact, the cluster administrator isn't required to make any special +configuration changes to get going. Swift will automatically begin using the +existing object ring as both the default ring and the Policy-0 ring. Adding the +declaration of policy 0 is totally optional and in its absence, the name given +to the implicit policy 0 will be 'Policy-0'. Let's say for testing purposes +that you wanted to take an existing cluster that already has lots of data on it +and upgrade to Swift with Storage Policies. From there you want to go ahead and +create a policy and test a few things out. All you need to do is: #. Upgrade all of your Swift nodes to a policy-aware version of Swift #. Define your policies in ``/etc/swift/swift.conf`` diff --git a/doc/source/overview_replication.rst b/doc/source/overview_replication.rst index 81523fab51..56aeeacd7d 100644 --- a/doc/source/overview_replication.rst +++ b/doc/source/overview_replication.rst @@ -111,11 +111,53 @@ Another improvement planned all along the way is separating the local disk structure from the protocol path structure. This separation will allow ring resizing at some point, or at least ring-doubling. -FOR NOW, IT IS NOT RECOMMENDED TO USE SSYNC ON PRODUCTION CLUSTERS. Some of us -will be in a limited fashion to look for any subtle issues, tuning, etc. but -generally ssync is an experimental feature. In its current implementation it is -probably going to be a bit slower than RSync, but if all goes according to plan -it will end up much faster. +Note that for objects being stored with an Erasure Code policy, the replicator +daemon is not involved. Instead, the reconstructor is used by Erasure Code +policies and is analogous to the replicator for Replication type policies. +See :doc:`overview_erasure_code` for complete information on both Erasure Code +support as well as the reconstructor. + +---------- +Hashes.pkl +---------- + +The hashes.pkl file is a key element for both replication and reconstruction +(for Erasure Coding). Both daemons use this file to determine if any kind of +action is required between nodes that are participating in the durability +scheme. The file itself is a pickled dictionary with slightly different +formats depending on whether the policy is Replication or Erasure Code. In +either case, however, the same basic information is provided between the +nodes. The dictionary contains a dictionary where the key is a suffix +directory name and the value is the MD5 hash of the directory listing for +that suffix. In this manner, the daemon can quickly identify differences +between local and remote suffix directories on a per partition basis as the +scope of any one hashes.pkl file is a partition directory. + +For Erasure Code policies, there is a little more information required. An +object's hash directory may contain multiple fragments of a single object in +the event that the node is acting as a handoff or perhaps if a rebalance is +underway. Each fragment of an object is stored with a fragment index, so +the hashes.pkl for an Erasure Code partition will still be a dictionary +keyed on the suffix directory name, however, the value is another dictionary +keyed on the fragment index with subsequent MD5 hashes for each one as +values. Some files within an object hash directory don't require a fragment +index so None is used to represent those. Below are examples of what these +dictionaries might look like. + +Replication hashes.pkl:: + + {'a43': '72018c5fbfae934e1f56069ad4425627', + 'b23': '12348c5fbfae934e1f56069ad4421234'} + +Erasure Code hashes.pkl:: + + {'a43': {None: '72018c5fbfae934e1f56069ad4425627', + 2: 'b6dd6db937cb8748f50a5b6e4bc3b808'}, + 'b23': {None: '12348c5fbfae934e1f56069ad4421234', + 1: '45676db937cb8748f50a5b6e4bc34567'}} + + + ----------------------------- diff --git a/etc/container-server.conf-sample b/etc/container-server.conf-sample index de511368ad..e7b8a802f8 100644 --- a/etc/container-server.conf-sample +++ b/etc/container-server.conf-sample @@ -167,6 +167,14 @@ use = egg:swift#recon # # Maximum amount of time to spend syncing each container per pass # container_time = 60 +# +# Maximum amount of time in seconds for the connection attempt +# conn_timeout = 5 +# Server errors from requests will be retried by default +# request_tries = 3 +# +# Internal client config file path +# internal_client_conf_path = /etc/swift/internal-client.conf # Note: Put it at the beginning of the pipeline to profile all middleware. But # it is safer to put this after healthcheck. diff --git a/etc/internal-client.conf-sample b/etc/internal-client.conf-sample new file mode 100644 index 0000000000..2d25d448b6 --- /dev/null +++ b/etc/internal-client.conf-sample @@ -0,0 +1,42 @@ +[DEFAULT] +# swift_dir = /etc/swift +# user = swift +# You can specify default log routing here if you want: +# log_name = swift +# log_facility = LOG_LOCAL0 +# log_level = INFO +# log_address = /dev/log +# +# comma separated list of functions to call to setup custom log handlers. +# functions get passed: conf, name, log_to_console, log_route, fmt, logger, +# adapted_logger +# log_custom_handlers = +# +# If set, log_udp_host will override log_address +# log_udp_host = +# log_udp_port = 514 +# +# You can enable StatsD logging here: +# log_statsd_host = localhost +# log_statsd_port = 8125 +# log_statsd_default_sample_rate = 1.0 +# log_statsd_sample_rate_factor = 1.0 +# log_statsd_metric_prefix = + +[pipeline:main] +pipeline = catch_errors proxy-logging cache proxy-server + +[app:proxy-server] +use = egg:swift#proxy +# See proxy-server.conf-sample for options + +[filter:cache] +use = egg:swift#memcache +# See proxy-server.conf-sample for options + +[filter:proxy-logging] +use = egg:swift#proxy_logging + +[filter:catch_errors] +use = egg:swift#catch_errors +# See proxy-server.conf-sample for options diff --git a/etc/object-server.conf-sample b/etc/object-server.conf-sample index b594a9576f..c510e0fb28 100644 --- a/etc/object-server.conf-sample +++ b/etc/object-server.conf-sample @@ -211,6 +211,29 @@ use = egg:swift#recon # removed when it has successfully replicated to all the canonical nodes. # handoff_delete = auto +[object-reconstructor] +# You can override the default log routing for this app here (don't use set!): +# Unless otherwise noted, each setting below has the same meaning as described +# in the [object-replicator] section, however these settings apply to the EC +# reconstructor +# +# log_name = object-reconstructor +# log_facility = LOG_LOCAL0 +# log_level = INFO +# log_address = /dev/log +# +# daemonize = on +# run_pause = 30 +# concurrency = 1 +# stats_interval = 300 +# node_timeout = 10 +# http_timeout = 60 +# lockup_timeout = 1800 +# reclaim_age = 604800 +# ring_check_interval = 15 +# recon_cache_path = /var/cache/swift +# handoffs_first = False + [object-updater] # You can override the default log routing for this app here (don't use set!): # log_name = object-updater diff --git a/etc/proxy-server.conf-sample b/etc/proxy-server.conf-sample index 36b5b97d2e..37fc7d4564 100644 --- a/etc/proxy-server.conf-sample +++ b/etc/proxy-server.conf-sample @@ -17,9 +17,10 @@ bind_port = 8080 # to /info. You can withhold subsections by separating the dict level with a # ".". The following would cause the sections 'container_quotas' and 'tempurl' # to not be listed, and the key max_failed_deletes would be removed from -# bulk_delete. Default is empty, allowing all registered features to be listed -# via HTTP GET /info. -# disallowed_sections = container_quotas, tempurl, bulk_delete.max_failed_deletes +# bulk_delete. Default value is 'swift.valid_api_versions' which allows all +# registered features to be listed via HTTP GET /info except +# swift.valid_api_versions information +# disallowed_sections = swift.valid_api_versions, container_quotas, tempurl # Use an integer to override the number of pre-forked processes that will # accept connections. Should default to the number of effective cpu diff --git a/etc/swift.conf-sample b/etc/swift.conf-sample index fac17676cf..f8accabaec 100644 --- a/etc/swift.conf-sample +++ b/etc/swift.conf-sample @@ -22,9 +22,13 @@ swift_hash_path_prefix = changeme # defined you must define a policy with index 0 and you must specify a # default. It is recommended you always define a section for # storage-policy:0. +# +# A 'policy_type' argument is also supported but is not mandatory. Default +# policy type 'replication' is used when 'policy_type' is unspecified. [storage-policy:0] name = Policy-0 default = yes +#policy_type = replication # the following section would declare a policy called 'silver', the number of # replicas will be determined by how the ring is built. In this example the @@ -39,9 +43,45 @@ default = yes # current default. #[storage-policy:1] #name = silver +#policy_type = replication + +# The following declares a storage policy of type 'erasure_coding' which uses +# Erasure Coding for data reliability. The 'erasure_coding' storage policy in +# Swift is available as a "beta". Please refer to Swift documentation for +# details on how the 'erasure_coding' storage policy is implemented. +# +# Swift uses PyECLib, a Python Erasure coding API library, for encode/decode +# operations. Please refer to Swift documentation for details on how to +# install PyECLib. +# +# When defining an EC policy, 'policy_type' needs to be 'erasure_coding' and +# EC configuration parameters 'ec_type', 'ec_num_data_fragments' and +# 'ec_num_parity_fragments' must be specified. 'ec_type' is chosen from the +# list of EC backends supported by PyECLib. The ring configured for the +# storage policy must have it's "replica" count configured to +# 'ec_num_data_fragments' + 'ec_num_parity_fragments' - this requirement is +# validated when services start. 'ec_object_segment_size' is the amount of +# data that will be buffered up before feeding a segment into the +# encoder/decoder. More information about these configuration options and +# supported `ec_type` schemes is available in the Swift documentation. Please +# refer to Swift documentation for details on how to configure EC policies. +# +# The example 'deepfreeze10-4' policy defined below is a _sample_ +# configuration with 10 'data' and 4 'parity' fragments. 'ec_type' +# defines the Erasure Coding scheme. 'jerasure_rs_vand' (Reed-Solomon +# Vandermonde) is used as an example below. +# +#[storage-policy:2] +#name = deepfreeze10-4 +#policy_type = erasure_coding +#ec_type = jerasure_rs_vand +#ec_num_data_fragments = 10 +#ec_num_parity_fragments = 4 +#ec_object_segment_size = 1048576 + # The swift-constraints section sets the basic constraints on data -# saved in the swift cluster. These constraints are automatically +# saved in the swift cluster. These constraints are automatically # published by the proxy server in responses to /info requests. [swift-constraints] @@ -116,3 +156,14 @@ default = yes # of a container name #max_container_name_length = 256 + + +# By default all REST API calls should use "v1" or "v1.0" as the version string, +# for example "/v1/account". This can be manually overridden to make this +# backward-compatible, in case a different version string has been used before. +# Use a comma-separated list in case of multiple allowed versions, for example +# valid_api_versions = v0,v1,v2 +# This is only enforced for account, container and object requests. The allowed +# api versions are by default excluded from /info. + +# valid_api_versions = v1,v1.0 diff --git a/requirements.txt b/requirements.txt index 96cb4fb030..27d507901a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,9 +3,10 @@ # process, which may cause wedges in the gate later. dnspython>=1.9.4 -eventlet>=0.9.15 +eventlet>=0.16.1,!=0.17.0 greenlet>=0.3.1 netifaces>=0.5,!=0.10.0,!=0.10.1 pastedeploy>=1.3.3 simplejson>=2.0.9 xattr>=0.4 +PyECLib>=1.0.3 diff --git a/setup.cfg b/setup.cfg index ea9c954a5b..4b648b1109 100644 --- a/setup.cfg +++ b/setup.cfg @@ -51,6 +51,7 @@ scripts = bin/swift-object-expirer bin/swift-object-info bin/swift-object-replicator + bin/swift-object-reconstructor bin/swift-object-server bin/swift-object-updater bin/swift-oldies diff --git a/swift/account/reaper.py b/swift/account/reaper.py index ce69fab927..06a0085352 100644 --- a/swift/account/reaper.py +++ b/swift/account/reaper.py @@ -19,6 +19,7 @@ from swift import gettext_ as _ from logging import DEBUG from math import sqrt from time import time +import itertools from eventlet import GreenPool, sleep, Timeout @@ -432,7 +433,7 @@ class AccountReaper(Daemon): * See also: :func:`swift.common.ring.Ring.get_nodes` for a description of the container node dicts. """ - container_nodes = list(container_nodes) + cnodes = itertools.cycle(container_nodes) try: ring = self.get_object_ring(policy_index) except PolicyError: @@ -443,7 +444,7 @@ class AccountReaper(Daemon): successes = 0 failures = 0 for node in nodes: - cnode = container_nodes.pop() + cnode = next(cnodes) try: direct_delete_object( node, part, account, container, obj, diff --git a/swift/cli/info.py b/swift/cli/info.py index cffb93eed5..2f140afee8 100644 --- a/swift/cli/info.py +++ b/swift/cli/info.py @@ -24,7 +24,7 @@ from swift.common.request_helpers import is_sys_meta, is_user_meta, \ from swift.account.backend import AccountBroker, DATADIR as ABDATADIR from swift.container.backend import ContainerBroker, DATADIR as CBDATADIR from swift.obj.diskfile import get_data_dir, read_metadata, DATADIR_BASE, \ - extract_policy_index + extract_policy from swift.common.storage_policy import POLICIES @@ -251,6 +251,10 @@ def print_obj_metadata(metadata): :raises: ValueError """ + user_metadata = {} + sys_metadata = {} + other_metadata = {} + if not metadata: raise ValueError('Metadata is None') path = metadata.pop('name', '') @@ -280,7 +284,25 @@ def print_obj_metadata(metadata): else: print 'Timestamp: Not found in metadata' - print 'User Metadata: %s' % metadata + for key, value in metadata.iteritems(): + if is_user_meta('Object', key): + user_metadata[key] = value + elif is_sys_meta('Object', key): + sys_metadata[key] = value + else: + other_metadata[key] = value + + def print_metadata(title, items): + print title + if items: + for meta_key in sorted(items): + print ' %s: %s' % (meta_key, items[meta_key]) + else: + print ' No metadata found' + + print_metadata('System Metadata:', sys_metadata) + print_metadata('User Metadata:', user_metadata) + print_metadata('Other Metadata:', other_metadata) def print_info(db_type, db_file, swift_dir='/etc/swift'): @@ -330,7 +352,7 @@ def print_obj(datafile, check_etag=True, swift_dir='/etc/swift', :param swift_dir: the path on disk to rings :param policy_name: optionally the name to use when finding the ring """ - if not os.path.exists(datafile) or not datafile.endswith('.data'): + if not os.path.exists(datafile): print "Data file doesn't exist" raise InfoSystemExit() if not datafile.startswith(('/', './')): @@ -341,10 +363,7 @@ def print_obj(datafile, check_etag=True, swift_dir='/etc/swift', datadir = DATADIR_BASE # try to extract policy index from datafile disk path - try: - policy_index = extract_policy_index(datafile) - except ValueError: - pass + policy_index = int(extract_policy(datafile) or POLICIES.legacy) try: if policy_index: diff --git a/swift/cli/recon.py b/swift/cli/recon.py index 676973c410..8c2042cb53 100755 --- a/swift/cli/recon.py +++ b/swift/cli/recon.py @@ -330,6 +330,27 @@ class SwiftRecon(object): print("[async_pending] - No hosts returned valid data.") print("=" * 79) + def driveaudit_check(self, hosts): + """ + Obtain and print drive audit error statistics + + :param hosts: set of hosts to check. in the format of: + set([('127.0.0.1', 6020), ('127.0.0.2', 6030)] + """ + scan = {} + recon = Scout("driveaudit", self.verbose, self.suppress_errors, + self.timeout) + print("[%s] Checking drive-audit errors" % self._ptime()) + for url, response, status in self.pool.imap(recon.scout, hosts): + if status == 200: + scan[url] = response['drive_audit_errors'] + stats = self._gen_stats(scan.values(), 'drive_audit_errors') + if stats['reported'] > 0: + self._print_stats(stats) + else: + print("[drive_audit_errors] - No hosts returned valid data.") + print("=" * 79) + def umount_check(self, hosts): """ Check for and print unmounted drives @@ -800,7 +821,7 @@ class SwiftRecon(object): print("No hosts returned valid data.") print("=" * 79) - def disk_usage(self, hosts, top=0, human_readable=False): + def disk_usage(self, hosts, top=0, lowest=0, human_readable=False): """ Obtain and print disk usage statistics @@ -814,6 +835,7 @@ class SwiftRecon(object): raw_total_avail = [] percents = {} top_percents = [(None, 0)] * top + low_percents = [(None, 100)] * lowest recon = Scout("diskusage", self.verbose, self.suppress_errors, self.timeout) print("[%s] Checking disk usage now" % self._ptime()) @@ -837,6 +859,13 @@ class SwiftRecon(object): top_percents.sort(key=lambda x: -x[1]) top_percents.pop() break + for ident, oused in low_percents: + if oused > used: + low_percents.append( + (url + ' ' + entry['device'], used)) + low_percents.sort(key=lambda x: x[1]) + low_percents.pop() + break stats[url] = hostusage for url in stats: @@ -882,6 +911,13 @@ class SwiftRecon(object): url, device = ident.split() host = urlparse(url).netloc.split(':')[0] print('%.02f%% %s' % (used, '%-15s %s' % (host, device))) + if low_percents: + print('LOWEST %s' % lowest) + for ident, used in low_percents: + if ident: + url, device = ident.split() + host = urlparse(url).netloc.split(':')[0] + print('%.02f%% %s' % (used, '%-15s %s' % (host, device))) def main(self): """ @@ -930,8 +966,13 @@ class SwiftRecon(object): "local copy") args.add_option('--sockstat', action="store_true", help="Get cluster socket usage stats") + args.add_option('--driveaudit', action="store_true", + help="Get drive audit error stats") args.add_option('--top', type='int', metavar='COUNT', default=0, help='Also show the top COUNT entries in rank order.') + args.add_option('--lowest', type='int', metavar='COUNT', default=0, + help='Also show the lowest COUNT entries in rank \ + order.') args.add_option('--all', action="store_true", help="Perform all checks. Equal to \t\t\t-arudlq " "--md5 --sockstat --auditor --updater --expirer") @@ -987,11 +1028,13 @@ class SwiftRecon(object): self.auditor_check(hosts) self.umount_check(hosts) self.load_check(hosts) - self.disk_usage(hosts, options.top, options.human_readable) + self.disk_usage(hosts, options.top, options.lowest, + options.human_readable) self.get_ringmd5(hosts, swift_dir) self.quarantine_check(hosts) self.socket_usage(hosts) self.server_type_check(hosts) + self.driveaudit_check(hosts) else: if options.async: if self.server_type == 'object': @@ -1025,7 +1068,8 @@ class SwiftRecon(object): if options.loadstats: self.load_check(hosts) if options.diskusage: - self.disk_usage(hosts, options.top, options.human_readable) + self.disk_usage(hosts, options.top, options.lowest, + options.human_readable) if options.md5: self.get_ringmd5(hosts, swift_dir) self.get_swiftconfmd5(hosts) @@ -1033,6 +1077,8 @@ class SwiftRecon(object): self.quarantine_check(hosts) if options.sockstat: self.socket_usage(hosts) + if options.driveaudit: + self.driveaudit_check(hosts) def main(): diff --git a/swift/cli/ringbuilder.py b/swift/cli/ringbuilder.py index 0a7dab5331..eac586e267 100755 --- a/swift/cli/ringbuilder.py +++ b/swift/cli/ringbuilder.py @@ -14,12 +14,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +import logging + from errno import EEXIST from itertools import islice, izip from operator import itemgetter from os import mkdir from os.path import basename, abspath, dirname, exists, join as pathjoin -from sys import argv as sys_argv, exit, stderr +from sys import argv as sys_argv, exit, stderr, stdout from textwrap import wrap from time import time import optparse @@ -32,7 +34,7 @@ from swift.common.ring.utils import validate_args, \ validate_and_normalize_ip, build_dev_from_opts, \ parse_builder_ring_filename_args, parse_search_value, \ parse_search_values_from_opts, parse_change_values_from_opts, \ - dispersion_report + dispersion_report, validate_device_name from swift.common.utils import lock_parent_directory MAJOR_VERSION = 1 @@ -218,6 +220,9 @@ def _parse_add_values(argvish): while i < len(rest) and rest[i] != '_': i += 1 device_name = rest[1:i] + if not validate_device_name(device_name): + raise ValueError('Invalid device name') + rest = rest[i:] meta = '' @@ -831,6 +836,8 @@ swift-ring-builder rebalance [options] help='Force a rebalanced ring to save even ' 'if < 1% of parts changed') parser.add_option('-s', '--seed', help="seed to use for rebalance") + parser.add_option('-d', '--debug', action='store_true', + help="print debug information") options, args = parser.parse_args(argv) def get_seed(index): @@ -841,6 +848,14 @@ swift-ring-builder rebalance [options] except IndexError: pass + if options.debug: + logger = logging.getLogger("swift.ring.builder") + logger.setLevel(logging.DEBUG) + handler = logging.StreamHandler(stdout) + formatter = logging.Formatter("%(levelname)s: %(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) + devs_changed = builder.devs_changed try: last_balance = builder.get_balance() @@ -889,11 +904,12 @@ swift-ring-builder rebalance [options] status = EXIT_SUCCESS if builder.dispersion > 0: print '-' * 79 - print('NOTE: Dispersion of %.06f indicates some parts are not\n' - ' optimally dispersed.\n\n' - ' You may want adjust some device weights, increase\n' - ' the overload or review the dispersion report.' % - builder.dispersion) + print( + 'NOTE: Dispersion of %.06f indicates some parts are not\n' + ' optimally dispersed.\n\n' + ' You may want to adjust some device weights, increase\n' + ' the overload or review the dispersion report.' % + builder.dispersion) status = EXIT_WARNING print '-' * 79 elif balance > 5 and balance / 100.0 > builder.overload: diff --git a/swift/common/constraints.py b/swift/common/constraints.py index d4458ddf84..4cee56ab3c 100644 --- a/swift/common/constraints.py +++ b/swift/common/constraints.py @@ -35,6 +35,7 @@ CONTAINER_LISTING_LIMIT = 10000 ACCOUNT_LISTING_LIMIT = 10000 MAX_ACCOUNT_NAME_LENGTH = 256 MAX_CONTAINER_NAME_LENGTH = 256 +VALID_API_VERSIONS = ["v1", "v1.0"] # If adding an entry to DEFAULT_CONSTRAINTS, note that # these constraints are automatically published by the @@ -52,6 +53,7 @@ DEFAULT_CONSTRAINTS = { 'account_listing_limit': ACCOUNT_LISTING_LIMIT, 'max_account_name_length': MAX_ACCOUNT_NAME_LENGTH, 'max_container_name_length': MAX_CONTAINER_NAME_LENGTH, + 'valid_api_versions': VALID_API_VERSIONS, } SWIFT_CONSTRAINTS_LOADED = False @@ -72,13 +74,17 @@ def reload_constraints(): SWIFT_CONSTRAINTS_LOADED = True for name in DEFAULT_CONSTRAINTS: try: - value = int(constraints_conf.get('swift-constraints', name)) + value = constraints_conf.get('swift-constraints', name) except NoOptionError: pass except NoSectionError: # We are never going to find the section for another option break else: + try: + value = int(value) + except ValueError: + value = utils.list_from_csv(value) OVERRIDE_CONSTRAINTS[name] = value for name, default in DEFAULT_CONSTRAINTS.items(): value = OVERRIDE_CONSTRAINTS.get(name, default) @@ -204,6 +210,19 @@ def check_object_creation(req, object_name): return check_metadata(req, 'object') +def check_dir(root, drive): + """ + Verify that the path to the device is a directory and is a lesser + constraint that is enforced when a full mount_check isn't possible + with, for instance, a VM using loopback or partitions. + + :param root: base path where the dir is + :param drive: drive name to be checked + :returns: True if it is a valid directoy, False otherwise + """ + return os.path.isdir(os.path.join(root, drive)) + + def check_mount(root, drive): """ Verify that the path to the device is a mount point and mounted. This @@ -399,3 +418,13 @@ def check_account_format(req, account): request=req, body='Account name cannot contain slashes') return account + + +def valid_api_version(version): + """ Checks if the requested version is valid. + + Currently Swift only supports "v1" and "v1.0". """ + global VALID_API_VERSIONS + if not isinstance(VALID_API_VERSIONS, list): + VALID_API_VERSIONS = [str(VALID_API_VERSIONS)] + return version in VALID_API_VERSIONS diff --git a/swift/common/exceptions.py b/swift/common/exceptions.py index d7ea759d66..dab0777d6d 100644 --- a/swift/common/exceptions.py +++ b/swift/common/exceptions.py @@ -31,10 +31,32 @@ class SwiftException(Exception): pass +class PutterConnectError(Exception): + + def __init__(self, status=None): + self.status = status + + class InvalidTimestamp(SwiftException): pass +class InsufficientStorage(SwiftException): + pass + + +class FooterNotSupported(SwiftException): + pass + + +class MultiphasePUTNotSupported(SwiftException): + pass + + +class SuffixSyncError(SwiftException): + pass + + class DiskFileError(SwiftException): pass @@ -103,6 +125,10 @@ class ConnectionTimeout(Timeout): pass +class ResponseTimeout(Timeout): + pass + + class DriveNotMounted(SwiftException): pass @@ -173,6 +199,10 @@ class MimeInvalid(SwiftException): pass +class APIVersionError(SwiftException): + pass + + class ClientException(Exception): def __init__(self, msg, http_scheme='', http_host='', http_port='', diff --git a/swift/common/manager.py b/swift/common/manager.py index a4ef350da1..ba4832ee00 100644 --- a/swift/common/manager.py +++ b/swift/common/manager.py @@ -33,7 +33,8 @@ ALL_SERVERS = ['account-auditor', 'account-server', 'container-auditor', 'container-replicator', 'container-reconciler', 'container-server', 'container-sync', 'container-updater', 'object-auditor', 'object-server', - 'object-expirer', 'object-replicator', 'object-updater', + 'object-expirer', 'object-replicator', + 'object-reconstructor', 'object-updater', 'proxy-server', 'account-replicator', 'account-reaper'] MAIN_SERVERS = ['proxy-server', 'account-server', 'container-server', 'object-server'] @@ -434,8 +435,11 @@ class Server(object): if not conf_files: # maybe there's a config file(s) out there, but I couldn't find it! if not kwargs.get('quiet'): - print _('Unable to locate config %sfor %s') % ( - ('number %s ' % number if number else ''), self.server) + if number: + print _('Unable to locate config number %s for %s' % ( + number, self.server)) + else: + print _('Unable to locate config for %s' % (self.server)) if kwargs.get('verbose') and not kwargs.get('quiet'): if found_conf_files: print _('Found configs:') diff --git a/swift/common/middleware/formpost.py b/swift/common/middleware/formpost.py index 7132b342a5..56a6d20f3f 100644 --- a/swift/common/middleware/formpost.py +++ b/swift/common/middleware/formpost.py @@ -218,7 +218,14 @@ class FormPost(object): env, attrs['boundary']) start_response(status, headers) return [body] - except (FormInvalid, MimeInvalid, EOFError) as err: + except MimeInvalid: + body = 'FormPost: invalid starting boundary' + start_response( + '400 Bad Request', + (('Content-Type', 'text/plain'), + ('Content-Length', str(len(body))))) + return [body] + except (FormInvalid, EOFError) as err: body = 'FormPost: %s' % err start_response( '400 Bad Request', diff --git a/swift/common/middleware/keystoneauth.py b/swift/common/middleware/keystoneauth.py index 09d5596640..6f70ede5f4 100644 --- a/swift/common/middleware/keystoneauth.py +++ b/swift/common/middleware/keystoneauth.py @@ -106,9 +106,9 @@ class KeystoneAuth(object): operator_roles service_roles - For backward compatibility, no prefix implies the parameter - applies to all reseller_prefixes. Here is an example, using two - prefixes:: + For backward compatibility, if either of these parameters is specified + without a prefix then it applies to all reseller_prefixes. Here is an + example, using two prefixes:: reseller_prefix = AUTH, SERVICE # The next three lines have identical effects (since the first applies @@ -242,11 +242,11 @@ class KeystoneAuth(object): # using _integral_keystone_identity to replace current # _keystone_identity. The purpose of keeping it in this release it for # back compatibility. - if environ.get('HTTP_X_IDENTITY_STATUS') != 'Confirmed': + if (environ.get('HTTP_X_IDENTITY_STATUS') != 'Confirmed' + or environ.get( + 'HTTP_X_SERVICE_IDENTITY_STATUS') not in (None, 'Confirmed')): return - roles = [] - if 'HTTP_X_ROLES' in environ: - roles = environ['HTTP_X_ROLES'].split(',') + roles = list_from_csv(environ.get('HTTP_X_ROLES', '')) identity = {'user': environ.get('HTTP_X_USER_NAME'), 'tenant': (environ.get('HTTP_X_TENANT_ID'), environ.get('HTTP_X_TENANT_NAME')), diff --git a/swift/common/middleware/recon.py b/swift/common/middleware/recon.py index c512493354..88d5243a4d 100644 --- a/swift/common/middleware/recon.py +++ b/swift/common/middleware/recon.py @@ -53,6 +53,8 @@ class ReconMiddleware(object): 'container.recon') self.account_recon_cache = os.path.join(self.recon_cache_path, 'account.recon') + self.drive_recon_cache = os.path.join(self.recon_cache_path, + 'drive.recon') self.account_ring_path = os.path.join(swift_dir, 'account.ring.gz') self.container_ring_path = os.path.join(swift_dir, 'container.ring.gz') self.rings = [self.account_ring_path, self.container_ring_path] @@ -124,6 +126,11 @@ class ReconMiddleware(object): return self._from_recon_cache(['async_pending'], self.object_recon_cache) + def get_driveaudit_error(self): + """get # of drive audit errors""" + return self._from_recon_cache(['drive_audit_errors'], + self.drive_recon_cache) + def get_replication_info(self, recon_type): """get replication info""" if recon_type == 'account': @@ -359,6 +366,8 @@ class ReconMiddleware(object): content = self.get_socket_info() elif rcheck == "version": content = self.get_version() + elif rcheck == "driveaudit": + content = self.get_driveaudit_error() else: content = "Invalid path: %s" % req.path return Response(request=req, status="404 Not Found", diff --git a/swift/common/middleware/tempauth.py b/swift/common/middleware/tempauth.py index a2b07128a8..93f55ff031 100644 --- a/swift/common/middleware/tempauth.py +++ b/swift/common/middleware/tempauth.py @@ -399,7 +399,7 @@ class TempAuth(object): s = base64.encodestring(hmac.new(key, msg, sha1).digest()).strip() if s != sign: return None - groups = self._get_user_groups(account, account_user) + groups = self._get_user_groups(account, account_user, account_id) return groups diff --git a/swift/common/request_helpers.py b/swift/common/request_helpers.py index 08e0ab5dc4..14b9fd8849 100644 --- a/swift/common/request_helpers.py +++ b/swift/common/request_helpers.py @@ -26,10 +26,12 @@ import time from contextlib import contextmanager from urllib import unquote from swift import gettext_ as _ +from swift.common.storage_policy import POLICIES from swift.common.constraints import FORMAT2CONTENT_TYPE from swift.common.exceptions import ListingIterError, SegmentError from swift.common.http import is_success -from swift.common.swob import HTTPBadRequest, HTTPNotAcceptable +from swift.common.swob import (HTTPBadRequest, HTTPNotAcceptable, + HTTPServiceUnavailable) from swift.common.utils import split_path, validate_device_partition from swift.common.wsgi import make_subrequest @@ -82,21 +84,27 @@ def get_listing_content_type(req): def get_name_and_placement(request, minsegs=1, maxsegs=None, rest_with_last=False): """ - Utility function to split and validate the request path and - storage_policy_index. The storage_policy_index is extracted from - the headers of the request and converted to an integer, and then the - args are passed through to :meth:`split_and_validate_path`. + Utility function to split and validate the request path and storage + policy. The storage policy index is extracted from the headers of + the request and converted to a StoragePolicy instance. The + remaining args are passed through to + :meth:`split_and_validate_path`. :returns: a list, result of :meth:`split_and_validate_path` with - storage_policy_index appended on the end - :raises: HTTPBadRequest + the BaseStoragePolicy instance appended on the end + :raises: HTTPServiceUnavailable if the path is invalid or no policy exists + with the extracted policy_index. """ - policy_idx = request.headers.get('X-Backend-Storage-Policy-Index', '0') - policy_idx = int(policy_idx) + policy_index = request.headers.get('X-Backend-Storage-Policy-Index') + policy = POLICIES.get_by_index(policy_index) + if not policy: + raise HTTPServiceUnavailable( + body=_("No policy with index %s") % policy_index, + request=request, content_type='text/plain') results = split_and_validate_path(request, minsegs=minsegs, maxsegs=maxsegs, rest_with_last=rest_with_last) - results.append(policy_idx) + results.append(policy) return results diff --git a/swift/common/ring/builder.py b/swift/common/ring/builder.py index b464bd76b2..6672fdbecc 100644 --- a/swift/common/ring/builder.py +++ b/swift/common/ring/builder.py @@ -17,6 +17,7 @@ import bisect import copy import errno import itertools +import logging import math import random import cPickle as pickle @@ -33,6 +34,16 @@ from swift.common.ring.utils import tiers_for_dev, build_tier_tree, \ MAX_BALANCE = 999.99 +try: + # python 2.7+ + from logging import NullHandler +except ImportError: + # python 2.6 + class NullHandler(logging.Handler): + def emit(self, *a, **kw): + pass + + class RingBuilder(object): """ Used to build swift.common.ring.RingData instances to be written to disk @@ -96,6 +107,11 @@ class RingBuilder(object): self._remove_devs = [] self._ring = None + self.logger = logging.getLogger("swift.ring.builder") + if not self.logger.handlers: + # silence "no handler for X" error messages + self.logger.addHandler(NullHandler()) + def weight_of_one_part(self): """ Returns the weight of each partition as calculated from the @@ -355,6 +371,7 @@ class RingBuilder(object): self._ring = None if self._last_part_moves_epoch is None: + self.logger.debug("New builder; performing initial balance") self._initial_balance() self.devs_changed = False self._build_dispersion_graph() @@ -363,16 +380,23 @@ class RingBuilder(object): self._update_last_part_moves() last_balance = 0 new_parts, removed_part_count = self._adjust_replica2part2dev_size() + self.logger.debug( + "%d new parts and %d removed parts from replica-count change", + len(new_parts), removed_part_count) changed_parts += removed_part_count self._set_parts_wanted() self._reassign_parts(new_parts) changed_parts += len(new_parts) while True: reassign_parts = self._gather_reassign_parts() - self._reassign_parts(reassign_parts) changed_parts += len(reassign_parts) + self.logger.debug("Gathered %d parts", changed_parts) + self._reassign_parts(reassign_parts) + self.logger.debug("Assigned %d parts", changed_parts) while self._remove_devs: - self.devs[self._remove_devs.pop()['id']] = None + remove_dev_id = self._remove_devs.pop()['id'] + self.logger.debug("Removing dev %d", remove_dev_id) + self.devs[remove_dev_id] = None balance = self.get_balance() if balance < 1 or abs(last_balance - balance) < 1 or \ changed_parts == self.parts: @@ -786,6 +810,9 @@ class RingBuilder(object): if dev_id in dev_ids: self._last_part_moves[part] = 0 removed_dev_parts[part].append(replica) + self.logger.debug( + "Gathered %d/%d from dev %d [dev removed]", + part, replica, dev_id) # Now we gather partitions that are "at risk" because they aren't # currently sufficient spread out across the cluster. @@ -859,6 +886,9 @@ class RingBuilder(object): dev['parts'] -= 1 removed_replica = True moved_parts += 1 + self.logger.debug( + "Gathered %d/%d from dev %d [dispersion]", + part, replica, dev['id']) break if removed_replica: for tier in tfd[dev['id']]: @@ -894,6 +924,9 @@ class RingBuilder(object): dev['parts_wanted'] += 1 dev['parts'] -= 1 reassign_parts[part].append(replica) + self.logger.debug( + "Gathered %d/%d from dev %d [weight]", + part, replica, dev['id']) reassign_parts.update(spread_out_parts) reassign_parts.update(removed_dev_parts) @@ -1121,6 +1154,8 @@ class RingBuilder(object): new_index, new_last_sort_key) self._replica2part2dev[replica][part] = dev['id'] + self.logger.debug( + "Placed %d/%d onto dev %d", part, replica, dev['id']) # Just to save memory and keep from accidental reuse. for dev in self._iter_devs(): diff --git a/swift/common/ring/ring.py b/swift/common/ring/ring.py index daad23ff1b..62e19951d3 100644 --- a/swift/common/ring/ring.py +++ b/swift/common/ring/ring.py @@ -243,7 +243,7 @@ class Ring(object): if dev_id not in seen_ids: part_nodes.append(self.devs[dev_id]) seen_ids.add(dev_id) - return part_nodes + return [dict(node, index=i) for i, node in enumerate(part_nodes)] def get_part(self, account, container=None, obj=None): """ @@ -291,6 +291,7 @@ class Ring(object): ====== =============================================================== id unique integer identifier amongst devices + index offset into the primary node list for the partition weight a float of the relative weight of this device as compared to others; this indicates how many partitions the builder will try to assign to this device diff --git a/swift/common/ring/utils.py b/swift/common/ring/utils.py index 17186dee87..b00ef825d4 100644 --- a/swift/common/ring/utils.py +++ b/swift/common/ring/utils.py @@ -515,6 +515,9 @@ def build_dev_from_opts(opts): (opts.replication_ip or opts.ip)) replication_port = opts.replication_port or opts.port + if not validate_device_name(opts.device): + raise ValueError('Invalid device name') + return {'region': opts.region, 'zone': opts.zone, 'ip': ip, 'port': opts.port, 'device': opts.device, 'meta': opts.meta, 'replication_ip': replication_ip, @@ -569,3 +572,10 @@ def get_tier_name(tier, builder): device = builder.devs[tier[3]] or {} return "r%sz%s-%s/%s" % (tier[0], tier[1], tier[2], device.get('device', 'IDd%s' % tier[3])) + + +def validate_device_name(device_name): + return not ( + device_name.startswith(' ') or + device_name.endswith(' ') or + len(device_name) == 0) diff --git a/swift/common/storage_policy.py b/swift/common/storage_policy.py index 245e3c325b..e45ab018c5 100644 --- a/swift/common/storage_policy.py +++ b/swift/common/storage_policy.py @@ -17,10 +17,18 @@ import string from swift.common.utils import config_true_value, SWIFT_CONF_FILE from swift.common.ring import Ring +from swift.common.utils import quorum_size +from swift.common.exceptions import RingValidationError +from pyeclib.ec_iface import ECDriver, ECDriverError, VALID_EC_TYPES LEGACY_POLICY_NAME = 'Policy-0' VALID_CHARS = '-' + string.letters + string.digits +DEFAULT_POLICY_TYPE = REPL_POLICY = 'replication' +EC_POLICY = 'erasure_coding' + +DEFAULT_EC_OBJECT_SEGMENT_SIZE = 1048576 + class PolicyError(ValueError): @@ -38,36 +46,73 @@ def _get_policy_string(base, policy_index): return return_string -def get_policy_string(base, policy_index): +def get_policy_string(base, policy_or_index): """ - Helper function to construct a string from a base and the policy - index. Used to encode the policy index into either a file name - or a directory name by various modules. + Helper function to construct a string from a base and the policy. + Used to encode the policy index into either a file name or a + directory name by various modules. :param base: the base string - :param policy_index: the storage policy index + :param policy_or_index: StoragePolicy instance, or an index + (string or int), if None the legacy + storage Policy-0 is assumed. :returns: base name with policy index added + :raises: PolicyError if no policy exists with the given policy_index """ - if POLICIES.get_by_index(policy_index) is None: - raise PolicyError("No policy with index %r" % policy_index) - return _get_policy_string(base, policy_index) + if isinstance(policy_or_index, BaseStoragePolicy): + policy = policy_or_index + else: + policy = POLICIES.get_by_index(policy_or_index) + if policy is None: + raise PolicyError("Unknown policy", index=policy_or_index) + return _get_policy_string(base, int(policy)) -class StoragePolicy(object): +def split_policy_string(policy_string): """ - Represents a storage policy. - Not meant to be instantiated directly; use - :func:`~swift.common.storage_policy.reload_storage_policies` to load - POLICIES from ``swift.conf``. + Helper function to convert a string representing a base and a + policy. Used to decode the policy from either a file name or + a directory name by various modules. + + :param policy_string: base name with policy index added + + :raises: PolicyError if given index does not map to a valid policy + :returns: a tuple, in the form (base, policy) where base is the base + string and policy is the StoragePolicy instance for the + index encoded in the policy_string. + """ + if '-' in policy_string: + base, policy_index = policy_string.rsplit('-', 1) + else: + base, policy_index = policy_string, None + policy = POLICIES.get_by_index(policy_index) + if get_policy_string(base, policy) != policy_string: + raise PolicyError("Unknown policy", index=policy_index) + return base, policy + + +class BaseStoragePolicy(object): + """ + Represents a storage policy. Not meant to be instantiated directly; + implement a derived subclasses (e.g. StoragePolicy, ECStoragePolicy, etc) + or use :func:`~swift.common.storage_policy.reload_storage_policies` to + load POLICIES from ``swift.conf``. The object_ring property is lazy loaded once the service's ``swift_dir`` is known via :meth:`~StoragePolicyCollection.get_object_ring`, but it may be over-ridden via object_ring kwarg at create time for testing or actively loaded with :meth:`~StoragePolicy.load_ring`. """ + + policy_type_to_policy_cls = {} + def __init__(self, idx, name='', is_default=False, is_deprecated=False, object_ring=None): + # do not allow BaseStoragePolicy class to be instantiated directly + if type(self) == BaseStoragePolicy: + raise TypeError("Can't instantiate BaseStoragePolicy directly") + # policy parameter validation try: self.idx = int(idx) except ValueError: @@ -88,6 +133,8 @@ class StoragePolicy(object): self.name = name self.is_deprecated = config_true_value(is_deprecated) self.is_default = config_true_value(is_default) + if self.policy_type not in BaseStoragePolicy.policy_type_to_policy_cls: + raise PolicyError('Invalid type', self.policy_type) if self.is_deprecated and self.is_default: raise PolicyError('Deprecated policy can not be default. ' 'Invalid config', self.idx) @@ -101,8 +148,80 @@ class StoragePolicy(object): return cmp(self.idx, int(other)) def __repr__(self): - return ("StoragePolicy(%d, %r, is_default=%s, is_deprecated=%s)") % ( - self.idx, self.name, self.is_default, self.is_deprecated) + return ("%s(%d, %r, is_default=%s, " + "is_deprecated=%s, policy_type=%r)") % \ + (self.__class__.__name__, self.idx, self.name, + self.is_default, self.is_deprecated, self.policy_type) + + @classmethod + def register(cls, policy_type): + """ + Decorator for Storage Policy implementations to register + their StoragePolicy class. This will also set the policy_type + attribute on the registered implementation. + """ + def register_wrapper(policy_cls): + if policy_type in cls.policy_type_to_policy_cls: + raise PolicyError( + '%r is already registered for the policy_type %r' % ( + cls.policy_type_to_policy_cls[policy_type], + policy_type)) + cls.policy_type_to_policy_cls[policy_type] = policy_cls + policy_cls.policy_type = policy_type + return policy_cls + return register_wrapper + + @classmethod + def _config_options_map(cls): + """ + Map config option name to StoragePolicy parameter name. + """ + return { + 'name': 'name', + 'policy_type': 'policy_type', + 'default': 'is_default', + 'deprecated': 'is_deprecated', + } + + @classmethod + def from_config(cls, policy_index, options): + config_to_policy_option_map = cls._config_options_map() + policy_options = {} + for config_option, value in options.items(): + try: + policy_option = config_to_policy_option_map[config_option] + except KeyError: + raise PolicyError('Invalid option %r in ' + 'storage-policy section' % config_option, + index=policy_index) + policy_options[policy_option] = value + return cls(policy_index, **policy_options) + + def get_info(self, config=False): + """ + Return the info dict and conf file options for this policy. + + :param config: boolean, if True all config options are returned + """ + info = {} + for config_option, policy_attribute in \ + self._config_options_map().items(): + info[config_option] = getattr(self, policy_attribute) + if not config: + # remove some options for public consumption + if not self.is_default: + info.pop('default') + if not self.is_deprecated: + info.pop('deprecated') + info.pop('policy_type') + return info + + def _validate_ring(self): + """ + Hook, called when the ring is loaded. Can be used to + validate the ring against the StoragePolicy configuration. + """ + pass def load_ring(self, swift_dir): """ @@ -114,6 +233,225 @@ class StoragePolicy(object): return self.object_ring = Ring(swift_dir, ring_name=self.ring_name) + # Validate ring to make sure it conforms to policy requirements + self._validate_ring() + + @property + def quorum(self): + """ + Number of successful backend requests needed for the proxy to + consider the client request successful. + """ + raise NotImplementedError() + + +@BaseStoragePolicy.register(REPL_POLICY) +class StoragePolicy(BaseStoragePolicy): + """ + Represents a storage policy of type 'replication'. Default storage policy + class unless otherwise overridden from swift.conf. + + Not meant to be instantiated directly; use + :func:`~swift.common.storage_policy.reload_storage_policies` to load + POLICIES from ``swift.conf``. + """ + + @property + def quorum(self): + """ + Quorum concept in the replication case: + floor(number of replica / 2) + 1 + """ + if not self.object_ring: + raise PolicyError('Ring is not loaded') + return quorum_size(self.object_ring.replica_count) + + +@BaseStoragePolicy.register(EC_POLICY) +class ECStoragePolicy(BaseStoragePolicy): + """ + Represents a storage policy of type 'erasure_coding'. + + Not meant to be instantiated directly; use + :func:`~swift.common.storage_policy.reload_storage_policies` to load + POLICIES from ``swift.conf``. + """ + def __init__(self, idx, name='', is_default=False, + is_deprecated=False, object_ring=None, + ec_segment_size=DEFAULT_EC_OBJECT_SEGMENT_SIZE, + ec_type=None, ec_ndata=None, ec_nparity=None): + + super(ECStoragePolicy, self).__init__( + idx, name, is_default, is_deprecated, object_ring) + + # Validate erasure_coding policy specific members + # ec_type is one of the EC implementations supported by PyEClib + if ec_type is None: + raise PolicyError('Missing ec_type') + if ec_type not in VALID_EC_TYPES: + raise PolicyError('Wrong ec_type %s for policy %s, should be one' + ' of "%s"' % (ec_type, self.name, + ', '.join(VALID_EC_TYPES))) + self._ec_type = ec_type + + # Define _ec_ndata as the number of EC data fragments + # Accessible as the property "ec_ndata" + try: + value = int(ec_ndata) + if value <= 0: + raise ValueError + self._ec_ndata = value + except (TypeError, ValueError): + raise PolicyError('Invalid ec_num_data_fragments %r' % + ec_ndata, index=self.idx) + + # Define _ec_nparity as the number of EC parity fragments + # Accessible as the property "ec_nparity" + try: + value = int(ec_nparity) + if value <= 0: + raise ValueError + self._ec_nparity = value + except (TypeError, ValueError): + raise PolicyError('Invalid ec_num_parity_fragments %r' + % ec_nparity, index=self.idx) + + # Define _ec_segment_size as the encode segment unit size + # Accessible as the property "ec_segment_size" + try: + value = int(ec_segment_size) + if value <= 0: + raise ValueError + self._ec_segment_size = value + except (TypeError, ValueError): + raise PolicyError('Invalid ec_object_segment_size %r' % + ec_segment_size, index=self.idx) + + # Initialize PyECLib EC backend + try: + self.pyeclib_driver = \ + ECDriver(k=self._ec_ndata, m=self._ec_nparity, + ec_type=self._ec_type) + except ECDriverError as e: + raise PolicyError("Error creating EC policy (%s)" % e, + index=self.idx) + + # quorum size in the EC case depends on the choice of EC scheme. + self._ec_quorum_size = \ + self._ec_ndata + self.pyeclib_driver.min_parity_fragments_needed() + + @property + def ec_type(self): + return self._ec_type + + @property + def ec_ndata(self): + return self._ec_ndata + + @property + def ec_nparity(self): + return self._ec_nparity + + @property + def ec_segment_size(self): + return self._ec_segment_size + + @property + def fragment_size(self): + """ + Maximum length of a fragment, including header. + + NB: a fragment archive is a sequence of 0 or more max-length + fragments followed by one possibly-shorter fragment. + """ + # Technically pyeclib's get_segment_info signature calls for + # (data_len, segment_size) but on a ranged GET we don't know the + # ec-content-length header before we need to compute where in the + # object we should request to align with the fragment size. So we + # tell pyeclib a lie - from it's perspective, as long as data_len >= + # segment_size it'll give us the answer we want. From our + # perspective, because we only use this answer to calculate the + # *minimum* size we should read from an object body even if data_len < + # segment_size we'll still only read *the whole one and only last + # fragment* and pass than into pyeclib who will know what to do with + # it just as it always does when the last fragment is < fragment_size. + return self.pyeclib_driver.get_segment_info( + self.ec_segment_size, self.ec_segment_size)['fragment_size'] + + @property + def ec_scheme_description(self): + """ + This short hand form of the important parts of the ec schema is stored + in Object System Metadata on the EC Fragment Archives for debugging. + """ + return "%s %d+%d" % (self._ec_type, self._ec_ndata, self._ec_nparity) + + def __repr__(self): + return ("%s, EC config(ec_type=%s, ec_segment_size=%d, " + "ec_ndata=%d, ec_nparity=%d)") % ( + super(ECStoragePolicy, self).__repr__(), self.ec_type, + self.ec_segment_size, self.ec_ndata, self.ec_nparity) + + @classmethod + def _config_options_map(cls): + options = super(ECStoragePolicy, cls)._config_options_map() + options.update({ + 'ec_type': 'ec_type', + 'ec_object_segment_size': 'ec_segment_size', + 'ec_num_data_fragments': 'ec_ndata', + 'ec_num_parity_fragments': 'ec_nparity', + }) + return options + + def get_info(self, config=False): + info = super(ECStoragePolicy, self).get_info(config=config) + if not config: + info.pop('ec_object_segment_size') + info.pop('ec_num_data_fragments') + info.pop('ec_num_parity_fragments') + info.pop('ec_type') + return info + + def _validate_ring(self): + """ + EC specific validation + + Replica count check - we need _at_least_ (#data + #parity) replicas + configured. Also if the replica count is larger than exactly that + number there's a non-zero risk of error for code that is considering + the number of nodes in the primary list from the ring. + """ + if not self.object_ring: + raise PolicyError('Ring is not loaded') + nodes_configured = self.object_ring.replica_count + if nodes_configured != (self.ec_ndata + self.ec_nparity): + raise RingValidationError( + 'EC ring for policy %s needs to be configured with ' + 'exactly %d nodes. Got %d.' % (self.name, + self.ec_ndata + self.ec_nparity, nodes_configured)) + + @property + def quorum(self): + """ + Number of successful backend requests needed for the proxy to consider + the client request successful. + + The quorum size for EC policies defines the minimum number + of data + parity elements required to be able to guarantee + the desired fault tolerance, which is the number of data + elements supplemented by the minimum number of parity + elements required by the chosen erasure coding scheme. + + For example, for Reed-Solomon, the minimum number parity + elements required is 1, and thus the quorum_size requirement + is ec_ndata + 1. + + Given the number of parity elements required is not the same + for every erasure coding scheme, consult PyECLib for + min_parity_fragments_needed() + """ + return self._ec_quorum_size + class StoragePolicyCollection(object): """ @@ -230,9 +568,19 @@ class StoragePolicyCollection(object): :returns: storage policy, or None if no such policy """ # makes it easier for callers to just pass in a header value - index = int(index) if index else 0 + if index in ('', None): + index = 0 + else: + try: + index = int(index) + except ValueError: + return None return self.by_index.get(index) + @property + def legacy(self): + return self.get_by_index(None) + def get_object_ring(self, policy_idx, swift_dir): """ Get the ring object to use to handle a request based on its policy. @@ -261,10 +609,7 @@ class StoragePolicyCollection(object): # delete from /info if deprecated if pol.is_deprecated: continue - policy_entry = {} - policy_entry['name'] = pol.name - if pol.is_default: - policy_entry['default'] = pol.is_default + policy_entry = pol.get_info() policy_info.append(policy_entry) return policy_info @@ -281,22 +626,10 @@ def parse_storage_policies(conf): if not section.startswith('storage-policy:'): continue policy_index = section.split(':', 1)[1] - # map config option name to StoragePolicy parameter name - config_to_policy_option_map = { - 'name': 'name', - 'default': 'is_default', - 'deprecated': 'is_deprecated', - } - policy_options = {} - for config_option, value in conf.items(section): - try: - policy_option = config_to_policy_option_map[config_option] - except KeyError: - raise PolicyError('Invalid option %r in ' - 'storage-policy section %r' % ( - config_option, section)) - policy_options[policy_option] = value - policy = StoragePolicy(policy_index, **policy_options) + config_options = dict(conf.items(section)) + policy_type = config_options.pop('policy_type', DEFAULT_POLICY_TYPE) + policy_cls = BaseStoragePolicy.policy_type_to_policy_cls[policy_type] + policy = policy_cls.from_config(policy_index, config_options) policies.append(policy) return StoragePolicyCollection(policies) diff --git a/swift/common/swob.py b/swift/common/swob.py index 1c43316ba4..c2e3afb4e8 100644 --- a/swift/common/swob.py +++ b/swift/common/swob.py @@ -36,7 +36,7 @@ needs to change. """ from collections import defaultdict -from cStringIO import StringIO +from StringIO import StringIO import UserDict import time from functools import partial @@ -128,6 +128,20 @@ class _UTC(tzinfo): UTC = _UTC() +class WsgiStringIO(StringIO): + """ + This class adds support for the additional wsgi.input methods defined on + eventlet.wsgi.Input to the StringIO class which would otherwise be a fine + stand-in for the file-like object in the WSGI environment. + """ + + def set_hundred_continue_response_headers(self, headers): + pass + + def send_hundred_continue_response(self): + pass + + def _datetime_property(header): """ Set and retrieve the datetime value of self.headers[header] @@ -743,16 +757,16 @@ def _req_environ_property(environ_field): def _req_body_property(): """ Set and retrieve the Request.body parameter. It consumes wsgi.input and - returns the results. On assignment, uses a StringIO to create a new + returns the results. On assignment, uses a WsgiStringIO to create a new wsgi.input. """ def getter(self): body = self.environ['wsgi.input'].read() - self.environ['wsgi.input'] = StringIO(body) + self.environ['wsgi.input'] = WsgiStringIO(body) return body def setter(self, value): - self.environ['wsgi.input'] = StringIO(value) + self.environ['wsgi.input'] = WsgiStringIO(value) self.environ['CONTENT_LENGTH'] = str(len(value)) return property(getter, setter, doc="Get and set the request body str") @@ -820,7 +834,7 @@ class Request(object): :param path: encoded, parsed, and unquoted into PATH_INFO :param environ: WSGI environ dictionary :param headers: HTTP headers - :param body: stuffed in a StringIO and hung on wsgi.input + :param body: stuffed in a WsgiStringIO and hung on wsgi.input :param kwargs: any environ key with an property setter """ headers = headers or {} @@ -855,10 +869,10 @@ class Request(object): } env.update(environ) if body is not None: - env['wsgi.input'] = StringIO(body) + env['wsgi.input'] = WsgiStringIO(body) env['CONTENT_LENGTH'] = str(len(body)) elif 'wsgi.input' not in env: - env['wsgi.input'] = StringIO('') + env['wsgi.input'] = WsgiStringIO('') req = Request(env) for key, val in headers.iteritems(): req.headers[key] = val @@ -928,6 +942,10 @@ class Request(object): if entity_path is not None: return '/' + entity_path + @property + def is_chunked(self): + return 'chunked' in self.headers.get('transfer-encoding', '') + @property def url(self): "Provides the full url of the request" @@ -961,7 +979,7 @@ class Request(object): env.update({ 'REQUEST_METHOD': 'GET', 'CONTENT_LENGTH': '0', - 'wsgi.input': StringIO(''), + 'wsgi.input': WsgiStringIO(''), }) return Request(env) @@ -1098,10 +1116,12 @@ class Response(object): app_iter = _resp_app_iter_property() def __init__(self, body=None, status=200, headers=None, app_iter=None, - request=None, conditional_response=False, **kw): + request=None, conditional_response=False, + conditional_etag=None, **kw): self.headers = HeaderKeyDict( [('Content-Type', 'text/html; charset=UTF-8')]) self.conditional_response = conditional_response + self._conditional_etag = conditional_etag self.request = request self.body = body self.app_iter = app_iter @@ -1127,6 +1147,26 @@ class Response(object): if 'charset' in kw and 'content_type' in kw: self.charset = kw['charset'] + @property + def conditional_etag(self): + """ + The conditional_etag keyword argument for Response will allow the + conditional match value of a If-Match request to be compared to a + non-standard value. + + This is available for Storage Policies that do not store the client + object data verbatim on the storage nodes, but still need support + conditional requests. + + It's most effectively used with X-Backend-Etag-Is-At which would + define the additional Metadata key where the original ETag of the + clear-form client request data. + """ + if self._conditional_etag is not None: + return self._conditional_etag + else: + return self.etag + def _prepare_for_ranges(self, ranges): """ Prepare the Response for multiple ranges. @@ -1157,15 +1197,16 @@ class Response(object): return content_size, content_type def _response_iter(self, app_iter, body): + etag = self.conditional_etag if self.conditional_response and self.request: - if self.etag and self.request.if_none_match and \ - self.etag in self.request.if_none_match: + if etag and self.request.if_none_match and \ + etag in self.request.if_none_match: self.status = 304 self.content_length = 0 return [''] - if self.etag and self.request.if_match and \ - self.etag not in self.request.if_match: + if etag and self.request.if_match and \ + etag not in self.request.if_match: self.status = 412 self.content_length = 0 return [''] diff --git a/swift/common/utils.py b/swift/common/utils.py index 86bb01a32f..19dcfd3d61 100644 --- a/swift/common/utils.py +++ b/swift/common/utils.py @@ -1062,19 +1062,21 @@ class NullLogger(object): class LoggerFileObject(object): - def __init__(self, logger): + def __init__(self, logger, log_type='STDOUT'): self.logger = logger + self.log_type = log_type def write(self, value): value = value.strip() if value: if 'Connection reset by peer' in value: - self.logger.error(_('STDOUT: Connection reset by peer')) + self.logger.error( + _('%s: Connection reset by peer'), self.log_type) else: - self.logger.error(_('STDOUT: %s'), value) + self.logger.error(_('%s: %s'), self.log_type, value) def writelines(self, values): - self.logger.error(_('STDOUT: %s'), '#012'.join(values)) + self.logger.error(_('%s: %s'), self.log_type, '#012'.join(values)) def close(self): pass @@ -1527,11 +1529,11 @@ def get_logger(conf, name=None, log_to_console=False, log_route=None, logger_hook(conf, name, log_to_console, log_route, fmt, logger, adapted_logger) except (AttributeError, ImportError): - print( - 'Error calling custom handler [%s]' % hook, - file=sys.stderr) + print('Error calling custom handler [%s]' % hook, + file=sys.stderr) except ValueError: - print('Invalid custom handler format [%s]' % hook, sys.stderr) + print('Invalid custom handler format [%s]' % hook, + file=sys.stderr) # Python 2.6 has the undesirable property of keeping references to all log # handlers around forever in logging._handlers and logging._handlerList. @@ -1641,7 +1643,7 @@ def capture_stdio(logger, **kwargs): if kwargs.pop('capture_stdout', True): sys.stdout = LoggerFileObject(logger) if kwargs.pop('capture_stderr', True): - sys.stderr = LoggerFileObject(logger) + sys.stderr = LoggerFileObject(logger, 'STDERR') def parse_options(parser=None, once=False, test_args=None): @@ -2234,11 +2236,16 @@ class GreenAsyncPile(object): Correlating results with jobs (if necessary) is left to the caller. """ - def __init__(self, size): + def __init__(self, size_or_pool): """ - :param size: size pool of green threads to use + :param size_or_pool: thread pool size or a pool to use """ - self._pool = GreenPool(size) + if isinstance(size_or_pool, GreenPool): + self._pool = size_or_pool + size = self._pool.size + else: + self._pool = GreenPool(size_or_pool) + size = size_or_pool self._responses = eventlet.queue.LightQueue(size) self._inflight = 0 @@ -2644,6 +2651,10 @@ def public(func): def quorum_size(n): """ + quorum size as it applies to services that use 'replication' for data + integrity (Account/Container services). Object quorum_size is defined + on a storage policy basis. + Number of successful backend requests needed for the proxy to consider the client request successful. """ @@ -3137,6 +3148,26 @@ _rfc_extension_pattern = re.compile( r'(?:\s*;\s*(' + _rfc_token + r")\s*(?:=\s*(" + _rfc_token + r'|"(?:[^"\\]|\\.)*"))?)') +_content_range_pattern = re.compile(r'^bytes (\d+)-(\d+)/(\d+)$') + + +def parse_content_range(content_range): + """ + Parse a content-range header into (first_byte, last_byte, total_size). + + See RFC 7233 section 4.2 for details on the header format, but it's + basically "Content-Range: bytes ${start}-${end}/${total}". + + :param content_range: Content-Range header value to parse, + e.g. "bytes 100-1249/49004" + :returns: 3-tuple (start, end, total) + :raises: ValueError if malformed + """ + found = re.search(_content_range_pattern, content_range) + if not found: + raise ValueError("malformed Content-Range %r" % (content_range,)) + return tuple(int(x) for x in found.groups()) + def parse_content_type(content_type): """ @@ -3291,8 +3322,11 @@ def iter_multipart_mime_documents(wsgi_input, boundary, read_chunk_size=4096): :raises: MimeInvalid if the document is malformed """ boundary = '--' + boundary - if wsgi_input.readline(len(boundary + '\r\n')).strip() != boundary: - raise swift.common.exceptions.MimeInvalid('invalid starting boundary') + blen = len(boundary) + 2 # \r\n + got = wsgi_input.readline(blen) + if got.strip() != boundary: + raise swift.common.exceptions.MimeInvalid( + 'invalid starting boundary: wanted %r, got %r', (boundary, got)) boundary = '\r\n' + boundary input_buffer = '' done = False diff --git a/swift/common/wsgi.py b/swift/common/wsgi.py index b1e1f5ea73..35df2077f2 100644 --- a/swift/common/wsgi.py +++ b/swift/common/wsgi.py @@ -25,6 +25,7 @@ import time import mimetools from swift import gettext_ as _ from StringIO import StringIO +from textwrap import dedent import eventlet import eventlet.debug @@ -96,13 +97,34 @@ def _loadconfigdir(object_type, uri, path, name, relative_to, global_conf): loadwsgi._loaders['config_dir'] = _loadconfigdir +class ConfigString(NamedConfigLoader): + """ + Wrap a raw config string up for paste.deploy. + + If you give one of these to our loadcontext (e.g. give it to our + appconfig) we'll intercept it and get it routed to the right loader. + """ + + def __init__(self, config_string): + self.contents = StringIO(dedent(config_string)) + self.filename = "string" + defaults = { + 'here': "string", + '__file__': "string", + } + self.parser = loadwsgi.NicerConfigParser("string", defaults=defaults) + self.parser.optionxform = str # Don't lower-case keys + self.parser.readfp(self.contents) + + def wrap_conf_type(f): """ Wrap a function whos first argument is a paste.deploy style config uri, - such that you can pass it an un-adorned raw filesystem path and the config - directive (either config: or config_dir:) will be added automatically - based on the type of filesystem entity at the given path (either a file or - directory) before passing it through to the paste.deploy function. + such that you can pass it an un-adorned raw filesystem path (or config + string) and the config directive (either config:, config_dir:, or + config_str:) will be added automatically based on the type of entity + (either a file or directory, or if no such entity on the file system - + just a string) before passing it through to the paste.deploy function. """ def wrapper(conf_path, *args, **kwargs): if os.path.isdir(conf_path): @@ -332,6 +354,12 @@ class PipelineWrapper(object): def loadcontext(object_type, uri, name=None, relative_to=None, global_conf=None): + if isinstance(uri, loadwsgi.ConfigLoader): + # bypass loadcontext's uri parsing and loader routing and + # just directly return the context + if global_conf: + uri.update_defaults(global_conf, overwrite=False) + return uri.get_context(object_type, name, global_conf) add_conf_type = wrap_conf_type(lambda x: x) return loadwsgi.loadcontext(object_type, add_conf_type(uri), name=name, relative_to=relative_to, diff --git a/swift/container/backend.py b/swift/container/backend.py index d1b9385427..de42f4bde8 100644 --- a/swift/container/backend.py +++ b/swift/container/backend.py @@ -158,6 +158,8 @@ class ContainerBroker(DatabaseBroker): if not self.container: raise ValueError( 'Attempting to create a new database with no container set') + if storage_policy_index is None: + storage_policy_index = 0 self.create_object_table(conn) self.create_policy_stat_table(conn, storage_policy_index) self.create_container_info_table(conn, put_timestamp, diff --git a/swift/container/reconciler.py b/swift/container/reconciler.py index 12c81be9df..ba896ae527 100644 --- a/swift/container/reconciler.py +++ b/swift/container/reconciler.py @@ -137,7 +137,7 @@ def get_reconciler_content_type(op): raise ValueError('invalid operation type %r' % op) -def get_row_to_q_entry_translater(broker): +def get_row_to_q_entry_translator(broker): account = broker.account container = broker.container op_type = { @@ -145,7 +145,7 @@ def get_row_to_q_entry_translater(broker): 1: get_reconciler_content_type('delete'), } - def translater(obj_info): + def translator(obj_info): name = get_reconciler_obj_name(obj_info['storage_policy_index'], account, container, obj_info['name']) @@ -157,7 +157,7 @@ def get_row_to_q_entry_translater(broker): 'content_type': op_type[obj_info['deleted']], 'size': 0, } - return translater + return translator def add_to_reconciler_queue(container_ring, account, container, obj, diff --git a/swift/container/replicator.py b/swift/container/replicator.py index 8974535251..9fa32e4962 100644 --- a/swift/container/replicator.py +++ b/swift/container/replicator.py @@ -22,7 +22,7 @@ from eventlet import Timeout from swift.container.backend import ContainerBroker, DATADIR from swift.container.reconciler import ( MISPLACED_OBJECTS_ACCOUNT, incorrect_policy_index, - get_reconciler_container_name, get_row_to_q_entry_translater) + get_reconciler_container_name, get_row_to_q_entry_translator) from swift.common import db_replicator from swift.common.storage_policy import POLICIES from swift.common.exceptions import DeviceUnavailable @@ -166,14 +166,14 @@ class ContainerReplicator(db_replicator.Replicator): misplaced = broker.get_misplaced_since(point, self.per_diff) if not misplaced: return max_sync - translater = get_row_to_q_entry_translater(broker) + translator = get_row_to_q_entry_translator(broker) errors = False low_sync = point while misplaced: batches = defaultdict(list) for item in misplaced: container = get_reconciler_container_name(item['created_at']) - batches[container].append(translater(item)) + batches[container].append(translator(item)) for container, item_list in batches.items(): success = self.feed_reconciler(container, item_list) if not success: diff --git a/swift/container/sync.py b/swift/container/sync.py index 4bf5fc5c36..a409de4ac7 100644 --- a/swift/container/sync.py +++ b/swift/container/sync.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import errno import os import uuid from swift import gettext_ as _ @@ -25,8 +26,8 @@ from eventlet import sleep, Timeout import swift.common.db from swift.container.backend import ContainerBroker, DATADIR from swift.common.container_sync_realms import ContainerSyncRealms -from swift.common.direct_client import direct_get_object -from swift.common.internal_client import delete_object, put_object +from swift.common.internal_client import ( + delete_object, put_object, InternalClient, UnexpectedResponse) from swift.common.exceptions import ClientException from swift.common.ring import Ring from swift.common.ring.utils import is_local_device @@ -37,6 +38,55 @@ from swift.common.utils import ( from swift.common.daemon import Daemon from swift.common.http import HTTP_UNAUTHORIZED, HTTP_NOT_FOUND from swift.common.storage_policy import POLICIES +from swift.common.wsgi import ConfigString + + +# The default internal client config body is to support upgrades without +# requiring deployment of the new /etc/swift/internal-client.conf +ic_conf_body = """ +[DEFAULT] +# swift_dir = /etc/swift +# user = swift +# You can specify default log routing here if you want: +# log_name = swift +# log_facility = LOG_LOCAL0 +# log_level = INFO +# log_address = /dev/log +# +# comma separated list of functions to call to setup custom log handlers. +# functions get passed: conf, name, log_to_console, log_route, fmt, logger, +# adapted_logger +# log_custom_handlers = +# +# If set, log_udp_host will override log_address +# log_udp_host = +# log_udp_port = 514 +# +# You can enable StatsD logging here: +# log_statsd_host = localhost +# log_statsd_port = 8125 +# log_statsd_default_sample_rate = 1.0 +# log_statsd_sample_rate_factor = 1.0 +# log_statsd_metric_prefix = + +[pipeline:main] +pipeline = catch_errors proxy-logging cache proxy-server + +[app:proxy-server] +use = egg:swift#proxy +# See proxy-server.conf-sample for options + +[filter:cache] +use = egg:swift#memcache +# See proxy-server.conf-sample for options + +[filter:proxy-logging] +use = egg:swift#proxy_logging + +[filter:catch_errors] +use = egg:swift#catch_errors +# See proxy-server.conf-sample for options +""".lstrip() class ContainerSync(Daemon): @@ -103,12 +153,12 @@ class ContainerSync(Daemon): loaded. This is overridden by unit tests. """ - def __init__(self, conf, container_ring=None): + def __init__(self, conf, container_ring=None, logger=None): #: The dict of configuration values from the [container-sync] section #: of the container-server.conf. self.conf = conf #: Logger to use for container-sync log lines. - self.logger = get_logger(conf, log_route='container-sync') + self.logger = logger or get_logger(conf, log_route='container-sync') #: Path to the local device mount points. self.devices = conf.get('devices', '/srv/node') #: Indicates whether mount points should be verified as actual mount @@ -158,6 +208,27 @@ class ContainerSync(Daemon): self._myport = int(conf.get('bind_port', 6001)) swift.common.db.DB_PREALLOCATION = \ config_true_value(conf.get('db_preallocation', 'f')) + self.conn_timeout = float(conf.get('conn_timeout', 5)) + request_tries = int(conf.get('request_tries') or 3) + + internal_client_conf_path = conf.get('internal_client_conf_path') + if not internal_client_conf_path: + self.logger.warning( + _('Configuration option internal_client_conf_path not ' + 'defined. Using default configuration, See ' + 'internal-client.conf-sample for options')) + internal_client_conf = ConfigString(ic_conf_body) + else: + internal_client_conf = internal_client_conf_path + try: + self.swift = InternalClient( + internal_client_conf, 'Swift Container Sync', request_tries) + except IOError as err: + if err.errno != errno.ENOENT: + raise + raise SystemExit( + _('Unable to load internal client from config: %r (%s)') % + (internal_client_conf_path, err)) def get_object_ring(self, policy_idx): """ @@ -361,7 +432,8 @@ class ContainerSync(Daemon): headers['x-container-sync-key'] = user_key delete_object(sync_to, name=row['name'], headers=headers, proxy=self.select_http_proxy(), - logger=self.logger) + logger=self.logger, + timeout=self.conn_timeout) except ClientException as err: if err.http_status != HTTP_NOT_FOUND: raise @@ -378,39 +450,32 @@ class ContainerSync(Daemon): looking_for_timestamp = Timestamp(row['created_at']) timestamp = -1 headers = body = None - headers_out = {'X-Backend-Storage-Policy-Index': + # look up for the newest one + headers_out = {'X-Newest': True, + 'X-Backend-Storage-Policy-Index': str(info['storage_policy_index'])} - for node in nodes: - try: - these_headers, this_body = direct_get_object( - node, part, info['account'], info['container'], - row['name'], headers=headers_out, - resp_chunk_size=65536) - this_timestamp = Timestamp( - these_headers['x-timestamp']) - if this_timestamp > timestamp: - timestamp = this_timestamp - headers = these_headers - body = this_body - except ClientException as err: - # If any errors are not 404, make sure we report the - # non-404 one. We don't want to mistakenly assume the - # object no longer exists just because one says so and - # the others errored for some other reason. - if not exc or getattr( - exc, 'http_status', HTTP_NOT_FOUND) == \ - HTTP_NOT_FOUND: - exc = err - except (Exception, Timeout) as err: - exc = err + try: + source_obj_status, source_obj_info, source_obj_iter = \ + self.swift.get_object(info['account'], + info['container'], row['name'], + headers=headers_out, + acceptable_statuses=(2, 4)) + + except (Exception, UnexpectedResponse, Timeout) as err: + source_obj_info = {} + source_obj_iter = None + exc = err + timestamp = Timestamp(source_obj_info.get( + 'x-timestamp', 0)) + headers = source_obj_info + body = source_obj_iter if timestamp < looking_for_timestamp: if exc: raise exc raise Exception( - _('Unknown exception trying to GET: %(node)r ' + _('Unknown exception trying to GET: ' '%(account)r %(container)r %(object)r'), - {'node': node, 'part': part, - 'account': info['account'], + {'account': info['account'], 'container': info['container'], 'object': row['name']}) for key in ('date', 'last-modified'): @@ -434,7 +499,8 @@ class ContainerSync(Daemon): headers['x-container-sync-key'] = user_key put_object(sync_to, name=row['name'], headers=headers, contents=FileLikeIter(body), - proxy=self.select_http_proxy(), logger=self.logger) + proxy=self.select_http_proxy(), logger=self.logger, + timeout=self.conn_timeout) self.container_puts += 1 self.logger.increment('puts') self.logger.timing_since('puts.timing', start_time) diff --git a/swift/locale/swift.pot b/swift/locale/swift.pot index 6b23d2155a..19690cf934 100644 --- a/swift/locale/swift.pot +++ b/swift/locale/swift.pot @@ -6,9 +6,9 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: swift 2.2.2.post96\n" +"Project-Id-Version: swift 2.3.0rc1.post7\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2015-02-27 06:14+0000\n" +"POT-Creation-Date: 2015-04-16 06:06+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -63,98 +63,98 @@ msgstr "" msgid "ERROR Could not get account info %s" msgstr "" -#: swift/account/reaper.py:133 swift/common/utils.py:2058 -#: swift/obj/diskfile.py:468 swift/obj/updater.py:87 swift/obj/updater.py:130 +#: swift/account/reaper.py:134 swift/common/utils.py:2127 +#: swift/obj/diskfile.py:476 swift/obj/updater.py:88 swift/obj/updater.py:131 #, python-format msgid "Skipping %s as it is not mounted" msgstr "" -#: swift/account/reaper.py:137 +#: swift/account/reaper.py:138 msgid "Exception in top-level account reaper loop" msgstr "" -#: swift/account/reaper.py:140 +#: swift/account/reaper.py:141 #, python-format msgid "Devices pass completed: %.02fs" msgstr "" -#: swift/account/reaper.py:237 +#: swift/account/reaper.py:238 #, python-format msgid "Beginning pass on account %s" msgstr "" -#: swift/account/reaper.py:254 +#: swift/account/reaper.py:255 #, python-format msgid "Exception with containers for account %s" msgstr "" -#: swift/account/reaper.py:261 +#: swift/account/reaper.py:262 #, python-format msgid "Exception with account %s" msgstr "" -#: swift/account/reaper.py:262 +#: swift/account/reaper.py:263 #, python-format msgid "Incomplete pass on account %s" msgstr "" -#: swift/account/reaper.py:264 +#: swift/account/reaper.py:265 #, python-format msgid ", %s containers deleted" msgstr "" -#: swift/account/reaper.py:266 +#: swift/account/reaper.py:267 #, python-format msgid ", %s objects deleted" msgstr "" -#: swift/account/reaper.py:268 +#: swift/account/reaper.py:269 #, python-format msgid ", %s containers remaining" msgstr "" -#: swift/account/reaper.py:271 +#: swift/account/reaper.py:272 #, python-format msgid ", %s objects remaining" msgstr "" -#: swift/account/reaper.py:273 +#: swift/account/reaper.py:274 #, python-format msgid ", %s containers possibly remaining" msgstr "" -#: swift/account/reaper.py:276 +#: swift/account/reaper.py:277 #, python-format msgid ", %s objects possibly remaining" msgstr "" -#: swift/account/reaper.py:279 +#: swift/account/reaper.py:280 msgid ", return codes: " msgstr "" -#: swift/account/reaper.py:283 +#: swift/account/reaper.py:284 #, python-format msgid ", elapsed: %.02fs" msgstr "" -#: swift/account/reaper.py:289 +#: swift/account/reaper.py:290 #, python-format msgid "Account %s has not been reaped since %s" msgstr "" -#: swift/account/reaper.py:348 swift/account/reaper.py:396 -#: swift/account/reaper.py:463 swift/container/updater.py:306 +#: swift/account/reaper.py:349 swift/account/reaper.py:397 +#: swift/account/reaper.py:464 swift/container/updater.py:306 #, python-format msgid "Exception with %(ip)s:%(port)s/%(device)s" msgstr "" -#: swift/account/reaper.py:368 +#: swift/account/reaper.py:369 #, python-format msgid "Exception with objects for container %(container)s for account %(account)s" msgstr "" #: swift/account/server.py:275 swift/container/server.py:582 -#: swift/obj/server.py:730 +#: swift/obj/server.py:910 #, python-format msgid "ERROR __call__ error with %(method)s %(path)s " msgstr "" @@ -270,90 +270,95 @@ msgstr "" msgid "Unexpected response: %s" msgstr "" -#: swift/common/manager.py:62 +#: swift/common/manager.py:63 msgid "WARNING: Unable to modify file descriptor limit. Running as non-root?" msgstr "" -#: swift/common/manager.py:69 +#: swift/common/manager.py:70 msgid "WARNING: Unable to modify memory limit. Running as non-root?" msgstr "" -#: swift/common/manager.py:76 +#: swift/common/manager.py:77 msgid "WARNING: Unable to modify max process limit. Running as non-root?" msgstr "" -#: swift/common/manager.py:194 +#: swift/common/manager.py:195 msgid "" "\n" "user quit" msgstr "" -#: swift/common/manager.py:231 swift/common/manager.py:543 +#: swift/common/manager.py:232 swift/common/manager.py:547 #, python-format msgid "No %s running" msgstr "" -#: swift/common/manager.py:244 +#: swift/common/manager.py:245 #, python-format msgid "%s (%s) appears to have stopped" msgstr "" -#: swift/common/manager.py:254 +#: swift/common/manager.py:255 #, python-format msgid "Waited %s seconds for %s to die; giving up" msgstr "" -#: swift/common/manager.py:437 +#: swift/common/manager.py:439 #, python-format -msgid "Unable to locate config %sfor %s" +msgid "Unable to locate config number %s for %s" msgstr "" -#: swift/common/manager.py:441 +#: swift/common/manager.py:442 +#, python-format +msgid "Unable to locate config for %s" +msgstr "" + +#: swift/common/manager.py:445 msgid "Found configs:" msgstr "" -#: swift/common/manager.py:485 +#: swift/common/manager.py:489 #, python-format msgid "Signal %s pid: %s signal: %s" msgstr "" -#: swift/common/manager.py:492 +#: swift/common/manager.py:496 #, python-format msgid "Removing stale pid file %s" msgstr "" -#: swift/common/manager.py:495 +#: swift/common/manager.py:499 #, python-format msgid "No permission to signal PID %d" msgstr "" -#: swift/common/manager.py:540 +#: swift/common/manager.py:544 #, python-format msgid "%s #%d not running (%s)" msgstr "" -#: swift/common/manager.py:547 swift/common/manager.py:640 -#: swift/common/manager.py:643 +#: swift/common/manager.py:551 swift/common/manager.py:644 +#: swift/common/manager.py:647 #, python-format msgid "%s running (%s - %s)" msgstr "" -#: swift/common/manager.py:646 +#: swift/common/manager.py:650 #, python-format msgid "%s already started..." msgstr "" -#: swift/common/manager.py:655 +#: swift/common/manager.py:659 #, python-format msgid "Running %s once" msgstr "" -#: swift/common/manager.py:657 +#: swift/common/manager.py:661 #, python-format msgid "Starting %s" msgstr "" -#: swift/common/manager.py:664 +#: swift/common/manager.py:668 #, python-format msgid "%s does not exist" msgstr "" @@ -373,7 +378,12 @@ msgstr "" msgid "Error limiting server %s" msgstr "" -#: swift/common/request_helpers.py:387 +#: swift/common/request_helpers.py:102 +#, python-format +msgid "No policy with index %s" +msgstr "" + +#: swift/common/request_helpers.py:395 msgid "ERROR: An error occurred while retrieving segments" msgstr "" @@ -386,95 +396,101 @@ msgstr "" msgid "Unable to locate fallocate, posix_fallocate in libc. Leaving as a no-op." msgstr "" -#: swift/common/utils.py:1005 -msgid "STDOUT: Connection reset by peer" -msgstr "" - -#: swift/common/utils.py:1007 swift/common/utils.py:1010 +#: swift/common/utils.py:662 #, python-format -msgid "STDOUT: %s" +msgid "Unable to perform fsync() on directory %s: %s" msgstr "" -#: swift/common/utils.py:1245 +#: swift/common/utils.py:1074 +#, python-format +msgid "%s: Connection reset by peer" +msgstr "" + +#: swift/common/utils.py:1076 swift/common/utils.py:1079 +#, python-format +msgid "%s: %s" +msgstr "" + +#: swift/common/utils.py:1314 msgid "Connection refused" msgstr "" -#: swift/common/utils.py:1247 +#: swift/common/utils.py:1316 msgid "Host unreachable" msgstr "" -#: swift/common/utils.py:1249 +#: swift/common/utils.py:1318 msgid "Connection timeout" msgstr "" -#: swift/common/utils.py:1551 +#: swift/common/utils.py:1620 msgid "UNCAUGHT EXCEPTION" msgstr "" -#: swift/common/utils.py:1606 +#: swift/common/utils.py:1675 msgid "Error: missing config path argument" msgstr "" -#: swift/common/utils.py:1611 +#: swift/common/utils.py:1680 #, python-format msgid "Error: unable to locate %s" msgstr "" -#: swift/common/utils.py:1919 +#: swift/common/utils.py:1988 #, python-format msgid "Unable to read config from %s" msgstr "" -#: swift/common/utils.py:1925 +#: swift/common/utils.py:1994 #, python-format msgid "Unable to find %s config section in %s" msgstr "" -#: swift/common/utils.py:2279 +#: swift/common/utils.py:2353 #, python-format msgid "Invalid X-Container-Sync-To format %r" msgstr "" -#: swift/common/utils.py:2284 +#: swift/common/utils.py:2358 #, python-format msgid "No realm key for %r" msgstr "" -#: swift/common/utils.py:2288 +#: swift/common/utils.py:2362 #, python-format msgid "No cluster endpoint for %r %r" msgstr "" -#: swift/common/utils.py:2297 +#: swift/common/utils.py:2371 #, python-format msgid "" "Invalid scheme %r in X-Container-Sync-To, must be \"//\", \"http\", or " "\"https\"." msgstr "" -#: swift/common/utils.py:2301 +#: swift/common/utils.py:2375 msgid "Path required in X-Container-Sync-To" msgstr "" -#: swift/common/utils.py:2304 +#: swift/common/utils.py:2378 msgid "Params, queries, and fragments not allowed in X-Container-Sync-To" msgstr "" -#: swift/common/utils.py:2309 +#: swift/common/utils.py:2383 #, python-format msgid "Invalid host %r in X-Container-Sync-To" msgstr "" -#: swift/common/utils.py:2501 +#: swift/common/utils.py:2575 msgid "Exception dumping recon cache" msgstr "" -#: swift/common/wsgi.py:175 +#: swift/common/wsgi.py:197 #, python-format msgid "Could not bind to %s:%s after trying for %s seconds" msgstr "" -#: swift/common/wsgi.py:185 +#: swift/common/wsgi.py:207 msgid "" "WARNING: SSL should only be enabled for testing purposes. Use external " "SSL termination for a production deployment." @@ -515,27 +531,27 @@ msgstr "" msgid "Warning: Cannot ratelimit without a memcached client" msgstr "" -#: swift/common/middleware/recon.py:78 +#: swift/common/middleware/recon.py:80 msgid "Error reading recon cache file" msgstr "" -#: swift/common/middleware/recon.py:80 +#: swift/common/middleware/recon.py:82 msgid "Error parsing recon cache file" msgstr "" -#: swift/common/middleware/recon.py:82 +#: swift/common/middleware/recon.py:84 msgid "Error retrieving recon data" msgstr "" -#: swift/common/middleware/recon.py:151 +#: swift/common/middleware/recon.py:158 msgid "Error listing devices" msgstr "" -#: swift/common/middleware/recon.py:247 +#: swift/common/middleware/recon.py:254 msgid "Error reading ringfile" msgstr "" -#: swift/common/middleware/recon.py:261 +#: swift/common/middleware/recon.py:268 msgid "Error reading swift.conf" msgstr "" @@ -642,52 +658,61 @@ msgid "" "later)" msgstr "" -#: swift/container/sync.py:193 +#: swift/container/sync.py:217 +msgid "" +"Configuration option internal_client_conf_path not defined. Using default" +" configuration, See internal-client.conf-sample for options" +msgstr "" + +#: swift/container/sync.py:230 +#, python-format +msgid "Unable to load internal client from config: %r (%s)" +msgstr "" + +#: swift/container/sync.py:264 msgid "Begin container sync \"once\" mode" msgstr "" -#: swift/container/sync.py:205 +#: swift/container/sync.py:276 #, python-format msgid "Container sync \"once\" mode completed: %.02fs" msgstr "" -#: swift/container/sync.py:213 +#: swift/container/sync.py:284 #, python-format msgid "" "Since %(time)s: %(sync)s synced [%(delete)s deletes, %(put)s puts], " "%(skip)s skipped, %(fail)s failed" msgstr "" -#: swift/container/sync.py:266 +#: swift/container/sync.py:337 #, python-format msgid "ERROR %(db_file)s: %(validate_sync_to_err)s" msgstr "" -#: swift/container/sync.py:322 +#: swift/container/sync.py:393 #, python-format msgid "ERROR Syncing %s" msgstr "" -#: swift/container/sync.py:410 +#: swift/container/sync.py:476 #, python-format -msgid "" -"Unknown exception trying to GET: %(node)r %(account)r %(container)r " -"%(object)r" +msgid "Unknown exception trying to GET: %(account)r %(container)r %(object)r" msgstr "" -#: swift/container/sync.py:444 +#: swift/container/sync.py:510 #, python-format msgid "Unauth %(sync_from)r => %(sync_to)r" msgstr "" -#: swift/container/sync.py:450 +#: swift/container/sync.py:516 #, python-format msgid "" "Not found %(sync_from)r => %(sync_to)r - object " "%(obj_name)r" msgstr "" -#: swift/container/sync.py:457 swift/container/sync.py:464 +#: swift/container/sync.py:523 swift/container/sync.py:530 #, python-format msgid "ERROR Syncing %(db_file)s %(row)s" msgstr "" @@ -697,8 +722,8 @@ msgstr "" msgid "ERROR: Failed to get paths to drive partitions: %s" msgstr "" -#: swift/container/updater.py:91 swift/obj/replicator.py:483 -#: swift/obj/replicator.py:569 +#: swift/container/updater.py:91 swift/obj/reconstructor.py:788 +#: swift/obj/replicator.py:487 swift/obj/replicator.py:575 #, python-format msgid "%s is not mounted" msgstr "" @@ -810,42 +835,57 @@ msgstr "" msgid "ERROR auditing: %s" msgstr "" -#: swift/obj/diskfile.py:318 +#: swift/obj/diskfile.py:323 swift/obj/diskfile.py:2305 #, python-format msgid "Quarantined %(hsh_path)s to %(quar_path)s because it is not a directory" msgstr "" -#: swift/obj/diskfile.py:407 +#: swift/obj/diskfile.py:414 swift/obj/diskfile.py:2373 msgid "Error hashing suffix" msgstr "" -#: swift/obj/diskfile.py:482 swift/obj/updater.py:169 +#: swift/obj/diskfile.py:486 swift/obj/updater.py:162 #, python-format -msgid "Directory %s does not map to a valid policy" +msgid "Directory %r does not map to a valid policy (%s)" msgstr "" -#: swift/obj/diskfile.py:676 +#: swift/obj/diskfile.py:737 #, python-format msgid "Quarantined %(object_path)s to %(quar_path)s because it is not a directory" msgstr "" -#: swift/obj/diskfile.py:867 +#: swift/obj/diskfile.py:936 swift/obj/diskfile.py:1795 #, python-format msgid "Problem cleaning up %s" msgstr "" -#: swift/obj/diskfile.py:1166 +#: swift/obj/diskfile.py:1253 #, python-format msgid "ERROR DiskFile %(data_file)s close failure: %(exc)s : %(stack)s" msgstr "" -#: swift/obj/diskfile.py:1447 +#: swift/obj/diskfile.py:1543 #, python-format msgid "" "Client path %(client)s does not match path stored in object metadata " "%(meta)s" msgstr "" +#: swift/obj/diskfile.py:1797 +#, python-format +msgid "Problem fsyncing durable state file: %s" +msgstr "" + +#: swift/obj/diskfile.py:1802 +#, python-format +msgid "No space left on device for %s" +msgstr "" + +#: swift/obj/diskfile.py:1806 +#, python-format +msgid "Problem writing durable state file: %s" +msgstr "" + #: swift/obj/expirer.py:79 #, python-format msgid "Pass completed in %ds; %d objects expired" @@ -875,338 +915,394 @@ msgstr "" msgid "Exception while deleting object %s %s %s" msgstr "" -#: swift/obj/mem_server.py:87 +#: swift/obj/reconstructor.py:189 swift/obj/reconstructor.py:472 +#, python-format +msgid "Invalid response %(resp)s from %(full_path)s" +msgstr "" + +#: swift/obj/reconstructor.py:195 +#, python-format +msgid "Trying to GET %(full_path)s" +msgstr "" + +#: swift/obj/reconstructor.py:301 +#, python-format +msgid "Error trying to rebuild %(path)s policy#%(policy)d frag#%(frag_index)s" +msgstr "" + +#: swift/obj/reconstructor.py:324 #, python-format msgid "" -"ERROR Container update failed: %(status)d response from " -"%(ip)s:%(port)s/%(dev)s" +"%(reconstructed)d/%(total)d (%(percentage).2f%%) partitions reconstructed" +" in %(time).2fs (%(rate).2f/sec, %(remaining)s remaining)" msgstr "" -#: swift/obj/mem_server.py:93 -#, python-format -msgid "ERROR container update failed with %(ip)s:%(port)s/%(dev)s" -msgstr "" - -#: swift/obj/replicator.py:138 -#, python-format -msgid "Killing long-running rsync: %s" -msgstr "" - -#: swift/obj/replicator.py:152 -#, python-format -msgid "Bad rsync return code: %(ret)d <- %(args)s" -msgstr "" - -#: swift/obj/replicator.py:159 swift/obj/replicator.py:163 -#, python-format -msgid "Successful rsync of %(src)s at %(dst)s (%(time).03f)" -msgstr "" - -#: swift/obj/replicator.py:277 -#, python-format -msgid "Removing %s objects" -msgstr "" - -#: swift/obj/replicator.py:285 -msgid "Error syncing handoff partition" -msgstr "" - -#: swift/obj/replicator.py:291 -#, python-format -msgid "Removing partition: %s" -msgstr "" - -#: swift/obj/replicator.py:346 -#, python-format -msgid "%(ip)s/%(device)s responded as unmounted" -msgstr "" - -#: swift/obj/replicator.py:351 -#, python-format -msgid "Invalid response %(resp)s from %(ip)s" -msgstr "" - -#: swift/obj/replicator.py:386 -#, python-format -msgid "Error syncing with node: %s" -msgstr "" - -#: swift/obj/replicator.py:390 -msgid "Error syncing partition" -msgstr "" - -#: swift/obj/replicator.py:403 -#, python-format -msgid "" -"%(replicated)d/%(total)d (%(percentage).2f%%) partitions replicated in " -"%(time).2fs (%(rate).2f/sec, %(remaining)s remaining)" -msgstr "" - -#: swift/obj/replicator.py:414 +#: swift/obj/reconstructor.py:337 swift/obj/replicator.py:419 #, python-format msgid "" "%(checked)d suffixes checked - %(hashed).2f%% hashed, %(synced).2f%% " "synced" msgstr "" -#: swift/obj/replicator.py:421 +#: swift/obj/reconstructor.py:344 swift/obj/replicator.py:426 #, python-format msgid "Partition times: max %(max).4fs, min %(min).4fs, med %(med).4fs" msgstr "" -#: swift/obj/replicator.py:429 +#: swift/obj/reconstructor.py:352 +#, python-format +msgid "Nothing reconstructed for %s seconds." +msgstr "" + +#: swift/obj/reconstructor.py:381 swift/obj/replicator.py:463 +msgid "Lockup detected.. killing live coros." +msgstr "" + +#: swift/obj/reconstructor.py:442 +#, python-format +msgid "Trying to sync suffixes with %s" +msgstr "" + +#: swift/obj/reconstructor.py:467 +#, python-format +msgid "%s responded as unmounted" +msgstr "" + +#: swift/obj/reconstructor.py:849 swift/obj/replicator.py:295 +#, python-format +msgid "Removing partition: %s" +msgstr "" + +#: swift/obj/reconstructor.py:865 +msgid "Ring change detected. Aborting current reconstruction pass." +msgstr "" + +#: swift/obj/reconstructor.py:884 +msgid "Exception in top-levelreconstruction loop" +msgstr "" + +#: swift/obj/reconstructor.py:894 +msgid "Running object reconstructor in script mode." +msgstr "" + +#: swift/obj/reconstructor.py:903 +#, python-format +msgid "Object reconstruction complete (once). (%.02f minutes)" +msgstr "" + +#: swift/obj/reconstructor.py:910 +msgid "Starting object reconstructor in daemon mode." +msgstr "" + +#: swift/obj/reconstructor.py:914 +msgid "Starting object reconstruction pass." +msgstr "" + +#: swift/obj/reconstructor.py:919 +#, python-format +msgid "Object reconstruction complete. (%.02f minutes)" +msgstr "" + +#: swift/obj/replicator.py:139 +#, python-format +msgid "Killing long-running rsync: %s" +msgstr "" + +#: swift/obj/replicator.py:153 +#, python-format +msgid "Bad rsync return code: %(ret)d <- %(args)s" +msgstr "" + +#: swift/obj/replicator.py:160 swift/obj/replicator.py:164 +#, python-format +msgid "Successful rsync of %(src)s at %(dst)s (%(time).03f)" +msgstr "" + +#: swift/obj/replicator.py:281 +#, python-format +msgid "Removing %s objects" +msgstr "" + +#: swift/obj/replicator.py:289 +msgid "Error syncing handoff partition" +msgstr "" + +#: swift/obj/replicator.py:351 +#, python-format +msgid "%(ip)s/%(device)s responded as unmounted" +msgstr "" + +#: swift/obj/replicator.py:356 +#, python-format +msgid "Invalid response %(resp)s from %(ip)s" +msgstr "" + +#: swift/obj/replicator.py:391 +#, python-format +msgid "Error syncing with node: %s" +msgstr "" + +#: swift/obj/replicator.py:395 +msgid "Error syncing partition" +msgstr "" + +#: swift/obj/replicator.py:408 +#, python-format +msgid "" +"%(replicated)d/%(total)d (%(percentage).2f%%) partitions replicated in " +"%(time).2fs (%(rate).2f/sec, %(remaining)s remaining)" +msgstr "" + +#: swift/obj/replicator.py:434 #, python-format msgid "Nothing replicated for %s seconds." msgstr "" -#: swift/obj/replicator.py:458 -msgid "Lockup detected.. killing live coros." -msgstr "" - -#: swift/obj/replicator.py:572 +#: swift/obj/replicator.py:578 msgid "Ring change detected. Aborting current replication pass." msgstr "" -#: swift/obj/replicator.py:593 +#: swift/obj/replicator.py:599 msgid "Exception in top-level replication loop" msgstr "" -#: swift/obj/replicator.py:602 +#: swift/obj/replicator.py:608 msgid "Running object replicator in script mode." msgstr "" -#: swift/obj/replicator.py:620 +#: swift/obj/replicator.py:626 #, python-format msgid "Object replication complete (once). (%.02f minutes)" msgstr "" -#: swift/obj/replicator.py:627 +#: swift/obj/replicator.py:633 msgid "Starting object replicator in daemon mode." msgstr "" -#: swift/obj/replicator.py:631 +#: swift/obj/replicator.py:637 msgid "Starting object replication pass." msgstr "" -#: swift/obj/replicator.py:636 +#: swift/obj/replicator.py:642 #, python-format msgid "Object replication complete. (%.02f minutes)" msgstr "" -#: swift/obj/server.py:202 +#: swift/obj/server.py:231 #, python-format msgid "" "ERROR Container update failed (saving for async update later): %(status)d" " response from %(ip)s:%(port)s/%(dev)s" msgstr "" -#: swift/obj/server.py:209 +#: swift/obj/server.py:238 #, python-format msgid "" "ERROR container update failed with %(ip)s:%(port)s/%(dev)s (saving for " "async update later)" msgstr "" -#: swift/obj/server.py:244 +#: swift/obj/server.py:273 #, python-format msgid "" "ERROR Container update failed: different numbers of hosts and devices in " "request: \"%s\" vs \"%s\"" msgstr "" -#: swift/obj/updater.py:62 +#: swift/obj/updater.py:63 #, python-format msgid "ERROR: Unable to access %(path)s: %(error)s" msgstr "" -#: swift/obj/updater.py:77 +#: swift/obj/updater.py:78 msgid "Begin object update sweep" msgstr "" -#: swift/obj/updater.py:103 +#: swift/obj/updater.py:104 #, python-format msgid "" "Object update sweep of %(device)s completed: %(elapsed).02fs, %(success)s" " successes, %(fail)s failures" msgstr "" -#: swift/obj/updater.py:112 +#: swift/obj/updater.py:113 #, python-format msgid "Object update sweep completed: %.02fs" msgstr "" -#: swift/obj/updater.py:121 +#: swift/obj/updater.py:122 msgid "Begin object update single threaded sweep" msgstr "" -#: swift/obj/updater.py:135 +#: swift/obj/updater.py:136 #, python-format msgid "" "Object update single threaded sweep completed: %(elapsed).02fs, " "%(success)s successes, %(fail)s failures" msgstr "" -#: swift/obj/updater.py:187 +#: swift/obj/updater.py:179 #, python-format msgid "ERROR async pending file with unexpected name %s" msgstr "" -#: swift/obj/updater.py:217 +#: swift/obj/updater.py:209 #, python-format msgid "ERROR Pickle problem, quarantining %s" msgstr "" -#: swift/obj/updater.py:282 +#: swift/obj/updater.py:274 #, python-format msgid "ERROR with remote server %(ip)s:%(port)s/%(device)s" msgstr "" -#: swift/proxy/server.py:380 +#: swift/proxy/server.py:405 msgid "ERROR Unhandled exception in request" msgstr "" -#: swift/proxy/server.py:435 +#: swift/proxy/server.py:460 #, python-format msgid "Node error limited %(ip)s:%(port)s (%(device)s)" msgstr "" -#: swift/proxy/server.py:452 swift/proxy/server.py:470 +#: swift/proxy/server.py:477 swift/proxy/server.py:495 #, python-format msgid "%(msg)s %(ip)s:%(port)s/%(device)s" msgstr "" -#: swift/proxy/server.py:540 +#: swift/proxy/server.py:571 #, python-format msgid "ERROR with %(type)s server %(ip)s:%(port)s/%(device)s re: %(info)s" msgstr "" -#: swift/proxy/controllers/account.py:63 +#: swift/proxy/controllers/account.py:64 msgid "Account" msgstr "" -#: swift/proxy/controllers/base.py:698 swift/proxy/controllers/base.py:731 -#: swift/proxy/controllers/obj.py:191 swift/proxy/controllers/obj.py:318 -#: swift/proxy/controllers/obj.py:358 swift/proxy/controllers/obj.py:376 -#: swift/proxy/controllers/obj.py:502 +#: swift/proxy/controllers/base.py:752 swift/proxy/controllers/base.py:814 +#: swift/proxy/controllers/obj.py:364 swift/proxy/controllers/obj.py:411 +#: swift/proxy/controllers/obj.py:427 swift/proxy/controllers/obj.py:643 +#: swift/proxy/controllers/obj.py:1130 swift/proxy/controllers/obj.py:1591 +#: swift/proxy/controllers/obj.py:1763 swift/proxy/controllers/obj.py:1908 +#: swift/proxy/controllers/obj.py:2093 msgid "Object" msgstr "" -#: swift/proxy/controllers/base.py:699 +#: swift/proxy/controllers/base.py:753 msgid "Trying to read during GET (retrying)" msgstr "" -#: swift/proxy/controllers/base.py:732 +#: swift/proxy/controllers/base.py:815 msgid "Trying to read during GET" msgstr "" -#: swift/proxy/controllers/base.py:736 +#: swift/proxy/controllers/base.py:819 #, python-format msgid "Client did not read from proxy within %ss" msgstr "" -#: swift/proxy/controllers/base.py:741 +#: swift/proxy/controllers/base.py:824 msgid "Client disconnected on read" msgstr "" -#: swift/proxy/controllers/base.py:743 +#: swift/proxy/controllers/base.py:826 msgid "Trying to send to client" msgstr "" -#: swift/proxy/controllers/base.py:780 swift/proxy/controllers/base.py:1050 +#: swift/proxy/controllers/base.py:863 swift/proxy/controllers/base.py:1141 #, python-format msgid "Trying to %(method)s %(path)s" msgstr "" -#: swift/proxy/controllers/base.py:817 swift/proxy/controllers/base.py:1038 -#: swift/proxy/controllers/obj.py:350 swift/proxy/controllers/obj.py:390 +#: swift/proxy/controllers/base.py:902 swift/proxy/controllers/base.py:1129 +#: swift/proxy/controllers/obj.py:402 swift/proxy/controllers/obj.py:450 +#: swift/proxy/controllers/obj.py:1900 swift/proxy/controllers/obj.py:2138 msgid "ERROR Insufficient Storage" msgstr "" -#: swift/proxy/controllers/base.py:820 +#: swift/proxy/controllers/base.py:905 #, python-format msgid "ERROR %(status)d %(body)s From %(type)s Server" msgstr "" -#: swift/proxy/controllers/base.py:1041 +#: swift/proxy/controllers/base.py:1132 #, python-format msgid "ERROR %(status)d Trying to %(method)s %(path)sFrom Container Server" msgstr "" -#: swift/proxy/controllers/base.py:1153 +#: swift/proxy/controllers/base.py:1260 #, python-format msgid "%(type)s returning 503 for %(statuses)s" msgstr "" -#: swift/proxy/controllers/container.py:97 swift/proxy/controllers/obj.py:117 +#: swift/proxy/controllers/container.py:98 swift/proxy/controllers/obj.py:161 msgid "Container" msgstr "" -#: swift/proxy/controllers/obj.py:319 +#: swift/proxy/controllers/obj.py:365 swift/proxy/controllers/obj.py:1592 #, python-format msgid "Trying to write to %s" msgstr "" -#: swift/proxy/controllers/obj.py:353 +#: swift/proxy/controllers/obj.py:406 swift/proxy/controllers/obj.py:1903 #, python-format msgid "ERROR %(status)d Expect: 100-continue From Object Server" msgstr "" -#: swift/proxy/controllers/obj.py:359 +#: swift/proxy/controllers/obj.py:412 swift/proxy/controllers/obj.py:1909 #, python-format msgid "Expect: 100-continue on %s" msgstr "" -#: swift/proxy/controllers/obj.py:377 +#: swift/proxy/controllers/obj.py:428 #, python-format msgid "Trying to get final status of PUT to %s" msgstr "" -#: swift/proxy/controllers/obj.py:394 +#: swift/proxy/controllers/obj.py:454 swift/proxy/controllers/obj.py:2143 #, python-format msgid "ERROR %(status)d %(body)s From Object Server re: %(path)s" msgstr "" -#: swift/proxy/controllers/obj.py:665 +#: swift/proxy/controllers/obj.py:716 #, python-format msgid "Object PUT returning 412, %(statuses)r" msgstr "" -#: swift/proxy/controllers/obj.py:674 +#: swift/proxy/controllers/obj.py:725 #, python-format msgid "Object PUT returning 202 for 409: %(req_timestamp)s <= %(timestamps)r" msgstr "" -#: swift/proxy/controllers/obj.py:682 -#, python-format -msgid "Object PUT returning 503, %(conns)s/%(nodes)s required connections" -msgstr "" - -#: swift/proxy/controllers/obj.py:713 -#, python-format -msgid "" -"Object PUT exceptions during send, %(conns)s/%(nodes)s required " -"connections" -msgstr "" - -#: swift/proxy/controllers/obj.py:724 +#: swift/proxy/controllers/obj.py:811 swift/proxy/controllers/obj.py:2048 #, python-format msgid "ERROR Client read timeout (%ss)" msgstr "" -#: swift/proxy/controllers/obj.py:729 +#: swift/proxy/controllers/obj.py:818 swift/proxy/controllers/obj.py:2055 msgid "ERROR Exception causing client disconnect" msgstr "" -#: swift/proxy/controllers/obj.py:734 +#: swift/proxy/controllers/obj.py:823 swift/proxy/controllers/obj.py:2060 msgid "Client disconnected without sending enough data" msgstr "" -#: swift/proxy/controllers/obj.py:743 +#: swift/proxy/controllers/obj.py:869 #, python-format msgid "Object servers returned %s mismatched etags" msgstr "" -#: swift/proxy/controllers/obj.py:747 +#: swift/proxy/controllers/obj.py:873 swift/proxy/controllers/obj.py:2218 msgid "Object PUT" msgstr "" +#: swift/proxy/controllers/obj.py:2035 +#, python-format +msgid "Not enough object servers ack'ed (got %d)" +msgstr "" + +#: swift/proxy/controllers/obj.py:2094 +#, python-format +msgid "Trying to get %s status of PUT to %s" +msgstr "" + diff --git a/swift/locale/zh_CN/LC_MESSAGES/swift.po b/swift/locale/zh_CN/LC_MESSAGES/swift.po index 284127c9c8..36f2767712 100644 --- a/swift/locale/zh_CN/LC_MESSAGES/swift.po +++ b/swift/locale/zh_CN/LC_MESSAGES/swift.po @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: Swift\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2015-02-27 06:14+0000\n" -"PO-Revision-Date: 2015-02-25 18:23+0000\n" +"POT-Creation-Date: 2015-04-16 06:06+0000\n" +"PO-Revision-Date: 2015-04-15 12:48+0000\n" "Last-Translator: openstackjenkins \n" "Language-Team: Chinese (China) " "(http://www.transifex.com/projects/p/swift/language/zh_CN/)\n" @@ -65,98 +65,98 @@ msgstr "审计失败%s: %s" msgid "ERROR Could not get account info %s" msgstr "错误:无法获取账号信息%s" -#: swift/account/reaper.py:133 swift/common/utils.py:2058 -#: swift/obj/diskfile.py:468 swift/obj/updater.py:87 swift/obj/updater.py:130 +#: swift/account/reaper.py:134 swift/common/utils.py:2127 +#: swift/obj/diskfile.py:476 swift/obj/updater.py:88 swift/obj/updater.py:131 #, python-format msgid "Skipping %s as it is not mounted" msgstr "挂载失败 跳过%s" -#: swift/account/reaper.py:137 +#: swift/account/reaper.py:138 msgid "Exception in top-level account reaper loop" msgstr "异常出现在top-level账号reaper环" -#: swift/account/reaper.py:140 +#: swift/account/reaper.py:141 #, python-format msgid "Devices pass completed: %.02fs" msgstr "设备通过完成: %.02fs" -#: swift/account/reaper.py:237 +#: swift/account/reaper.py:238 #, python-format msgid "Beginning pass on account %s" msgstr "账号%s开始通过" -#: swift/account/reaper.py:254 +#: swift/account/reaper.py:255 #, python-format msgid "Exception with containers for account %s" msgstr "账号%s内容器出现异常" -#: swift/account/reaper.py:261 +#: swift/account/reaper.py:262 #, python-format msgid "Exception with account %s" msgstr "账号%s出现异常" -#: swift/account/reaper.py:262 +#: swift/account/reaper.py:263 #, python-format msgid "Incomplete pass on account %s" msgstr "账号%s未完成通过" -#: swift/account/reaper.py:264 +#: swift/account/reaper.py:265 #, python-format msgid ", %s containers deleted" msgstr ",删除容器%s" -#: swift/account/reaper.py:266 +#: swift/account/reaper.py:267 #, python-format msgid ", %s objects deleted" msgstr ",删除对象%s" -#: swift/account/reaper.py:268 +#: swift/account/reaper.py:269 #, python-format msgid ", %s containers remaining" msgstr ",剩余容器%s" -#: swift/account/reaper.py:271 +#: swift/account/reaper.py:272 #, python-format msgid ", %s objects remaining" msgstr ",剩余对象%s" -#: swift/account/reaper.py:273 +#: swift/account/reaper.py:274 #, python-format msgid ", %s containers possibly remaining" msgstr ",可能剩余容器%s" -#: swift/account/reaper.py:276 +#: swift/account/reaper.py:277 #, python-format msgid ", %s objects possibly remaining" msgstr ",可能剩余对象%s" -#: swift/account/reaper.py:279 +#: swift/account/reaper.py:280 msgid ", return codes: " msgstr ",返回代码:" -#: swift/account/reaper.py:283 +#: swift/account/reaper.py:284 #, python-format msgid ", elapsed: %.02fs" msgstr ",耗时:%.02fs" -#: swift/account/reaper.py:289 +#: swift/account/reaper.py:290 #, python-format msgid "Account %s has not been reaped since %s" msgstr "账号%s自%s起未被reaped" -#: swift/account/reaper.py:348 swift/account/reaper.py:396 -#: swift/account/reaper.py:463 swift/container/updater.py:306 +#: swift/account/reaper.py:349 swift/account/reaper.py:397 +#: swift/account/reaper.py:464 swift/container/updater.py:306 #, python-format msgid "Exception with %(ip)s:%(port)s/%(device)s" msgstr "%(ip)s:%(port)s/%(device)s出现异常" -#: swift/account/reaper.py:368 +#: swift/account/reaper.py:369 #, python-format msgid "Exception with objects for container %(container)s for account %(account)s" msgstr "账号%(account)s容器%(container)s的对象出现异常" #: swift/account/server.py:275 swift/container/server.py:582 -#: swift/obj/server.py:730 +#: swift/obj/server.py:910 #, python-format msgid "ERROR __call__ error with %(method)s %(path)s " msgstr "%(method)s %(path)s出现错误__call__ error" @@ -272,19 +272,19 @@ msgstr "尝试复制时发生错误" msgid "Unexpected response: %s" msgstr "意外响应:%s" -#: swift/common/manager.py:62 +#: swift/common/manager.py:63 msgid "WARNING: Unable to modify file descriptor limit. Running as non-root?" msgstr "警告:无法修改文件描述限制。是否按非root运行?" -#: swift/common/manager.py:69 +#: swift/common/manager.py:70 msgid "WARNING: Unable to modify memory limit. Running as non-root?" msgstr "警告:无法修改内存极限,是否按非root运行?" -#: swift/common/manager.py:76 +#: swift/common/manager.py:77 msgid "WARNING: Unable to modify max process limit. Running as non-root?" msgstr "警告:无法修改最大运行极限,是否按非root运行?" -#: swift/common/manager.py:194 +#: swift/common/manager.py:195 msgid "" "\n" "user quit" @@ -292,72 +292,77 @@ msgstr "" "\n" "用户退出" -#: swift/common/manager.py:231 swift/common/manager.py:543 +#: swift/common/manager.py:232 swift/common/manager.py:547 #, python-format msgid "No %s running" msgstr "无%s账号运行" -#: swift/common/manager.py:244 +#: swift/common/manager.py:245 #, python-format msgid "%s (%s) appears to have stopped" msgstr "%s (%s)显示已停止" -#: swift/common/manager.py:254 +#: swift/common/manager.py:255 #, python-format msgid "Waited %s seconds for %s to die; giving up" msgstr "等待%s秒直到%s停止;放弃" -#: swift/common/manager.py:437 +#: swift/common/manager.py:439 #, python-format -msgid "Unable to locate config %sfor %s" -msgstr "无法找到配置%s的%s" +msgid "Unable to locate config number %s for %s" +msgstr "" -#: swift/common/manager.py:441 +#: swift/common/manager.py:442 +#, python-format +msgid "Unable to locate config for %s" +msgstr "" + +#: swift/common/manager.py:445 msgid "Found configs:" msgstr "找到配置" -#: swift/common/manager.py:485 +#: swift/common/manager.py:489 #, python-format msgid "Signal %s pid: %s signal: %s" msgstr "发出信号%s pid: %s 信号: %s" -#: swift/common/manager.py:492 +#: swift/common/manager.py:496 #, python-format msgid "Removing stale pid file %s" msgstr "移除原有pid文件%s" -#: swift/common/manager.py:495 +#: swift/common/manager.py:499 #, python-format msgid "No permission to signal PID %d" msgstr "无权限发送信号PID%d" -#: swift/common/manager.py:540 +#: swift/common/manager.py:544 #, python-format msgid "%s #%d not running (%s)" msgstr "%s #%d无法运行(%s)" -#: swift/common/manager.py:547 swift/common/manager.py:640 -#: swift/common/manager.py:643 +#: swift/common/manager.py:551 swift/common/manager.py:644 +#: swift/common/manager.py:647 #, python-format msgid "%s running (%s - %s)" msgstr "%s运行(%s - %s)" -#: swift/common/manager.py:646 +#: swift/common/manager.py:650 #, python-format msgid "%s already started..." msgstr "%s已启动..." -#: swift/common/manager.py:655 +#: swift/common/manager.py:659 #, python-format msgid "Running %s once" msgstr "运行%s一次" -#: swift/common/manager.py:657 +#: swift/common/manager.py:661 #, python-format msgid "Starting %s" msgstr "启动%s" -#: swift/common/manager.py:664 +#: swift/common/manager.py:668 #, python-format msgid "%s does not exist" msgstr "%s不存在" @@ -377,7 +382,12 @@ msgstr "%(action)s错误 高性能内存对象缓存: %(server)s" msgid "Error limiting server %s" msgstr "服务器出现错误%s " -#: swift/common/request_helpers.py:387 +#: swift/common/request_helpers.py:102 +#, python-format +msgid "No policy with index %s" +msgstr "" + +#: swift/common/request_helpers.py:395 msgid "ERROR: An error occurred while retrieving segments" msgstr "" @@ -390,95 +400,101 @@ msgstr "无法查询到%s 保留为no-op" msgid "Unable to locate fallocate, posix_fallocate in libc. Leaving as a no-op." msgstr "无法查询到fallocate, posix_fallocate。保存为no-op" -#: swift/common/utils.py:1005 -msgid "STDOUT: Connection reset by peer" -msgstr "STDOUT:连接被peer重新设置" - -#: swift/common/utils.py:1007 swift/common/utils.py:1010 +#: swift/common/utils.py:662 #, python-format -msgid "STDOUT: %s" -msgstr "STDOUT: %s" +msgid "Unable to perform fsync() on directory %s: %s" +msgstr "" -#: swift/common/utils.py:1245 +#: swift/common/utils.py:1074 +#, python-format +msgid "%s: Connection reset by peer" +msgstr "" + +#: swift/common/utils.py:1076 swift/common/utils.py:1079 +#, python-format +msgid "%s: %s" +msgstr "" + +#: swift/common/utils.py:1314 msgid "Connection refused" msgstr "连接被拒绝" -#: swift/common/utils.py:1247 +#: swift/common/utils.py:1316 msgid "Host unreachable" msgstr "无法连接到主机" -#: swift/common/utils.py:1249 +#: swift/common/utils.py:1318 msgid "Connection timeout" msgstr "连接超时" -#: swift/common/utils.py:1551 +#: swift/common/utils.py:1620 msgid "UNCAUGHT EXCEPTION" msgstr "未捕获的异常" -#: swift/common/utils.py:1606 +#: swift/common/utils.py:1675 msgid "Error: missing config path argument" msgstr "错误:设置路径信息丢失" -#: swift/common/utils.py:1611 +#: swift/common/utils.py:1680 #, python-format msgid "Error: unable to locate %s" msgstr "错误:无法查询到 %s" -#: swift/common/utils.py:1919 +#: swift/common/utils.py:1988 #, python-format msgid "Unable to read config from %s" msgstr "无法从%s读取设置" -#: swift/common/utils.py:1925 +#: swift/common/utils.py:1994 #, python-format msgid "Unable to find %s config section in %s" msgstr "无法在%s中查找到%s设置部分" -#: swift/common/utils.py:2279 +#: swift/common/utils.py:2353 #, python-format msgid "Invalid X-Container-Sync-To format %r" msgstr "无效的X-Container-Sync-To格式%r" -#: swift/common/utils.py:2284 +#: swift/common/utils.py:2358 #, python-format msgid "No realm key for %r" msgstr "%r权限key不存在" -#: swift/common/utils.py:2288 +#: swift/common/utils.py:2362 #, python-format msgid "No cluster endpoint for %r %r" msgstr "%r %r的集群节点不存在" -#: swift/common/utils.py:2297 +#: swift/common/utils.py:2371 #, python-format msgid "" "Invalid scheme %r in X-Container-Sync-To, must be \"//\", \"http\", or " "\"https\"." msgstr "在X-Container-Sync-To中%r是无效的方案,须为\"//\", \"http\", or \"https\"。" -#: swift/common/utils.py:2301 +#: swift/common/utils.py:2375 msgid "Path required in X-Container-Sync-To" msgstr "在X-Container-Sync-To中路径是必须的" -#: swift/common/utils.py:2304 +#: swift/common/utils.py:2378 msgid "Params, queries, and fragments not allowed in X-Container-Sync-To" msgstr "在X-Container-Sync-To中,变量,查询和碎片不被允许" -#: swift/common/utils.py:2309 +#: swift/common/utils.py:2383 #, python-format msgid "Invalid host %r in X-Container-Sync-To" msgstr "X-Container-Sync-To中无效主机%r" -#: swift/common/utils.py:2501 +#: swift/common/utils.py:2575 msgid "Exception dumping recon cache" msgstr "执行dump recon的时候出现异常" -#: swift/common/wsgi.py:175 +#: swift/common/wsgi.py:197 #, python-format msgid "Could not bind to %s:%s after trying for %s seconds" msgstr "尝试过%s秒后无法捆绑%s:%s" -#: swift/common/wsgi.py:185 +#: swift/common/wsgi.py:207 msgid "" "WARNING: SSL should only be enabled for testing purposes. Use external " "SSL termination for a production deployment." @@ -521,27 +537,27 @@ msgstr "" msgid "Warning: Cannot ratelimit without a memcached client" msgstr "警告:缺失缓存客户端 无法控制流量 " -#: swift/common/middleware/recon.py:78 +#: swift/common/middleware/recon.py:80 msgid "Error reading recon cache file" msgstr "读取recon cache file时出现错误" -#: swift/common/middleware/recon.py:80 +#: swift/common/middleware/recon.py:82 msgid "Error parsing recon cache file" msgstr "解析recon cache file时出现错误" -#: swift/common/middleware/recon.py:82 +#: swift/common/middleware/recon.py:84 msgid "Error retrieving recon data" msgstr "检索recon data时出现错误" -#: swift/common/middleware/recon.py:151 +#: swift/common/middleware/recon.py:158 msgid "Error listing devices" msgstr "设备列表时出现错误" -#: swift/common/middleware/recon.py:247 +#: swift/common/middleware/recon.py:254 msgid "Error reading ringfile" msgstr "读取ringfile时出现错误" -#: swift/common/middleware/recon.py:261 +#: swift/common/middleware/recon.py:268 msgid "Error reading swift.conf" msgstr "读取swift.conf时出现错误" @@ -648,16 +664,27 @@ msgid "" "later)" msgstr "错误 账号更新失败 %(ip)s:%(port)s/%(device)s (稍后尝试)" -#: swift/container/sync.py:193 +#: swift/container/sync.py:217 +msgid "" +"Configuration option internal_client_conf_path not defined. Using default" +" configuration, See internal-client.conf-sample for options" +msgstr "" + +#: swift/container/sync.py:230 +#, python-format +msgid "Unable to load internal client from config: %r (%s)" +msgstr "" + +#: swift/container/sync.py:264 msgid "Begin container sync \"once\" mode" msgstr "开始容器同步\"once\"模式" -#: swift/container/sync.py:205 +#: swift/container/sync.py:276 #, python-format msgid "Container sync \"once\" mode completed: %.02fs" msgstr "容器同步\"once\"模式完成:%.02fs" -#: swift/container/sync.py:213 +#: swift/container/sync.py:284 #, python-format msgid "" "Since %(time)s: %(sync)s synced [%(delete)s deletes, %(put)s puts], " @@ -666,36 +693,34 @@ msgstr "" "自%(time)s起:%(sync)s完成同步 [%(delete)s 删除, %(put)s 上传], \"\n" "\"%(skip)s 跳过, %(fail)s 失败" -#: swift/container/sync.py:266 +#: swift/container/sync.py:337 #, python-format msgid "ERROR %(db_file)s: %(validate_sync_to_err)s" msgstr "错误 %(db_file)s: %(validate_sync_to_err)s" -#: swift/container/sync.py:322 +#: swift/container/sync.py:393 #, python-format msgid "ERROR Syncing %s" msgstr "同步时发生错误%s" -#: swift/container/sync.py:410 +#: swift/container/sync.py:476 #, python-format -msgid "" -"Unknown exception trying to GET: %(node)r %(account)r %(container)r " -"%(object)r" -msgstr "尝试获取时发生未知的异常%(node)r %(account)r %(container)r %(object)r" +msgid "Unknown exception trying to GET: %(account)r %(container)r %(object)r" +msgstr "" -#: swift/container/sync.py:444 +#: swift/container/sync.py:510 #, python-format msgid "Unauth %(sync_from)r => %(sync_to)r" msgstr "未授权%(sync_from)r => %(sync_to)r" -#: swift/container/sync.py:450 +#: swift/container/sync.py:516 #, python-format msgid "" "Not found %(sync_from)r => %(sync_to)r - object " "%(obj_name)r" msgstr "未找到: %(sync_from)r => %(sync_to)r - object %(obj_name)r" -#: swift/container/sync.py:457 swift/container/sync.py:464 +#: swift/container/sync.py:523 swift/container/sync.py:530 #, python-format msgid "ERROR Syncing %(db_file)s %(row)s" msgstr "同步错误 %(db_file)s %(row)s" @@ -705,8 +730,8 @@ msgstr "同步错误 %(db_file)s %(row)s" msgid "ERROR: Failed to get paths to drive partitions: %s" msgstr "%s未挂载" -#: swift/container/updater.py:91 swift/obj/replicator.py:483 -#: swift/obj/replicator.py:569 +#: swift/container/updater.py:91 swift/obj/reconstructor.py:788 +#: swift/obj/replicator.py:487 swift/obj/replicator.py:575 #, python-format msgid "%s is not mounted" msgstr "%s未挂载" @@ -828,42 +853,57 @@ msgstr "错误:无法执行审计:%s" msgid "ERROR auditing: %s" msgstr "审计错误:%s" -#: swift/obj/diskfile.py:318 +#: swift/obj/diskfile.py:323 swift/obj/diskfile.py:2305 #, python-format msgid "Quarantined %(hsh_path)s to %(quar_path)s because it is not a directory" msgstr "隔离%(hsh_path)s和%(quar_path)s因为非目录" -#: swift/obj/diskfile.py:407 +#: swift/obj/diskfile.py:414 swift/obj/diskfile.py:2373 msgid "Error hashing suffix" msgstr "执行Hashing后缀时发生错误" -#: swift/obj/diskfile.py:482 swift/obj/updater.py:169 +#: swift/obj/diskfile.py:486 swift/obj/updater.py:162 #, python-format -msgid "Directory %s does not map to a valid policy" -msgstr "目录%s无法映射到一个有效的policy" +msgid "Directory %r does not map to a valid policy (%s)" +msgstr "" -#: swift/obj/diskfile.py:676 +#: swift/obj/diskfile.py:737 #, python-format msgid "Quarantined %(object_path)s to %(quar_path)s because it is not a directory" msgstr "隔离%(object_path)s和%(quar_path)s因为非目录" -#: swift/obj/diskfile.py:867 +#: swift/obj/diskfile.py:936 swift/obj/diskfile.py:1795 #, python-format msgid "Problem cleaning up %s" msgstr "问题清除%s" -#: swift/obj/diskfile.py:1166 +#: swift/obj/diskfile.py:1253 #, python-format msgid "ERROR DiskFile %(data_file)s close failure: %(exc)s : %(stack)s" msgstr "磁盘文件错误%(data_file)s关闭失败: %(exc)s : %(stack)s" -#: swift/obj/diskfile.py:1447 +#: swift/obj/diskfile.py:1543 #, python-format msgid "" "Client path %(client)s does not match path stored in object metadata " "%(meta)s" msgstr "客户路径%(client)s与对象元数据中存储的路径%(meta)s不符" +#: swift/obj/diskfile.py:1797 +#, python-format +msgid "Problem fsyncing durable state file: %s" +msgstr "" + +#: swift/obj/diskfile.py:1802 +#, python-format +msgid "No space left on device for %s" +msgstr "" + +#: swift/obj/diskfile.py:1806 +#, python-format +msgid "Problem writing durable state file: %s" +msgstr "" + #: swift/obj/expirer.py:79 #, python-format msgid "Pass completed in %ds; %d objects expired" @@ -893,67 +933,138 @@ msgstr "未处理的异常" msgid "Exception while deleting object %s %s %s" msgstr "执行删除对象时发生异常%s %s %s" -#: swift/obj/mem_server.py:87 +#: swift/obj/reconstructor.py:189 swift/obj/reconstructor.py:472 #, python-format -msgid "" -"ERROR Container update failed: %(status)d response from " -"%(ip)s:%(port)s/%(dev)s" -msgstr "错误 容器更新失败:%(status)d 从%(ip)s:%(port)s/%(dev)s得到回应" - -#: swift/obj/mem_server.py:93 -#, python-format -msgid "ERROR container update failed with %(ip)s:%(port)s/%(dev)s" -msgstr "错误 容器更新失败%(ip)s:%(port)s/%(dev)s" - -#: swift/obj/replicator.py:138 -#, python-format -msgid "Killing long-running rsync: %s" -msgstr "终止long-running同步: %s" - -#: swift/obj/replicator.py:152 -#, python-format -msgid "Bad rsync return code: %(ret)d <- %(args)s" -msgstr "Bad rsync返还代码:%(ret)d <- %(args)s" - -#: swift/obj/replicator.py:159 swift/obj/replicator.py:163 -#, python-format -msgid "Successful rsync of %(src)s at %(dst)s (%(time).03f)" -msgstr "成功的rsync %(src)s at %(dst)s (%(time).03f)" - -#: swift/obj/replicator.py:277 -#, python-format -msgid "Removing %s objects" +msgid "Invalid response %(resp)s from %(full_path)s" msgstr "" -#: swift/obj/replicator.py:285 -msgid "Error syncing handoff partition" -msgstr "执行同步切换分区时发生错误" +#: swift/obj/reconstructor.py:195 +#, python-format +msgid "Trying to GET %(full_path)s" +msgstr "" -#: swift/obj/replicator.py:291 +#: swift/obj/reconstructor.py:301 +#, python-format +msgid "Error trying to rebuild %(path)s policy#%(policy)d frag#%(frag_index)s" +msgstr "" + +#: swift/obj/reconstructor.py:324 +#, python-format +msgid "" +"%(reconstructed)d/%(total)d (%(percentage).2f%%) partitions reconstructed" +" in %(time).2fs (%(rate).2f/sec, %(remaining)s remaining)" +msgstr "" + +#: swift/obj/reconstructor.py:337 swift/obj/replicator.py:419 +#, python-format +msgid "" +"%(checked)d suffixes checked - %(hashed).2f%% hashed, %(synced).2f%% " +"synced" +msgstr "%(checked)d后缀已被检查 %(hashed).2f%% hashed, %(synced).2f%% synced" + +#: swift/obj/reconstructor.py:344 swift/obj/replicator.py:426 +#, python-format +msgid "Partition times: max %(max).4fs, min %(min).4fs, med %(med).4fs" +msgstr "分区时间: max %(max).4fs, min %(min).4fs, med %(med).4fs" + +#: swift/obj/reconstructor.py:352 +#, python-format +msgid "Nothing reconstructed for %s seconds." +msgstr "" + +#: swift/obj/reconstructor.py:381 swift/obj/replicator.py:463 +msgid "Lockup detected.. killing live coros." +msgstr "检测到lockup。终止正在执行的coros" + +#: swift/obj/reconstructor.py:442 +#, python-format +msgid "Trying to sync suffixes with %s" +msgstr "" + +#: swift/obj/reconstructor.py:467 +#, python-format +msgid "%s responded as unmounted" +msgstr "" + +#: swift/obj/reconstructor.py:849 swift/obj/replicator.py:295 #, python-format msgid "Removing partition: %s" msgstr "移除分区:%s" -#: swift/obj/replicator.py:346 +#: swift/obj/reconstructor.py:865 +msgid "Ring change detected. Aborting current reconstruction pass." +msgstr "" + +#: swift/obj/reconstructor.py:884 +msgid "Exception in top-levelreconstruction loop" +msgstr "" + +#: swift/obj/reconstructor.py:894 +msgid "Running object reconstructor in script mode." +msgstr "" + +#: swift/obj/reconstructor.py:903 +#, python-format +msgid "Object reconstruction complete (once). (%.02f minutes)" +msgstr "" + +#: swift/obj/reconstructor.py:910 +msgid "Starting object reconstructor in daemon mode." +msgstr "" + +#: swift/obj/reconstructor.py:914 +msgid "Starting object reconstruction pass." +msgstr "" + +#: swift/obj/reconstructor.py:919 +#, python-format +msgid "Object reconstruction complete. (%.02f minutes)" +msgstr "" + +#: swift/obj/replicator.py:139 +#, python-format +msgid "Killing long-running rsync: %s" +msgstr "终止long-running同步: %s" + +#: swift/obj/replicator.py:153 +#, python-format +msgid "Bad rsync return code: %(ret)d <- %(args)s" +msgstr "Bad rsync返还代码:%(ret)d <- %(args)s" + +#: swift/obj/replicator.py:160 swift/obj/replicator.py:164 +#, python-format +msgid "Successful rsync of %(src)s at %(dst)s (%(time).03f)" +msgstr "成功的rsync %(src)s at %(dst)s (%(time).03f)" + +#: swift/obj/replicator.py:281 +#, python-format +msgid "Removing %s objects" +msgstr "" + +#: swift/obj/replicator.py:289 +msgid "Error syncing handoff partition" +msgstr "执行同步切换分区时发生错误" + +#: swift/obj/replicator.py:351 #, python-format msgid "%(ip)s/%(device)s responded as unmounted" msgstr "%(ip)s/%(device)s的回应为未挂载" -#: swift/obj/replicator.py:351 +#: swift/obj/replicator.py:356 #, python-format msgid "Invalid response %(resp)s from %(ip)s" msgstr "无效的回应%(resp)s来自%(ip)s" -#: swift/obj/replicator.py:386 +#: swift/obj/replicator.py:391 #, python-format msgid "Error syncing with node: %s" msgstr "执行同步时节点%s发生错误" -#: swift/obj/replicator.py:390 +#: swift/obj/replicator.py:395 msgid "Error syncing partition" msgstr "执行同步分区时发生错误" -#: swift/obj/replicator.py:403 +#: swift/obj/replicator.py:408 #, python-format msgid "" "%(replicated)d/%(total)d (%(percentage).2f%%) partitions replicated in " @@ -962,271 +1073,256 @@ msgstr "" "%(replicated)d/%(total)d (%(percentage).2f%%) 分区被复制 持续时间为 \"\n" "\"%(time).2fs (%(rate).2f/sec, %(remaining)s remaining)" -#: swift/obj/replicator.py:414 -#, python-format -msgid "" -"%(checked)d suffixes checked - %(hashed).2f%% hashed, %(synced).2f%% " -"synced" -msgstr "%(checked)d后缀已被检查 %(hashed).2f%% hashed, %(synced).2f%% synced" - -#: swift/obj/replicator.py:421 -#, python-format -msgid "Partition times: max %(max).4fs, min %(min).4fs, med %(med).4fs" -msgstr "分区时间: max %(max).4fs, min %(min).4fs, med %(med).4fs" - -#: swift/obj/replicator.py:429 +#: swift/obj/replicator.py:434 #, python-format msgid "Nothing replicated for %s seconds." msgstr "%s秒无复制" -#: swift/obj/replicator.py:458 -msgid "Lockup detected.. killing live coros." -msgstr "检测到lockup。终止正在执行的coros" - -#: swift/obj/replicator.py:572 +#: swift/obj/replicator.py:578 msgid "Ring change detected. Aborting current replication pass." msgstr "Ring改变被检测到。退出现有的复制通过" -#: swift/obj/replicator.py:593 +#: swift/obj/replicator.py:599 msgid "Exception in top-level replication loop" msgstr "top-level复制圈出现异常" -#: swift/obj/replicator.py:602 +#: swift/obj/replicator.py:608 msgid "Running object replicator in script mode." msgstr "在加密模式下执行对象复制" -#: swift/obj/replicator.py:620 +#: swift/obj/replicator.py:626 #, python-format msgid "Object replication complete (once). (%.02f minutes)" msgstr "对象复制完成(一次)。(%.02f minutes)" -#: swift/obj/replicator.py:627 +#: swift/obj/replicator.py:633 msgid "Starting object replicator in daemon mode." msgstr "在守护模式下开始对象复制" -#: swift/obj/replicator.py:631 +#: swift/obj/replicator.py:637 msgid "Starting object replication pass." msgstr "开始通过对象复制" -#: swift/obj/replicator.py:636 +#: swift/obj/replicator.py:642 #, python-format msgid "Object replication complete. (%.02f minutes)" msgstr "对象复制完成。(%.02f minutes)" -#: swift/obj/server.py:202 +#: swift/obj/server.py:231 #, python-format msgid "" "ERROR Container update failed (saving for async update later): %(status)d" " response from %(ip)s:%(port)s/%(dev)s" msgstr "错误 容器更新失败(正在保存 稍后同步更新):%(status)d回应来自%(ip)s:%(port)s/%(dev)s" -#: swift/obj/server.py:209 +#: swift/obj/server.py:238 #, python-format msgid "" "ERROR container update failed with %(ip)s:%(port)s/%(dev)s (saving for " "async update later)" msgstr "错误 容器更新失败%(ip)s:%(port)s/%(dev)s(正在保存 稍后同步更新)" -#: swift/obj/server.py:244 +#: swift/obj/server.py:273 #, python-format msgid "" "ERROR Container update failed: different numbers of hosts and devices in " "request: \"%s\" vs \"%s\"" msgstr "错误 容器更新失败:主机数量和设备数量不符合请求: \"%s\" vs \"%s\"" -#: swift/obj/updater.py:62 +#: swift/obj/updater.py:63 #, python-format msgid "ERROR: Unable to access %(path)s: %(error)s" msgstr "" -#: swift/obj/updater.py:77 +#: swift/obj/updater.py:78 msgid "Begin object update sweep" msgstr "开始对象更新扫除" -#: swift/obj/updater.py:103 +#: swift/obj/updater.py:104 #, python-format msgid "" "Object update sweep of %(device)s completed: %(elapsed).02fs, %(success)s" " successes, %(fail)s failures" msgstr "%(device)s对象更新扫除完成:%(elapsed).02fs, %(success)s成功, %(fail)s失败" -#: swift/obj/updater.py:112 +#: swift/obj/updater.py:113 #, python-format msgid "Object update sweep completed: %.02fs" msgstr "对象更新扫除完成:%.02fs" -#: swift/obj/updater.py:121 +#: swift/obj/updater.py:122 msgid "Begin object update single threaded sweep" msgstr "开始对象更新单线程扫除" -#: swift/obj/updater.py:135 +#: swift/obj/updater.py:136 #, python-format msgid "" "Object update single threaded sweep completed: %(elapsed).02fs, " "%(success)s successes, %(fail)s failures" msgstr "对象更新单线程扫除完成:%(elapsed).02fs,%(success)s 成功, %(fail)s 失败" -#: swift/obj/updater.py:187 +#: swift/obj/updater.py:179 #, python-format msgid "ERROR async pending file with unexpected name %s" msgstr "执行同步等待文件 文件名不可知%s" -#: swift/obj/updater.py:217 +#: swift/obj/updater.py:209 #, python-format msgid "ERROR Pickle problem, quarantining %s" msgstr "错误 Pickle问题 隔离%s" -#: swift/obj/updater.py:282 +#: swift/obj/updater.py:274 #, python-format msgid "ERROR with remote server %(ip)s:%(port)s/%(device)s" msgstr "远程服务器发生错误 %(ip)s:%(port)s/%(device)s" -#: swift/proxy/server.py:380 +#: swift/proxy/server.py:405 msgid "ERROR Unhandled exception in request" msgstr "错误 未处理的异常发出请求" -#: swift/proxy/server.py:435 +#: swift/proxy/server.py:460 #, python-format msgid "Node error limited %(ip)s:%(port)s (%(device)s)" msgstr "节点错误极限 %(ip)s:%(port)s (%(device)s)" -#: swift/proxy/server.py:452 swift/proxy/server.py:470 +#: swift/proxy/server.py:477 swift/proxy/server.py:495 #, python-format msgid "%(msg)s %(ip)s:%(port)s/%(device)s" msgstr "%(msg)s %(ip)s:%(port)s/%(device)s" -#: swift/proxy/server.py:540 +#: swift/proxy/server.py:571 #, python-format msgid "ERROR with %(type)s server %(ip)s:%(port)s/%(device)s re: %(info)s" msgstr "%(type)s服务器发生错误 %(ip)s:%(port)s/%(device)s re: %(info)s" -#: swift/proxy/controllers/account.py:63 +#: swift/proxy/controllers/account.py:64 msgid "Account" msgstr "账号" -#: swift/proxy/controllers/base.py:698 swift/proxy/controllers/base.py:731 -#: swift/proxy/controllers/obj.py:191 swift/proxy/controllers/obj.py:318 -#: swift/proxy/controllers/obj.py:358 swift/proxy/controllers/obj.py:376 -#: swift/proxy/controllers/obj.py:502 +#: swift/proxy/controllers/base.py:752 swift/proxy/controllers/base.py:814 +#: swift/proxy/controllers/obj.py:364 swift/proxy/controllers/obj.py:411 +#: swift/proxy/controllers/obj.py:427 swift/proxy/controllers/obj.py:643 +#: swift/proxy/controllers/obj.py:1130 swift/proxy/controllers/obj.py:1591 +#: swift/proxy/controllers/obj.py:1763 swift/proxy/controllers/obj.py:1908 +#: swift/proxy/controllers/obj.py:2093 msgid "Object" msgstr "对象" -#: swift/proxy/controllers/base.py:699 +#: swift/proxy/controllers/base.py:753 msgid "Trying to read during GET (retrying)" msgstr "执行GET时尝试读取(重新尝试)" -#: swift/proxy/controllers/base.py:732 +#: swift/proxy/controllers/base.py:815 msgid "Trying to read during GET" msgstr "执行GET时尝试读取" -#: swift/proxy/controllers/base.py:736 +#: swift/proxy/controllers/base.py:819 #, python-format msgid "Client did not read from proxy within %ss" msgstr "客户尚未从代理处读取%ss" -#: swift/proxy/controllers/base.py:741 +#: swift/proxy/controllers/base.py:824 msgid "Client disconnected on read" msgstr "客户读取时中断" -#: swift/proxy/controllers/base.py:743 +#: swift/proxy/controllers/base.py:826 msgid "Trying to send to client" msgstr "尝试发送到客户端" -#: swift/proxy/controllers/base.py:780 swift/proxy/controllers/base.py:1050 +#: swift/proxy/controllers/base.py:863 swift/proxy/controllers/base.py:1141 #, python-format msgid "Trying to %(method)s %(path)s" msgstr "尝试执行%(method)s %(path)s" -#: swift/proxy/controllers/base.py:817 swift/proxy/controllers/base.py:1038 -#: swift/proxy/controllers/obj.py:350 swift/proxy/controllers/obj.py:390 +#: swift/proxy/controllers/base.py:902 swift/proxy/controllers/base.py:1129 +#: swift/proxy/controllers/obj.py:402 swift/proxy/controllers/obj.py:450 +#: swift/proxy/controllers/obj.py:1900 swift/proxy/controllers/obj.py:2138 msgid "ERROR Insufficient Storage" msgstr "错误 存储空间不足" -#: swift/proxy/controllers/base.py:820 +#: swift/proxy/controllers/base.py:905 #, python-format msgid "ERROR %(status)d %(body)s From %(type)s Server" msgstr "错误 %(status)d %(body)s 来自 %(type)s 服务器" -#: swift/proxy/controllers/base.py:1041 +#: swift/proxy/controllers/base.py:1132 #, python-format msgid "ERROR %(status)d Trying to %(method)s %(path)sFrom Container Server" msgstr "" -#: swift/proxy/controllers/base.py:1153 +#: swift/proxy/controllers/base.py:1260 #, python-format msgid "%(type)s returning 503 for %(statuses)s" msgstr "%(type)s 返回 503 在 %(statuses)s" -#: swift/proxy/controllers/container.py:97 swift/proxy/controllers/obj.py:117 +#: swift/proxy/controllers/container.py:98 swift/proxy/controllers/obj.py:161 msgid "Container" msgstr "容器" -#: swift/proxy/controllers/obj.py:319 +#: swift/proxy/controllers/obj.py:365 swift/proxy/controllers/obj.py:1592 #, python-format msgid "Trying to write to %s" msgstr "尝试执行书写%s" -#: swift/proxy/controllers/obj.py:353 +#: swift/proxy/controllers/obj.py:406 swift/proxy/controllers/obj.py:1903 #, python-format msgid "ERROR %(status)d Expect: 100-continue From Object Server" msgstr "" -#: swift/proxy/controllers/obj.py:359 +#: swift/proxy/controllers/obj.py:412 swift/proxy/controllers/obj.py:1909 #, python-format msgid "Expect: 100-continue on %s" msgstr "已知:100-continue on %s" -#: swift/proxy/controllers/obj.py:377 +#: swift/proxy/controllers/obj.py:428 #, python-format msgid "Trying to get final status of PUT to %s" msgstr "尝试执行获取最后的PUT状态%s" -#: swift/proxy/controllers/obj.py:394 +#: swift/proxy/controllers/obj.py:454 swift/proxy/controllers/obj.py:2143 #, python-format msgid "ERROR %(status)d %(body)s From Object Server re: %(path)s" msgstr "错误 %(status)d %(body)s 来自 对象服务器 re: %(path)s" -#: swift/proxy/controllers/obj.py:665 +#: swift/proxy/controllers/obj.py:716 #, python-format msgid "Object PUT returning 412, %(statuses)r" msgstr "对象PUT返还 412,%(statuses)r " -#: swift/proxy/controllers/obj.py:674 +#: swift/proxy/controllers/obj.py:725 #, python-format msgid "Object PUT returning 202 for 409: %(req_timestamp)s <= %(timestamps)r" msgstr "" -#: swift/proxy/controllers/obj.py:682 -#, python-format -msgid "Object PUT returning 503, %(conns)s/%(nodes)s required connections" -msgstr "对象PUT返回503,%(conns)s/%(nodes)s 请求连接" - -#: swift/proxy/controllers/obj.py:713 -#, python-format -msgid "" -"Object PUT exceptions during send, %(conns)s/%(nodes)s required " -"connections" -msgstr "对象PUT发送时出现异常,%(conns)s/%(nodes)s请求连接" - -#: swift/proxy/controllers/obj.py:724 +#: swift/proxy/controllers/obj.py:811 swift/proxy/controllers/obj.py:2048 #, python-format msgid "ERROR Client read timeout (%ss)" msgstr "错误 客户读取超时(%ss)" -#: swift/proxy/controllers/obj.py:729 +#: swift/proxy/controllers/obj.py:818 swift/proxy/controllers/obj.py:2055 msgid "ERROR Exception causing client disconnect" msgstr "错误 异常导致客户端中断连接" -#: swift/proxy/controllers/obj.py:734 +#: swift/proxy/controllers/obj.py:823 swift/proxy/controllers/obj.py:2060 msgid "Client disconnected without sending enough data" msgstr "客户中断 尚未发送足够" -#: swift/proxy/controllers/obj.py:743 +#: swift/proxy/controllers/obj.py:869 #, python-format msgid "Object servers returned %s mismatched etags" msgstr "对象服务器返还%s不匹配etags" -#: swift/proxy/controllers/obj.py:747 +#: swift/proxy/controllers/obj.py:873 swift/proxy/controllers/obj.py:2218 msgid "Object PUT" msgstr "对象上传" +#: swift/proxy/controllers/obj.py:2035 +#, python-format +msgid "Not enough object servers ack'ed (got %d)" +msgstr "" + +#: swift/proxy/controllers/obj.py:2094 +#, python-format +msgid "Trying to get %s status of PUT to %s" +msgstr "" + diff --git a/swift/obj/diskfile.py b/swift/obj/diskfile.py index 9697d9d8f8..39eff67bde 100644 --- a/swift/obj/diskfile.py +++ b/swift/obj/diskfile.py @@ -40,7 +40,7 @@ import hashlib import logging import traceback import xattr -from os.path import basename, dirname, exists, getmtime, join +from os.path import basename, dirname, exists, getmtime, join, splitext from random import shuffle from tempfile import mkstemp from contextlib import contextmanager @@ -50,7 +50,7 @@ from eventlet import Timeout from eventlet.hubs import trampoline from swift import gettext_ as _ -from swift.common.constraints import check_mount +from swift.common.constraints import check_mount, check_dir from swift.common.request_helpers import is_sys_meta from swift.common.utils import mkdirs, Timestamp, \ storage_directory, hash_path, renamer, fallocate, fsync, \ @@ -63,7 +63,9 @@ from swift.common.exceptions import DiskFileQuarantined, DiskFileNotExist, \ DiskFileDeleted, DiskFileError, DiskFileNotOpen, PathNotDir, \ ReplicationLockTimeout, DiskFileExpired, DiskFileXattrNotSupported from swift.common.swob import multi_range_iterator -from swift.common.storage_policy import get_policy_string, POLICIES +from swift.common.storage_policy import ( + get_policy_string, split_policy_string, PolicyError, POLICIES, + REPL_POLICY, EC_POLICY) from functools import partial @@ -154,10 +156,10 @@ def write_metadata(fd, metadata, xattr_size=65536): raise -def extract_policy_index(obj_path): +def extract_policy(obj_path): """ - Extracts the policy index for an object (based on the name of the objects - directory) given the device-relative path to the object. Returns 0 in + Extracts the policy for an object (based on the name of the objects + directory) given the device-relative path to the object. Returns None in the event that the path is malformed in some way. The device-relative path is everything after the mount point; for example: @@ -170,19 +172,18 @@ def extract_policy_index(obj_path): objects-5/179/485dc017205a81df3af616d917c90179/1401811134.873649.data :param obj_path: device-relative path of an object - :returns: storage policy index + :returns: a :class:`~swift.common.storage_policy.BaseStoragePolicy` or None """ - policy_idx = 0 try: obj_portion = obj_path[obj_path.index(DATADIR_BASE):] obj_dirname = obj_portion[:obj_portion.index('/')] except Exception: - return policy_idx - if '-' in obj_dirname: - base, policy_idx = obj_dirname.split('-', 1) - if POLICIES.get_by_index(policy_idx) is None: - policy_idx = 0 - return int(policy_idx) + return None + try: + base, policy = split_policy_string(obj_dirname) + except PolicyError: + return None + return policy def quarantine_renamer(device_path, corrupted_file_path): @@ -197,9 +198,13 @@ def quarantine_renamer(device_path, corrupted_file_path): :raises OSError: re-raises non errno.EEXIST / errno.ENOTEMPTY exceptions from rename """ + policy = extract_policy(corrupted_file_path) + if policy is None: + # TODO: support a quarantine-unknown location + policy = POLICIES.legacy from_dir = dirname(corrupted_file_path) to_dir = join(device_path, 'quarantined', - get_data_dir(extract_policy_index(corrupted_file_path)), + get_data_dir(policy), basename(from_dir)) invalidate_hash(dirname(from_dir)) try: @@ -429,8 +434,9 @@ class AuditLocation(object): stringify to a filesystem path so the auditor's logs look okay. """ - def __init__(self, path, device, partition): - self.path, self.device, self.partition = path, device, partition + def __init__(self, path, device, partition, policy): + self.path, self.device, self.partition, self.policy = ( + path, device, partition, policy) def __str__(self): return str(self.path) @@ -470,19 +476,17 @@ def object_audit_location_generator(devices, mount_check=True, logger=None, _('Skipping %s as it is not mounted'), device) continue # loop through object dirs for all policies - for dir in [dir for dir in os.listdir(os.path.join(devices, device)) - if dir.startswith(DATADIR_BASE)]: - datadir_path = os.path.join(devices, device, dir) - # warn if the object dir doesn't match with a policy - policy_idx = 0 - if '-' in dir: - base, policy_idx = dir.split('-', 1) + for dir_ in os.listdir(os.path.join(devices, device)): + if not dir_.startswith(DATADIR_BASE): + continue try: - get_data_dir(policy_idx) - except ValueError: + base, policy = split_policy_string(dir_) + except PolicyError as e: if logger: - logger.warn(_('Directory %s does not map to a ' - 'valid policy') % dir) + logger.warn(_('Directory %r does not map ' + 'to a valid policy (%s)') % (dir_, e)) + continue + datadir_path = os.path.join(devices, device, dir_) partitions = listdir(datadir_path) for partition in partitions: part_path = os.path.join(datadir_path, partition) @@ -502,9 +506,50 @@ def object_audit_location_generator(devices, mount_check=True, logger=None, continue for hsh in hashes: hsh_path = os.path.join(suff_path, hsh) - yield AuditLocation(hsh_path, device, partition) + yield AuditLocation(hsh_path, device, partition, + policy) +def strip_self(f): + """ + Wrapper to attach module level functions to base class. + """ + def wrapper(self, *args, **kwargs): + return f(*args, **kwargs) + return wrapper + + +class DiskFileRouter(object): + + policy_type_to_manager_cls = {} + + @classmethod + def register(cls, policy_type): + """ + Decorator for Storage Policy implementations to register + their DiskFile implementation. + """ + def register_wrapper(diskfile_cls): + if policy_type in cls.policy_type_to_manager_cls: + raise PolicyError( + '%r is already registered for the policy_type %r' % ( + cls.policy_type_to_manager_cls[policy_type], + policy_type)) + cls.policy_type_to_manager_cls[policy_type] = diskfile_cls + return diskfile_cls + return register_wrapper + + def __init__(self, *args, **kwargs): + self.policy_to_manager = {} + for policy in POLICIES: + manager_cls = self.policy_type_to_manager_cls[policy.policy_type] + self.policy_to_manager[policy] = manager_cls(*args, **kwargs) + + def __getitem__(self, policy): + return self.policy_to_manager[policy] + + +@DiskFileRouter.register(REPL_POLICY) class DiskFileManager(object): """ Management class for devices, providing common place for shared parameters @@ -527,6 +572,16 @@ class DiskFileManager(object): :param conf: caller provided configuration object :param logger: caller provided logger """ + + diskfile_cls = None # DiskFile will be set after that class is defined + + # module level functions dropped to implementation specific + hash_cleanup_listdir = strip_self(hash_cleanup_listdir) + _get_hashes = strip_self(get_hashes) + invalidate_hash = strip_self(invalidate_hash) + get_ondisk_files = strip_self(get_ondisk_files) + quarantine_renamer = strip_self(quarantine_renamer) + def __init__(self, conf, logger): self.logger = logger self.devices = conf.get('devices', '/srv/node') @@ -583,21 +638,25 @@ class DiskFileManager(object): def get_dev_path(self, device, mount_check=None): """ - Return the path to a device, checking to see that it is a proper mount - point based on a configuration parameter. + Return the path to a device, first checking to see if either it + is a proper mount point, or at least a directory depending on + the mount_check configuration option. :param device: name of target device :param mount_check: whether or not to check mountedness of device. Defaults to bool(self.mount_check). :returns: full path to the device, None if the path to the device is - not a proper mount point. + not a proper mount point or directory. """ - should_check = self.mount_check if mount_check is None else mount_check - if should_check and not check_mount(self.devices, device): - dev_path = None - else: - dev_path = os.path.join(self.devices, device) - return dev_path + # we'll do some kind of check unless explicitly forbidden + if mount_check is not False: + if mount_check or self.mount_check: + check = check_mount + else: + check = check_dir + if not check(self.devices, device): + return None + return os.path.join(self.devices, device) @contextmanager def replication_lock(self, device): @@ -619,28 +678,27 @@ class DiskFileManager(object): yield True def pickle_async_update(self, device, account, container, obj, data, - timestamp, policy_idx): + timestamp, policy): device_path = self.construct_dev_path(device) - async_dir = os.path.join(device_path, get_async_dir(policy_idx)) + async_dir = os.path.join(device_path, get_async_dir(policy)) ohash = hash_path(account, container, obj) self.threadpools[device].run_in_thread( write_pickle, data, os.path.join(async_dir, ohash[-3:], ohash + '-' + Timestamp(timestamp).internal), - os.path.join(device_path, get_tmp_dir(policy_idx))) + os.path.join(device_path, get_tmp_dir(policy))) self.logger.increment('async_pendings') def get_diskfile(self, device, partition, account, container, obj, - policy_idx=0, **kwargs): + policy, **kwargs): dev_path = self.get_dev_path(device) if not dev_path: raise DiskFileDeviceUnavailable() - return DiskFile(self, dev_path, self.threadpools[device], - partition, account, container, obj, - policy_idx=policy_idx, - use_splice=self.use_splice, pipe_size=self.pipe_size, - **kwargs) + return self.diskfile_cls(self, dev_path, self.threadpools[device], + partition, account, container, obj, + policy=policy, use_splice=self.use_splice, + pipe_size=self.pipe_size, **kwargs) def object_audit_location_generator(self, device_dirs=None): return object_audit_location_generator(self.devices, self.mount_check, @@ -648,12 +706,12 @@ class DiskFileManager(object): def get_diskfile_from_audit_location(self, audit_location): dev_path = self.get_dev_path(audit_location.device, mount_check=False) - return DiskFile.from_hash_dir( + return self.diskfile_cls.from_hash_dir( self, audit_location.path, dev_path, - audit_location.partition) + audit_location.partition, policy=audit_location.policy) def get_diskfile_from_hash(self, device, partition, object_hash, - policy_idx, **kwargs): + policy, **kwargs): """ Returns a DiskFile instance for an object at the given object_hash. Just in case someone thinks of refactoring, be @@ -667,13 +725,14 @@ class DiskFileManager(object): if not dev_path: raise DiskFileDeviceUnavailable() object_path = os.path.join( - dev_path, get_data_dir(policy_idx), partition, object_hash[-3:], + dev_path, get_data_dir(policy), str(partition), object_hash[-3:], object_hash) try: - filenames = hash_cleanup_listdir(object_path, self.reclaim_age) + filenames = self.hash_cleanup_listdir(object_path, + self.reclaim_age) except OSError as err: if err.errno == errno.ENOTDIR: - quar_path = quarantine_renamer(dev_path, object_path) + quar_path = self.quarantine_renamer(dev_path, object_path) logging.exception( _('Quarantined %(object_path)s to %(quar_path)s because ' 'it is not a directory'), {'object_path': object_path, @@ -693,21 +752,20 @@ class DiskFileManager(object): metadata.get('name', ''), 3, 3, True) except ValueError: raise DiskFileNotExist() - return DiskFile(self, dev_path, self.threadpools[device], - partition, account, container, obj, - policy_idx=policy_idx, **kwargs) + return self.diskfile_cls(self, dev_path, self.threadpools[device], + partition, account, container, obj, + policy=policy, **kwargs) - def get_hashes(self, device, partition, suffix, policy_idx): + def get_hashes(self, device, partition, suffixes, policy): dev_path = self.get_dev_path(device) if not dev_path: raise DiskFileDeviceUnavailable() - partition_path = os.path.join(dev_path, get_data_dir(policy_idx), + partition_path = os.path.join(dev_path, get_data_dir(policy), partition) if not os.path.exists(partition_path): mkdirs(partition_path) - suffixes = suffix.split('-') if suffix else [] _junk, hashes = self.threadpools[device].force_run_in_thread( - get_hashes, partition_path, recalculate=suffixes) + self._get_hashes, partition_path, recalculate=suffixes) return hashes def _listdir(self, path): @@ -720,7 +778,7 @@ class DiskFileManager(object): path, err) return [] - def yield_suffixes(self, device, partition, policy_idx): + def yield_suffixes(self, device, partition, policy): """ Yields tuples of (full_path, suffix_only) for suffixes stored on the given device and partition. @@ -728,7 +786,7 @@ class DiskFileManager(object): dev_path = self.get_dev_path(device) if not dev_path: raise DiskFileDeviceUnavailable() - partition_path = os.path.join(dev_path, get_data_dir(policy_idx), + partition_path = os.path.join(dev_path, get_data_dir(policy), partition) for suffix in self._listdir(partition_path): if len(suffix) != 3: @@ -739,7 +797,7 @@ class DiskFileManager(object): continue yield (os.path.join(partition_path, suffix), suffix) - def yield_hashes(self, device, partition, policy_idx, suffixes=None): + def yield_hashes(self, device, partition, policy, suffixes=None, **kwargs): """ Yields tuples of (full_path, hash_only, timestamp) for object information stored for the given device, partition, and @@ -752,17 +810,18 @@ class DiskFileManager(object): if not dev_path: raise DiskFileDeviceUnavailable() if suffixes is None: - suffixes = self.yield_suffixes(device, partition, policy_idx) + suffixes = self.yield_suffixes(device, partition, policy) else: - partition_path = os.path.join(dev_path, get_data_dir(policy_idx), - partition) + partition_path = os.path.join(dev_path, + get_data_dir(policy), + str(partition)) suffixes = ( (os.path.join(partition_path, suffix), suffix) for suffix in suffixes) for suffix_path, suffix in suffixes: for object_hash in self._listdir(suffix_path): object_path = os.path.join(suffix_path, object_hash) - for name in hash_cleanup_listdir( + for name in self.hash_cleanup_listdir( object_path, self.reclaim_age): ts, ext = name.rsplit('.', 1) yield (object_path, object_hash, ts) @@ -794,8 +853,11 @@ class DiskFileWriter(object): :param tmppath: full path name of the opened file descriptor :param bytes_per_sync: number bytes written between sync calls :param threadpool: internal thread pool to use for disk operations + :param diskfile: the diskfile creating this DiskFileWriter instance """ - def __init__(self, name, datadir, fd, tmppath, bytes_per_sync, threadpool): + + def __init__(self, name, datadir, fd, tmppath, bytes_per_sync, threadpool, + diskfile): # Parameter tracking self._name = name self._datadir = datadir @@ -803,6 +865,7 @@ class DiskFileWriter(object): self._tmppath = tmppath self._bytes_per_sync = bytes_per_sync self._threadpool = threadpool + self._diskfile = diskfile # Internal attributes self._upload_size = 0 @@ -810,6 +873,10 @@ class DiskFileWriter(object): self._extension = '.data' self._put_succeeded = False + @property + def manager(self): + return self._diskfile.manager + @property def put_succeeded(self): return self._put_succeeded @@ -855,7 +922,7 @@ class DiskFileWriter(object): # drop_cache() after fsync() to avoid redundant work (pages all # clean). drop_buffer_cache(self._fd, 0, self._upload_size) - invalidate_hash(dirname(self._datadir)) + self.manager.invalidate_hash(dirname(self._datadir)) # After the rename completes, this object will be available for other # requests to reference. renamer(self._tmppath, target_path) @@ -864,7 +931,7 @@ class DiskFileWriter(object): # succeeded, the tempfile would no longer exist at its original path. self._put_succeeded = True try: - hash_cleanup_listdir(self._datadir) + self.manager.hash_cleanup_listdir(self._datadir) except OSError: logging.exception(_('Problem cleaning up %s'), self._datadir) @@ -887,6 +954,16 @@ class DiskFileWriter(object): self._threadpool.force_run_in_thread( self._finalize_put, metadata, target_path) + def commit(self, timestamp): + """ + Perform any operations necessary to mark the object as durable. For + replication policy type this is a no-op. + + :param timestamp: object put timestamp, an instance of + :class:`~swift.common.utils.Timestamp` + """ + pass + class DiskFileReader(object): """ @@ -917,17 +994,20 @@ class DiskFileReader(object): :param quarantine_hook: 1-arg callable called w/reason when quarantined :param use_splice: if true, use zero-copy splice() to send data :param pipe_size: size of pipe buffer used in zero-copy operations + :param diskfile: the diskfile creating this DiskFileReader instance :param keep_cache: should resulting reads be kept in the buffer cache """ def __init__(self, fp, data_file, obj_size, etag, threadpool, disk_chunk_size, keep_cache_size, device_path, logger, - quarantine_hook, use_splice, pipe_size, keep_cache=False): + quarantine_hook, use_splice, pipe_size, diskfile, + keep_cache=False): # Parameter tracking self._fp = fp self._data_file = data_file self._obj_size = obj_size self._etag = etag self._threadpool = threadpool + self._diskfile = diskfile self._disk_chunk_size = disk_chunk_size self._device_path = device_path self._logger = logger @@ -950,6 +1030,10 @@ class DiskFileReader(object): self._suppress_file_closing = False self._quarantined_dir = None + @property + def manager(self): + return self._diskfile.manager + def __iter__(self): """Returns an iterator over the data file.""" try: @@ -1130,7 +1214,8 @@ class DiskFileReader(object): def _quarantine(self, msg): self._quarantined_dir = self._threadpool.run_in_thread( - quarantine_renamer, self._device_path, self._data_file) + self.manager.quarantine_renamer, self._device_path, + self._data_file) self._logger.warn("Quarantined object %s: %s" % ( self._data_file, msg)) self._logger.increment('quarantines') @@ -1196,15 +1281,18 @@ class DiskFile(object): :param container: container name for the object :param obj: object name for the object :param _datadir: override the full datadir otherwise constructed here - :param policy_idx: used to get the data dir when constructing it here + :param policy: the StoragePolicy instance :param use_splice: if true, use zero-copy splice() to send data :param pipe_size: size of pipe buffer used in zero-copy operations """ + reader_cls = DiskFileReader + writer_cls = DiskFileWriter + def __init__(self, mgr, device_path, threadpool, partition, account=None, container=None, obj=None, _datadir=None, - policy_idx=0, use_splice=False, pipe_size=None): - self._mgr = mgr + policy=None, use_splice=False, pipe_size=None, **kwargs): + self._manager = mgr self._device_path = device_path self._threadpool = threadpool or ThreadPool(nthreads=0) self._logger = mgr.logger @@ -1212,6 +1300,7 @@ class DiskFile(object): self._bytes_per_sync = mgr.bytes_per_sync self._use_splice = use_splice self._pipe_size = pipe_size + self.policy = policy if account and container and obj: self._name = '/' + '/'.join((account, container, obj)) self._account = account @@ -1219,7 +1308,7 @@ class DiskFile(object): self._obj = obj name_hash = hash_path(account, container, obj) self._datadir = join( - device_path, storage_directory(get_data_dir(policy_idx), + device_path, storage_directory(get_data_dir(policy), partition, name_hash)) else: # gets populated when we read the metadata @@ -1228,7 +1317,7 @@ class DiskFile(object): self._container = None self._obj = None self._datadir = None - self._tmpdir = join(device_path, get_tmp_dir(policy_idx)) + self._tmpdir = join(device_path, get_tmp_dir(policy)) self._metadata = None self._data_file = None self._fp = None @@ -1239,9 +1328,13 @@ class DiskFile(object): else: name_hash = hash_path(account, container, obj) self._datadir = join( - device_path, storage_directory(get_data_dir(policy_idx), + device_path, storage_directory(get_data_dir(policy), partition, name_hash)) + @property + def manager(self): + return self._manager + @property def account(self): return self._account @@ -1267,8 +1360,9 @@ class DiskFile(object): return Timestamp(self._metadata.get('X-Timestamp')) @classmethod - def from_hash_dir(cls, mgr, hash_dir_path, device_path, partition): - return cls(mgr, device_path, None, partition, _datadir=hash_dir_path) + def from_hash_dir(cls, mgr, hash_dir_path, device_path, partition, policy): + return cls(mgr, device_path, None, partition, _datadir=hash_dir_path, + policy=policy) def open(self): """ @@ -1307,7 +1401,7 @@ class DiskFile(object): .. note:: - An implemenation shall raise `DiskFileNotOpen` when has not + An implementation shall raise `DiskFileNotOpen` when has not previously invoked the :func:`swift.obj.diskfile.DiskFile.open` method. """ @@ -1339,7 +1433,7 @@ class DiskFile(object): :returns: DiskFileQuarantined exception object """ self._quarantined_dir = self._threadpool.run_in_thread( - quarantine_renamer, self._device_path, data_file) + self.manager.quarantine_renamer, self._device_path, data_file) self._logger.warn("Quarantined object %s: %s" % ( data_file, msg)) self._logger.increment('quarantines') @@ -1384,7 +1478,7 @@ class DiskFile(object): # The data directory does not exist, so the object cannot exist. fileset = (None, None, None) else: - fileset = get_ondisk_files(files, self._datadir) + fileset = self.manager.get_ondisk_files(files, self._datadir) return fileset def _construct_exception_from_ts_file(self, ts_file): @@ -1576,12 +1670,12 @@ class DiskFile(object): Not needed by the REST layer. :returns: a :class:`swift.obj.diskfile.DiskFileReader` object """ - dr = DiskFileReader( + dr = self.reader_cls( self._fp, self._data_file, int(self._metadata['Content-Length']), self._metadata['ETag'], self._threadpool, self._disk_chunk_size, - self._mgr.keep_cache_size, self._device_path, self._logger, + self._manager.keep_cache_size, self._device_path, self._logger, use_splice=self._use_splice, quarantine_hook=_quarantine_hook, - pipe_size=self._pipe_size, keep_cache=keep_cache) + pipe_size=self._pipe_size, diskfile=self, keep_cache=keep_cache) # At this point the reader object is now responsible for closing # the file pointer. self._fp = None @@ -1605,16 +1699,26 @@ class DiskFile(object): """ if not exists(self._tmpdir): mkdirs(self._tmpdir) - fd, tmppath = mkstemp(dir=self._tmpdir) + try: + fd, tmppath = mkstemp(dir=self._tmpdir) + except OSError as err: + if err.errno in (errno.ENOSPC, errno.EDQUOT): + # No more inodes in filesystem + raise DiskFileNoSpace() + raise dfw = None try: if size is not None and size > 0: try: fallocate(fd, size) - except OSError: - raise DiskFileNoSpace() - dfw = DiskFileWriter(self._name, self._datadir, fd, tmppath, - self._bytes_per_sync, self._threadpool) + except OSError as err: + if err.errno in (errno.ENOSPC, errno.EDQUOT): + raise DiskFileNoSpace() + raise + dfw = self.writer_cls(self._name, self._datadir, fd, tmppath, + bytes_per_sync=self._bytes_per_sync, + threadpool=self._threadpool, + diskfile=self) yield dfw finally: try: @@ -1663,8 +1767,620 @@ class DiskFile(object): :raises DiskFileError: this implementation will raise the same errors as the `create()` method. """ - timestamp = Timestamp(timestamp).internal - + # this is dumb, only tests send in strings + timestamp = Timestamp(timestamp) with self.create() as deleter: deleter._extension = '.ts' - deleter.put({'X-Timestamp': timestamp}) + deleter.put({'X-Timestamp': timestamp.internal}) + +# TODO: move DiskFileManager definition down here +DiskFileManager.diskfile_cls = DiskFile + + +class ECDiskFileReader(DiskFileReader): + pass + + +class ECDiskFileWriter(DiskFileWriter): + + def _finalize_durable(self, durable_file_path): + exc = msg = None + try: + with open(durable_file_path, 'w') as _fd: + fsync(_fd) + try: + self.manager.hash_cleanup_listdir(self._datadir) + except OSError: + self.manager.logger.exception( + _('Problem cleaning up %s'), self._datadir) + except OSError: + msg = (_('Problem fsyncing durable state file: %s'), + durable_file_path) + exc = DiskFileError(msg) + except IOError as io_err: + if io_err.errno in (errno.ENOSPC, errno.EDQUOT): + msg = (_("No space left on device for %s"), + durable_file_path) + exc = DiskFileNoSpace() + else: + msg = (_('Problem writing durable state file: %s'), + durable_file_path) + exc = DiskFileError(msg) + if exc: + self.manager.logger.exception(msg) + raise exc + + def commit(self, timestamp): + """ + Finalize put by writing a timestamp.durable file for the object. We + do this for EC policy because it requires a 2-phase put commit + confirmation. + + :param timestamp: object put timestamp, an instance of + :class:`~swift.common.utils.Timestamp` + """ + durable_file_path = os.path.join( + self._datadir, timestamp.internal + '.durable') + self._threadpool.force_run_in_thread( + self._finalize_durable, durable_file_path) + + def put(self, metadata): + """ + The only difference between this method and the replication policy + DiskFileWriter method is the call into manager.make_on_disk_filename + to construct the data file name. + """ + timestamp = Timestamp(metadata['X-Timestamp']) + fi = None + if self._extension == '.data': + # generally we treat the fragment index provided in metadata as + # canon, but if it's unavailable (e.g. tests) it's reasonable to + # use the frag_index provided at instantiation. Either way make + # sure that the fragment index is included in object sysmeta. + fi = metadata.setdefault('X-Object-Sysmeta-Ec-Frag-Index', + self._diskfile._frag_index) + filename = self.manager.make_on_disk_filename( + timestamp, self._extension, frag_index=fi) + metadata['name'] = self._name + target_path = join(self._datadir, filename) + + self._threadpool.force_run_in_thread( + self._finalize_put, metadata, target_path) + + +class ECDiskFile(DiskFile): + + reader_cls = ECDiskFileReader + writer_cls = ECDiskFileWriter + + def __init__(self, *args, **kwargs): + super(ECDiskFile, self).__init__(*args, **kwargs) + frag_index = kwargs.get('frag_index') + self._frag_index = None + if frag_index is not None: + self._frag_index = self.manager.validate_fragment_index(frag_index) + + def _get_ondisk_file(self): + """ + The only difference between this method and the replication policy + DiskFile method is passing in the frag_index kwarg to our manager's + get_ondisk_files method. + """ + try: + files = os.listdir(self._datadir) + except OSError as err: + if err.errno == errno.ENOTDIR: + # If there's a file here instead of a directory, quarantine + # it; something's gone wrong somewhere. + raise self._quarantine( + # hack: quarantine_renamer actually renames the directory + # enclosing the filename you give it, but here we just + # want this one file and not its parent. + os.path.join(self._datadir, "made-up-filename"), + "Expected directory, found file at %s" % self._datadir) + elif err.errno != errno.ENOENT: + raise DiskFileError( + "Error listing directory %s: %s" % (self._datadir, err)) + # The data directory does not exist, so the object cannot exist. + fileset = (None, None, None) + else: + fileset = self.manager.get_ondisk_files( + files, self._datadir, frag_index=self._frag_index) + return fileset + + def purge(self, timestamp, frag_index): + """ + Remove a tombstone file matching the specified timestamp or + datafile matching the specified timestamp and fragment index + from the object directory. + + This provides the EC reconstructor/ssync process with a way to + remove a tombstone or fragment from a handoff node after + reverting it to its primary node. + + The hash will be invalidated, and if empty or invalid the + hsh_path will be removed on next hash_cleanup_listdir. + + :param timestamp: the object timestamp, an instance of + :class:`~swift.common.utils.Timestamp` + :param frag_index: a fragment archive index, must be a whole number. + """ + for ext in ('.data', '.ts'): + purge_file = self.manager.make_on_disk_filename( + timestamp, ext=ext, frag_index=frag_index) + remove_file(os.path.join(self._datadir, purge_file)) + self.manager.invalidate_hash(dirname(self._datadir)) + + +@DiskFileRouter.register(EC_POLICY) +class ECDiskFileManager(DiskFileManager): + diskfile_cls = ECDiskFile + + def validate_fragment_index(self, frag_index): + """ + Return int representation of frag_index, or raise a DiskFileError if + frag_index is not a whole number. + """ + try: + frag_index = int(str(frag_index)) + except (ValueError, TypeError) as e: + raise DiskFileError( + 'Bad fragment index: %s: %s' % (frag_index, e)) + if frag_index < 0: + raise DiskFileError( + 'Fragment index must not be negative: %s' % frag_index) + return frag_index + + def make_on_disk_filename(self, timestamp, ext=None, frag_index=None, + *a, **kw): + """ + Returns the EC specific filename for given timestamp. + + :param timestamp: the object timestamp, an instance of + :class:`~swift.common.utils.Timestamp` + :param ext: an optional string representing a file extension to be + appended to the returned file name + :param frag_index: a fragment archive index, used with .data extension + only, must be a whole number. + :returns: a file name + :raises DiskFileError: if ext=='.data' and the kwarg frag_index is not + a whole number + """ + rv = timestamp.internal + if ext == '.data': + # for datafiles only we encode the fragment index in the filename + # to allow archives of different indexes to temporarily be stored + # on the same node in certain situations + frag_index = self.validate_fragment_index(frag_index) + rv += '#' + str(frag_index) + if ext: + rv = '%s%s' % (rv, ext) + return rv + + def parse_on_disk_filename(self, filename): + """ + Returns the timestamp extracted from a policy specific .data file name. + For EC policy the data file name includes a fragment index which must + be stripped off to retrieve the timestamp. + + :param filename: the data file name including extension + :returns: a dict, with keys for timestamp, frag_index, and ext:: + + * timestamp is a :class:`~swift.common.utils.Timestamp` + * frag_index is an int or None + * ext is a string, the file extension including the leading dot or + the empty string if the filename has no extenstion. + + :raises DiskFileError: if any part of the filename is not able to be + validated. + """ + frag_index = None + filename, ext = splitext(filename) + parts = filename.split('#', 1) + timestamp = parts[0] + if ext == '.data': + # it is an error for an EC data file to not have a valid + # fragment index + try: + frag_index = parts[1] + except IndexError: + # expect validate_fragment_index raise DiskFileError + pass + frag_index = self.validate_fragment_index(frag_index) + return { + 'timestamp': Timestamp(timestamp), + 'frag_index': frag_index, + 'ext': ext, + } + + def is_obsolete(self, filename, other_filename): + """ + Test if a given file is considered to be obsolete with respect to + another file in an object storage dir. + + Implements EC policy specific behavior when comparing files against a + .durable file. + + A simple string comparison would consider t2#1.data to be older than + t2.durable (since t2#1.data < t2.durable). By stripping off the file + extensions we get the desired behavior: t2#1 > t2 without compromising + the detection of t1#1 < t2. + + :param filename: a string representing an absolute filename + :param other_filename: a string representing an absolute filename + :returns: True if filename is considered obsolete, False otherwise. + """ + if other_filename.endswith('.durable'): + return splitext(filename)[0] < splitext(other_filename)[0] + return filename < other_filename + + def _gather_on_disk_file(self, filename, ext, context, frag_index=None, + **kwargs): + """ + Called by gather_ondisk_files() for each file in an object + datadir in reverse sorted order. If a file is considered part of a + valid on-disk file set it will be added to the context dict, keyed by + its extension. If a file is considered to be obsolete it will be added + to a list stored under the key 'obsolete' in the context dict. + + :param filename: name of file to be accepted or not + :param ext: extension part of filename + :param context: a context dict that may have been populated by previous + calls to this method + :param frag_index: if set, search for a specific fragment index .data + file, otherwise accept the first valid .data file. + :returns: True if a valid file set has been found, False otherwise + """ + + # if first file with given extension then add filename to context + # dict and return True + accept_first = lambda: context.setdefault(ext, filename) == filename + # add the filename to the list of obsolete files in context dict + discard = lambda: context.setdefault('obsolete', []).append(filename) + # set a flag in the context dict indicating that a valid fileset has + # been found + set_valid_fileset = lambda: context.setdefault('found_valid', True) + # return True if the valid fileset flag is set in the context dict + have_valid_fileset = lambda: context.get('found_valid') + + if context.get('.durable'): + # a .durable file has been found + if ext == '.data': + if self.is_obsolete(filename, context.get('.durable')): + # this and remaining data files are older than durable + discard() + set_valid_fileset() + else: + # accept the first .data file if it matches requested + # frag_index, or if no specific frag_index is requested + fi = self.parse_on_disk_filename(filename)['frag_index'] + if frag_index is None or frag_index == int(fi): + accept_first() + set_valid_fileset() + # else: keep searching for a .data file to match frag_index + context.setdefault('fragments', []).append(filename) + else: + # there can no longer be a matching .data file so mark what has + # been found so far as the valid fileset + discard() + set_valid_fileset() + elif ext == '.data': + # not yet found a .durable + if have_valid_fileset(): + # valid fileset means we must have a newer + # .ts, so discard the older .data file + discard() + else: + # .data newer than a .durable or .ts, don't discard yet + context.setdefault('fragments_without_durable', []).append( + filename) + elif ext == '.ts': + if have_valid_fileset() or not accept_first(): + # newer .data, .durable or .ts already found so discard this + discard() + if not have_valid_fileset(): + # remove any .meta that may have been previously found + context['.meta'] = None + set_valid_fileset() + elif ext in ('.meta', '.durable'): + if have_valid_fileset() or not accept_first(): + # newer .data, .durable or .ts already found so discard this + discard() + else: + # ignore unexpected files + pass + return have_valid_fileset() + + def _verify_on_disk_files(self, accepted_files, frag_index=None, **kwargs): + """ + Verify that the final combination of on disk files complies with the + diskfile contract. + + :param accepted_files: files that have been found and accepted + :param frag_index: specifies a specific fragment index .data file + :returns: True if the file combination is compliant, False otherwise + """ + if not accepted_files.get('.data'): + # We may find only a .meta, which doesn't mean the on disk + # contract is broken. So we clear it to comply with + # superclass assertions. + accepted_files['.meta'] = None + + data_file, meta_file, ts_file, durable_file = tuple( + [accepted_files.get(ext) + for ext in ('.data', '.meta', '.ts', '.durable')]) + + return ((data_file is None or durable_file is not None) + and (data_file is None and meta_file is None + and ts_file is None and durable_file is None) + or (ts_file is not None and data_file is None + and meta_file is None and durable_file is None) + or (data_file is not None and durable_file is not None + and ts_file is None) + or (durable_file is not None and meta_file is None + and ts_file is None)) + + def gather_ondisk_files(self, files, include_obsolete=False, + frag_index=None, verify=False, **kwargs): + """ + Given a simple list of files names, iterate over them to determine the + files that constitute a valid object, and optionally determine the + files that are obsolete and could be deleted. Note that some files may + fall into neither category. + + :param files: a list of file names. + :param include_obsolete: By default the iteration will stop when a + valid file set has been found. Setting this + argument to True will cause the iteration to + continue in order to find all obsolete files. + :param frag_index: if set, search for a specific fragment index .data + file, otherwise accept the first valid .data file. + :returns: a dict that may contain: valid on disk files keyed by their + filename extension; a list of obsolete files stored under the + key 'obsolete'. + """ + # This visitor pattern enables future refactoring of other disk + # manager implementations to re-use this method and override + # _gather_ondisk_file and _verify_ondisk_files to apply implementation + # specific selection and verification of on-disk files. + files.sort(reverse=True) + results = {} + for afile in files: + ts_file = results.get('.ts') + data_file = results.get('.data') + if not include_obsolete: + assert ts_file is None, "On-disk file search loop" \ + " continuing after tombstone, %s, encountered" % ts_file + assert data_file is None, "On-disk file search loop" \ + " continuing after data file, %s, encountered" % data_file + + ext = splitext(afile)[1] + if self._gather_on_disk_file( + afile, ext, results, frag_index=frag_index, **kwargs): + if not include_obsolete: + break + + if verify: + assert self._verify_on_disk_files( + results, frag_index=frag_index, **kwargs), \ + "On-disk file search algorithm contract is broken: %s" \ + % results.values() + return results + + def get_ondisk_files(self, files, datadir, **kwargs): + """ + Given a simple list of files names, determine the files to use. + + :param files: simple set of files as a python list + :param datadir: directory name files are from for convenience + :returns: a tuple of data, meta, and tombstone + """ + # maintain compatibility with 'legacy' get_ondisk_files return value + accepted_files = self.gather_ondisk_files(files, verify=True, **kwargs) + result = [(join(datadir, accepted_files.get(ext)) + if accepted_files.get(ext) else None) + for ext in ('.data', '.meta', '.ts')] + return tuple(result) + + def cleanup_ondisk_files(self, hsh_path, reclaim_age=ONE_WEEK, + frag_index=None): + """ + Clean up on-disk files that are obsolete and gather the set of valid + on-disk files for an object. + + :param hsh_path: object hash path + :param reclaim_age: age in seconds at which to remove tombstones + :param frag_index: if set, search for a specific fragment index .data + file, otherwise accept the first valid .data file + :returns: a dict that may contain: valid on disk files keyed by their + filename extension; a list of obsolete files stored under the + key 'obsolete'; a list of files remaining in the directory, + reverse sorted, stored under the key 'files'. + """ + def is_reclaimable(filename): + timestamp = self.parse_on_disk_filename(filename)['timestamp'] + return (time.time() - float(timestamp)) > reclaim_age + + files = listdir(hsh_path) + files.sort(reverse=True) + results = self.gather_ondisk_files(files, include_obsolete=True, + frag_index=frag_index) + if '.durable' in results and not results.get('fragments'): + # a .durable with no .data is deleted as soon as it is found + results.setdefault('obsolete', []).append(results.pop('.durable')) + if '.ts' in results and is_reclaimable(results['.ts']): + results.setdefault('obsolete', []).append(results.pop('.ts')) + for filename in results.get('fragments_without_durable', []): + # stray fragments are not deleted until reclaim-age + if is_reclaimable(filename): + results.setdefault('obsolete', []).append(filename) + for filename in results.get('obsolete', []): + remove_file(join(hsh_path, filename)) + files.remove(filename) + results['files'] = files + return results + + def hash_cleanup_listdir(self, hsh_path, reclaim_age=ONE_WEEK): + """ + List contents of a hash directory and clean up any old files. + For EC policy, delete files older than a .durable or .ts file. + + :param hsh_path: object hash path + :param reclaim_age: age in seconds at which to remove tombstones + :returns: list of files remaining in the directory, reverse sorted + """ + # maintain compatibility with 'legacy' hash_cleanup_listdir + # return value + return self.cleanup_ondisk_files( + hsh_path, reclaim_age=reclaim_age)['files'] + + def yield_hashes(self, device, partition, policy, + suffixes=None, frag_index=None): + """ + This is the same as the replicated yield_hashes except when frag_index + is provided data files for fragment indexes not matching the given + frag_index are skipped. + """ + dev_path = self.get_dev_path(device) + if not dev_path: + raise DiskFileDeviceUnavailable() + if suffixes is None: + suffixes = self.yield_suffixes(device, partition, policy) + else: + partition_path = os.path.join(dev_path, + get_data_dir(policy), + str(partition)) + suffixes = ( + (os.path.join(partition_path, suffix), suffix) + for suffix in suffixes) + for suffix_path, suffix in suffixes: + for object_hash in self._listdir(suffix_path): + object_path = os.path.join(suffix_path, object_hash) + newest_valid_file = None + try: + results = self.cleanup_ondisk_files( + object_path, self.reclaim_age, frag_index=frag_index) + newest_valid_file = (results.get('.meta') + or results.get('.data') + or results.get('.ts')) + if newest_valid_file: + timestamp = self.parse_on_disk_filename( + newest_valid_file)['timestamp'] + yield (object_path, object_hash, timestamp.internal) + except AssertionError as err: + self.logger.debug('Invalid file set in %s (%s)' % ( + object_path, err)) + except DiskFileError as err: + self.logger.debug( + 'Invalid diskfile filename %r in %r (%s)' % ( + newest_valid_file, object_path, err)) + + def _hash_suffix(self, path, reclaim_age): + """ + The only difference between this method and the module level function + hash_suffix is the way that files are updated on the returned hash. + + Instead of all filenames hashed into a single hasher, each file name + will fall into a bucket either by fragment index for datafiles, or + None (indicating a durable, metadata or tombstone). + """ + # hash_per_fi instead of single hash for whole suffix + hash_per_fi = defaultdict(hashlib.md5) + try: + path_contents = sorted(os.listdir(path)) + except OSError as err: + if err.errno in (errno.ENOTDIR, errno.ENOENT): + raise PathNotDir() + raise + for hsh in path_contents: + hsh_path = join(path, hsh) + try: + files = self.hash_cleanup_listdir(hsh_path, reclaim_age) + except OSError as err: + if err.errno == errno.ENOTDIR: + partition_path = dirname(path) + objects_path = dirname(partition_path) + device_path = dirname(objects_path) + quar_path = quarantine_renamer(device_path, hsh_path) + logging.exception( + _('Quarantined %(hsh_path)s to %(quar_path)s because ' + 'it is not a directory'), {'hsh_path': hsh_path, + 'quar_path': quar_path}) + continue + raise + if not files: + try: + os.rmdir(hsh_path) + except OSError: + pass + # we just deleted this hsh_path, why are we waiting + # until the next suffix hash to raise PathNotDir so that + # this suffix will get del'd from the suffix hashes? + for filename in files: + info = self.parse_on_disk_filename(filename) + fi = info['frag_index'] + if fi is None: + hash_per_fi[fi].update(filename) + else: + hash_per_fi[fi].update(info['timestamp'].internal) + try: + os.rmdir(path) + except OSError: + pass + # here we flatten out the hashers hexdigest into a dictionary instead + # of just returning the one hexdigest for the whole suffix + return dict((fi, md5.hexdigest()) for fi, md5 in hash_per_fi.items()) + + def _get_hashes(self, partition_path, recalculate=None, do_listdir=False, + reclaim_age=None): + """ + The only difference with this method and the module level function + get_hashes is the call to hash_suffix routes to a method _hash_suffix + on this instance. + """ + reclaim_age = reclaim_age or self.reclaim_age + hashed = 0 + hashes_file = join(partition_path, HASH_FILE) + modified = False + force_rewrite = False + hashes = {} + mtime = -1 + + if recalculate is None: + recalculate = [] + + try: + with open(hashes_file, 'rb') as fp: + hashes = pickle.load(fp) + mtime = getmtime(hashes_file) + except Exception: + do_listdir = True + force_rewrite = True + if do_listdir: + for suff in os.listdir(partition_path): + if len(suff) == 3: + hashes.setdefault(suff, None) + modified = True + hashes.update((suffix, None) for suffix in recalculate) + for suffix, hash_ in hashes.items(): + if not hash_: + suffix_dir = join(partition_path, suffix) + try: + hashes[suffix] = self._hash_suffix(suffix_dir, reclaim_age) + hashed += 1 + except PathNotDir: + del hashes[suffix] + except OSError: + logging.exception(_('Error hashing suffix')) + modified = True + if modified: + with lock_path(partition_path): + if force_rewrite or not exists(hashes_file) or \ + getmtime(hashes_file) == mtime: + write_pickle( + hashes, hashes_file, partition_path, PICKLE_PROTOCOL) + return hashed, hashes + return self._get_hashes(partition_path, recalculate, do_listdir, + reclaim_age) + else: + return hashed, hashes diff --git a/swift/obj/mem_diskfile.py b/swift/obj/mem_diskfile.py index efb8c6c8c0..be5fbf1349 100644 --- a/swift/obj/mem_diskfile.py +++ b/swift/obj/mem_diskfile.py @@ -57,6 +57,12 @@ class InMemoryFileSystem(object): def get_diskfile(self, account, container, obj, **kwargs): return DiskFile(self, account, container, obj) + def pickle_async_update(self, *args, **kwargs): + """ + For now don't handle async updates. + """ + pass + class DiskFileWriter(object): """ @@ -98,6 +104,16 @@ class DiskFileWriter(object): metadata['name'] = self._name self._filesystem.put_object(self._name, self._fp, metadata) + def commit(self, timestamp): + """ + Perform any operations necessary to mark the object as durable. For + mem_diskfile type this is a no-op. + + :param timestamp: object put timestamp, an instance of + :class:`~swift.common.utils.Timestamp` + """ + pass + class DiskFileReader(object): """ diff --git a/swift/obj/mem_server.py b/swift/obj/mem_server.py index 83647661aa..764a92a92d 100644 --- a/swift/obj/mem_server.py +++ b/swift/obj/mem_server.py @@ -15,15 +15,7 @@ """ In-Memory Object Server for Swift """ -import os -from swift import gettext_ as _ -from eventlet import Timeout - -from swift.common.bufferedhttp import http_connect -from swift.common.exceptions import ConnectionTimeout - -from swift.common.http import is_success from swift.obj.mem_diskfile import InMemoryFileSystem from swift.obj import server @@ -53,49 +45,6 @@ class ObjectController(server.ObjectController): """ return self._filesystem.get_diskfile(account, container, obj, **kwargs) - def async_update(self, op, account, container, obj, host, partition, - contdevice, headers_out, objdevice, policy_idx): - """ - Sends or saves an async update. - - :param op: operation performed (ex: 'PUT', or 'DELETE') - :param account: account name for the object - :param container: container name for the object - :param obj: object name - :param host: host that the container is on - :param partition: partition that the container is on - :param contdevice: device name that the container is on - :param headers_out: dictionary of headers to send in the container - request - :param objdevice: device name that the object is in - :param policy_idx: the associated storage policy index - """ - headers_out['user-agent'] = 'object-server %s' % os.getpid() - full_path = '/%s/%s/%s' % (account, container, obj) - if all([host, partition, contdevice]): - try: - with ConnectionTimeout(self.conn_timeout): - ip, port = host.rsplit(':', 1) - conn = http_connect(ip, port, contdevice, partition, op, - full_path, headers_out) - with Timeout(self.node_timeout): - response = conn.getresponse() - response.read() - if is_success(response.status): - return - else: - self.logger.error(_( - 'ERROR Container update failed: %(status)d ' - 'response from %(ip)s:%(port)s/%(dev)s'), - {'status': response.status, 'ip': ip, 'port': port, - 'dev': contdevice}) - except (Exception, Timeout): - self.logger.exception(_( - 'ERROR container update failed with ' - '%(ip)s:%(port)s/%(dev)s'), - {'ip': ip, 'port': port, 'dev': contdevice}) - # FIXME: For now don't handle async updates - def REPLICATE(self, request): """ Handle REPLICATE requests for the Swift Object Server. This is used diff --git a/swift/obj/reconstructor.py b/swift/obj/reconstructor.py new file mode 100644 index 0000000000..db078de2fc --- /dev/null +++ b/swift/obj/reconstructor.py @@ -0,0 +1,927 @@ +# Copyright (c) 2010-2015 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from os.path import join +import random +import time +import itertools +from collections import defaultdict +import cPickle as pickle +import shutil + +from eventlet import (GreenPile, GreenPool, Timeout, sleep, hubs, tpool, + spawn) +from eventlet.support.greenlets import GreenletExit + +from swift import gettext_ as _ +from swift.common.utils import ( + whataremyips, unlink_older_than, compute_eta, get_logger, + dump_recon_cache, ismount, mkdirs, config_true_value, list_from_csv, + get_hub, tpool_reraise, GreenAsyncPile, Timestamp, remove_file) +from swift.common.swob import HeaderKeyDict +from swift.common.bufferedhttp import http_connect +from swift.common.daemon import Daemon +from swift.common.ring.utils import is_local_device +from swift.obj.ssync_sender import Sender as ssync_sender +from swift.common.http import HTTP_OK, HTTP_INSUFFICIENT_STORAGE +from swift.obj.diskfile import DiskFileRouter, get_data_dir, \ + get_tmp_dir +from swift.common.storage_policy import POLICIES, EC_POLICY +from swift.common.exceptions import ConnectionTimeout, DiskFileError, \ + SuffixSyncError + +SYNC, REVERT = ('sync_only', 'sync_revert') + + +hubs.use_hub(get_hub()) + + +def _get_partners(frag_index, part_nodes): + """ + Returns the left and right partners of the node whose index is + equal to the given frag_index. + + :param frag_index: a fragment index + :param part_nodes: a list of primary nodes + :returns: [, ] + """ + return [ + part_nodes[(frag_index - 1) % len(part_nodes)], + part_nodes[(frag_index + 1) % len(part_nodes)], + ] + + +class RebuildingECDiskFileStream(object): + """ + This class wraps the the reconstructed fragment archive data and + metadata in the DiskFile interface for ssync. + """ + + def __init__(self, metadata, frag_index, rebuilt_fragment_iter): + # start with metadata from a participating FA + self.metadata = metadata + + # the new FA is going to have the same length as others in the set + self._content_length = self.metadata['Content-Length'] + + # update the FI and delete the ETag, the obj server will + # recalc on the other side... + self.metadata['X-Object-Sysmeta-Ec-Frag-Index'] = frag_index + for etag_key in ('ETag', 'Etag'): + self.metadata.pop(etag_key, None) + + self.frag_index = frag_index + self.rebuilt_fragment_iter = rebuilt_fragment_iter + + def get_metadata(self): + return self.metadata + + @property + def content_length(self): + return self._content_length + + def reader(self): + for chunk in self.rebuilt_fragment_iter: + yield chunk + + +class ObjectReconstructor(Daemon): + """ + Reconstruct objects using erasure code. And also rebalance EC Fragment + Archive objects off handoff nodes. + + Encapsulates most logic and data needed by the object reconstruction + process. Each call to .reconstruct() performs one pass. It's up to the + caller to do this in a loop. + """ + + def __init__(self, conf, logger=None): + """ + :param conf: configuration object obtained from ConfigParser + :param logger: logging object + """ + self.conf = conf + self.logger = logger or get_logger( + conf, log_route='object-reconstructor') + self.devices_dir = conf.get('devices', '/srv/node') + self.mount_check = config_true_value(conf.get('mount_check', 'true')) + self.swift_dir = conf.get('swift_dir', '/etc/swift') + self.port = int(conf.get('bind_port', 6000)) + self.concurrency = int(conf.get('concurrency', 1)) + self.stats_interval = int(conf.get('stats_interval', '300')) + self.ring_check_interval = int(conf.get('ring_check_interval', 15)) + self.next_check = time.time() + self.ring_check_interval + self.reclaim_age = int(conf.get('reclaim_age', 86400 * 7)) + self.partition_times = [] + self.run_pause = int(conf.get('run_pause', 30)) + self.http_timeout = int(conf.get('http_timeout', 60)) + self.lockup_timeout = int(conf.get('lockup_timeout', 1800)) + self.recon_cache_path = conf.get('recon_cache_path', + '/var/cache/swift') + self.rcache = os.path.join(self.recon_cache_path, "object.recon") + # defaults subject to change after beta + self.conn_timeout = float(conf.get('conn_timeout', 0.5)) + self.node_timeout = float(conf.get('node_timeout', 10)) + self.network_chunk_size = int(conf.get('network_chunk_size', 65536)) + self.disk_chunk_size = int(conf.get('disk_chunk_size', 65536)) + self.headers = { + 'Content-Length': '0', + 'user-agent': 'obj-reconstructor %s' % os.getpid()} + self.handoffs_first = config_true_value(conf.get('handoffs_first', + False)) + self._df_router = DiskFileRouter(conf, self.logger) + + def load_object_ring(self, policy): + """ + Make sure the policy's rings are loaded. + + :param policy: the StoragePolicy instance + :returns: appropriate ring object + """ + policy.load_ring(self.swift_dir) + return policy.object_ring + + def check_ring(self, object_ring): + """ + Check to see if the ring has been updated + + :param object_ring: the ring to check + :returns: boolean indicating whether or not the ring has changed + """ + if time.time() > self.next_check: + self.next_check = time.time() + self.ring_check_interval + if object_ring.has_changed(): + return False + return True + + def _full_path(self, node, part, path, policy): + return '%(replication_ip)s:%(replication_port)s' \ + '/%(device)s/%(part)s%(path)s ' \ + 'policy#%(policy)d frag#%(frag_index)s' % { + 'replication_ip': node['replication_ip'], + 'replication_port': node['replication_port'], + 'device': node['device'], + 'part': part, 'path': path, + 'policy': policy, + 'frag_index': node.get('index', 'handoff'), + } + + def _get_response(self, node, part, path, headers, policy): + """ + Helper method for reconstruction that GETs a single EC fragment + archive + + :param node: the node to GET from + :param part: the partition + :param path: full path of the desired EC archive + :param headers: the headers to send + :param policy: an instance of + :class:`~swift.common.storage_policy.BaseStoragePolicy` + :returns: response + """ + resp = None + headers['X-Backend-Node-Index'] = node['index'] + try: + with ConnectionTimeout(self.conn_timeout): + conn = http_connect(node['ip'], node['port'], node['device'], + part, 'GET', path, headers=headers) + with Timeout(self.node_timeout): + resp = conn.getresponse() + if resp.status != HTTP_OK: + self.logger.warning( + _("Invalid response %(resp)s from %(full_path)s"), + {'resp': resp.status, + 'full_path': self._full_path(node, part, path, policy)}) + resp = None + except (Exception, Timeout): + self.logger.exception( + _("Trying to GET %(full_path)s"), { + 'full_path': self._full_path(node, part, path, policy)}) + return resp + + def reconstruct_fa(self, job, node, metadata): + """ + Reconstructs a fragment archive - this method is called from ssync + after a remote node responds that is missing this object - the local + diskfile is opened to provide metadata - but to reconstruct the + missing fragment archive we must connect to multiple object servers. + + :param job: job from ssync_sender + :param node: node that we're rebuilding to + :param metadata: the metadata to attach to the rebuilt archive + :returns: a DiskFile like class for use by ssync + :raises DiskFileError: if the fragment archive cannot be reconstructed + """ + + part_nodes = job['policy'].object_ring.get_part_nodes( + job['partition']) + part_nodes.remove(node) + + # the fragment index we need to reconstruct is the position index + # of the node we're rebuilding to within the primary part list + fi_to_rebuild = node['index'] + + # KISS send out connection requests to all nodes, see what sticks + headers = { + 'X-Backend-Storage-Policy-Index': int(job['policy']), + } + pile = GreenAsyncPile(len(part_nodes)) + path = metadata['name'] + for node in part_nodes: + pile.spawn(self._get_response, node, job['partition'], + path, headers, job['policy']) + responses = [] + etag = None + for resp in pile: + if not resp: + continue + resp.headers = HeaderKeyDict(resp.getheaders()) + responses.append(resp) + etag = sorted(responses, reverse=True, + key=lambda r: Timestamp( + r.headers.get('X-Backend-Timestamp') + ))[0].headers.get('X-Object-Sysmeta-Ec-Etag') + responses = [r for r in responses if + r.headers.get('X-Object-Sysmeta-Ec-Etag') == etag] + + if len(responses) >= job['policy'].ec_ndata: + break + else: + self.logger.error( + 'Unable to get enough responses (%s/%s) ' + 'to reconstruct %s with ETag %s' % ( + len(responses), job['policy'].ec_ndata, + self._full_path(node, job['partition'], + metadata['name'], job['policy']), + etag)) + raise DiskFileError('Unable to reconstruct EC archive') + + rebuilt_fragment_iter = self.make_rebuilt_fragment_iter( + responses[:job['policy'].ec_ndata], path, job['policy'], + fi_to_rebuild) + return RebuildingECDiskFileStream(metadata, fi_to_rebuild, + rebuilt_fragment_iter) + + def _reconstruct(self, policy, fragment_payload, frag_index): + # XXX with jerasure this doesn't work if we need to rebuild a + # parity fragment, and not all data fragments are available + # segment = policy.pyeclib_driver.reconstruct( + # fragment_payload, [frag_index])[0] + + # for safety until pyeclib 1.0.7 we'll just use decode and encode + segment = policy.pyeclib_driver.decode(fragment_payload) + return policy.pyeclib_driver.encode(segment)[frag_index] + + def make_rebuilt_fragment_iter(self, responses, path, policy, frag_index): + """ + Turn a set of connections from backend object servers into a generator + that yields up the rebuilt fragment archive for frag_index. + """ + + def _get_one_fragment(resp): + buff = '' + remaining_bytes = policy.fragment_size + while remaining_bytes: + chunk = resp.read(remaining_bytes) + if not chunk: + break + remaining_bytes -= len(chunk) + buff += chunk + return buff + + def fragment_payload_iter(): + # We need a fragment from each connections, so best to + # use a GreenPile to keep them ordered and in sync + pile = GreenPile(len(responses)) + while True: + for resp in responses: + pile.spawn(_get_one_fragment, resp) + try: + with Timeout(self.node_timeout): + fragment_payload = [fragment for fragment in pile] + except (Exception, Timeout): + self.logger.exception( + _("Error trying to rebuild %(path)s " + "policy#%(policy)d frag#%(frag_index)s"), { + 'path': path, + 'policy': policy, + 'frag_index': frag_index, + }) + break + if not all(fragment_payload): + break + rebuilt_fragment = self._reconstruct( + policy, fragment_payload, frag_index) + yield rebuilt_fragment + + return fragment_payload_iter() + + def stats_line(self): + """ + Logs various stats for the currently running reconstruction pass. + """ + if self.reconstruction_count: + elapsed = (time.time() - self.start) or 0.000001 + rate = self.reconstruction_count / elapsed + self.logger.info( + _("%(reconstructed)d/%(total)d (%(percentage).2f%%)" + " partitions reconstructed in %(time).2fs (%(rate).2f/sec, " + "%(remaining)s remaining)"), + {'reconstructed': self.reconstruction_count, + 'total': self.job_count, + 'percentage': + self.reconstruction_count * 100.0 / self.job_count, + 'time': time.time() - self.start, 'rate': rate, + 'remaining': '%d%s' % compute_eta(self.start, + self.reconstruction_count, + self.job_count)}) + if self.suffix_count: + self.logger.info( + _("%(checked)d suffixes checked - " + "%(hashed).2f%% hashed, %(synced).2f%% synced"), + {'checked': self.suffix_count, + 'hashed': (self.suffix_hash * 100.0) / self.suffix_count, + 'synced': (self.suffix_sync * 100.0) / self.suffix_count}) + self.partition_times.sort() + self.logger.info( + _("Partition times: max %(max).4fs, " + "min %(min).4fs, med %(med).4fs"), + {'max': self.partition_times[-1], + 'min': self.partition_times[0], + 'med': self.partition_times[ + len(self.partition_times) // 2]}) + else: + self.logger.info( + _("Nothing reconstructed for %s seconds."), + (time.time() - self.start)) + + def kill_coros(self): + """Utility function that kills all coroutines currently running.""" + for coro in list(self.run_pool.coroutines_running): + try: + coro.kill(GreenletExit) + except GreenletExit: + pass + + def heartbeat(self): + """ + Loop that runs in the background during reconstruction. It + periodically logs progress. + """ + while True: + sleep(self.stats_interval) + self.stats_line() + + def detect_lockups(self): + """ + In testing, the pool.waitall() call very occasionally failed to return. + This is an attempt to make sure the reconstructor finishes its + reconstruction pass in some eventuality. + """ + while True: + sleep(self.lockup_timeout) + if self.reconstruction_count == self.last_reconstruction_count: + self.logger.error(_("Lockup detected.. killing live coros.")) + self.kill_coros() + self.last_reconstruction_count = self.reconstruction_count + + def _get_hashes(self, policy, path, recalculate=None, do_listdir=False): + df_mgr = self._df_router[policy] + hashed, suffix_hashes = tpool_reraise( + df_mgr._get_hashes, path, recalculate=recalculate, + do_listdir=do_listdir, reclaim_age=self.reclaim_age) + self.logger.update_stats('suffix.hashes', hashed) + return suffix_hashes + + def get_suffix_delta(self, local_suff, local_index, + remote_suff, remote_index): + """ + Compare the local suffix hashes with the remote suffix hashes + for the given local and remote fragment indexes. Return those + suffixes which should be synced. + + :param local_suff: the local suffix hashes (from _get_hashes) + :param local_index: the local fragment index for the job + :param remote_suff: the remote suffix hashes (from remote + REPLICATE request) + :param remote_index: the remote fragment index for the job + + :returns: a list of strings, the suffix dirs to sync + """ + suffixes = [] + for suffix, sub_dict_local in local_suff.iteritems(): + sub_dict_remote = remote_suff.get(suffix, {}) + if (sub_dict_local.get(None) != sub_dict_remote.get(None) or + sub_dict_local.get(local_index) != + sub_dict_remote.get(remote_index)): + suffixes.append(suffix) + return suffixes + + def rehash_remote(self, node, job, suffixes): + try: + with Timeout(self.http_timeout): + conn = http_connect( + node['replication_ip'], node['replication_port'], + node['device'], job['partition'], 'REPLICATE', + '/' + '-'.join(sorted(suffixes)), + headers=self.headers) + conn.getresponse().read() + except (Exception, Timeout): + self.logger.exception( + _("Trying to sync suffixes with %s") % self._full_path( + node, job['partition'], '', job['policy'])) + + def _get_suffixes_to_sync(self, job, node): + """ + For SYNC jobs we need to make a remote REPLICATE request to get + the remote node's current suffix's hashes and then compare to our + local suffix's hashes to decide which suffixes (if any) are out + of sync. + + :param: the job dict, with the keys defined in ``_get_part_jobs`` + :param node: the remote node dict + :returns: a (possibly empty) list of strings, the suffixes to be + synced with the remote node. + """ + # get hashes from the remote node + remote_suffixes = None + try: + with Timeout(self.http_timeout): + resp = http_connect( + node['replication_ip'], node['replication_port'], + node['device'], job['partition'], 'REPLICATE', + '', headers=self.headers).getresponse() + if resp.status == HTTP_INSUFFICIENT_STORAGE: + self.logger.error( + _('%s responded as unmounted'), + self._full_path(node, job['partition'], '', + job['policy'])) + elif resp.status != HTTP_OK: + self.logger.error( + _("Invalid response %(resp)s " + "from %(full_path)s"), { + 'resp': resp.status, + 'full_path': self._full_path( + node, job['partition'], '', + job['policy']) + }) + else: + remote_suffixes = pickle.loads(resp.read()) + except (Exception, Timeout): + # all exceptions are logged here so that our caller can + # safely catch our exception and continue to the next node + # without logging + self.logger.exception('Unable to get remote suffix hashes ' + 'from %r' % self._full_path( + node, job['partition'], '', + job['policy'])) + + if remote_suffixes is None: + raise SuffixSyncError('Unable to get remote suffix hashes') + + suffixes = self.get_suffix_delta(job['hashes'], + job['frag_index'], + remote_suffixes, + node['index']) + # now recalculate local hashes for suffixes that don't + # match so we're comparing the latest + local_suff = self._get_hashes(job['policy'], job['path'], + recalculate=suffixes) + + suffixes = self.get_suffix_delta(local_suff, + job['frag_index'], + remote_suffixes, + node['index']) + + self.suffix_count += len(suffixes) + return suffixes + + def delete_reverted_objs(self, job, objects, frag_index): + """ + For EC we can potentially revert only some of a partition + so we'll delete reverted objects here. Note that we delete + the fragment index of the file we sent to the remote node. + + :param job: the job being processed + :param objects: a dict of objects to be deleted, each entry maps + hash=>timestamp + :param frag_index: (int) the fragment index of data files to be deleted + """ + df_mgr = self._df_router[job['policy']] + for object_hash, timestamp in objects.items(): + try: + df = df_mgr.get_diskfile_from_hash( + job['local_dev']['device'], job['partition'], + object_hash, job['policy'], + frag_index=frag_index) + df.purge(Timestamp(timestamp), frag_index) + except DiskFileError: + continue + + def process_job(self, job): + """ + Sync the local partition with the remote node(s) according to + the parameters of the job. For primary nodes, the SYNC job type + will define both left and right hand sync_to nodes to ssync with + as defined by this primary nodes index in the node list based on + the fragment index found in the partition. For non-primary + nodes (either handoff revert, or rebalance) the REVERT job will + define a single node in sync_to which is the proper/new home for + the fragment index. + + N.B. ring rebalancing can be time consuming and handoff nodes' + fragment indexes do not have a stable order, it's possible to + have more than one REVERT job for a partition, and in some rare + failure conditions there may even also be a SYNC job for the + same partition - but each one will be processed separately + because each job will define a separate list of node(s) to + 'sync_to'. + + :param: the job dict, with the keys defined in ``_get_job_info`` + """ + self.headers['X-Backend-Storage-Policy-Index'] = int(job['policy']) + begin = time.time() + if job['job_type'] == REVERT: + self._revert(job, begin) + else: + self._sync(job, begin) + self.partition_times.append(time.time() - begin) + self.reconstruction_count += 1 + + def _sync(self, job, begin): + """ + Process a SYNC job. + """ + self.logger.increment( + 'partition.update.count.%s' % (job['local_dev']['device'],)) + # after our left and right partners, if there's some sort of + # failure we'll continue onto the remaining primary nodes and + # make sure they're in sync - or potentially rebuild missing + # fragments we find + dest_nodes = itertools.chain( + job['sync_to'], + # I think we could order these based on our index to better + # protect against a broken chain + itertools.ifilter( + lambda n: n['id'] not in (n['id'] for n in job['sync_to']), + job['policy'].object_ring.get_part_nodes(job['partition'])), + ) + syncd_with = 0 + for node in dest_nodes: + if syncd_with >= len(job['sync_to']): + # success! + break + + try: + suffixes = self._get_suffixes_to_sync(job, node) + except SuffixSyncError: + continue + + if not suffixes: + syncd_with += 1 + continue + + # ssync any out-of-sync suffixes with the remote node + success, _ = ssync_sender( + self, node, job, suffixes)() + # let remote end know to rehash it's suffixes + self.rehash_remote(node, job, suffixes) + # update stats for this attempt + self.suffix_sync += len(suffixes) + self.logger.update_stats('suffix.syncs', len(suffixes)) + if success: + syncd_with += 1 + self.logger.timing_since('partition.update.timing', begin) + + def _revert(self, job, begin): + """ + Process a REVERT job. + """ + self.logger.increment( + 'partition.delete.count.%s' % (job['local_dev']['device'],)) + # we'd desperately like to push this partition back to it's + # primary location, but if that node is down, the next best thing + # is one of the handoff locations - which *might* be us already! + dest_nodes = itertools.chain( + job['sync_to'], + job['policy'].object_ring.get_more_nodes(job['partition']), + ) + syncd_with = 0 + reverted_objs = {} + for node in dest_nodes: + if syncd_with >= len(job['sync_to']): + break + if node['id'] == job['local_dev']['id']: + # this is as good a place as any for this data for now + break + success, in_sync_objs = ssync_sender( + self, node, job, job['suffixes'])() + self.rehash_remote(node, job, job['suffixes']) + if success: + syncd_with += 1 + reverted_objs.update(in_sync_objs) + if syncd_with >= len(job['sync_to']): + self.delete_reverted_objs( + job, reverted_objs, job['frag_index']) + self.logger.timing_since('partition.delete.timing', begin) + + def _get_part_jobs(self, local_dev, part_path, partition, policy): + """ + Helper function to build jobs for a partition, this method will + read the suffix hashes and create job dictionaries to describe + the needed work. There will be one job for each fragment index + discovered in the partition. + + For a fragment index which corresponds to this node's ring + index, a job with job_type SYNC will be created to ensure that + the left and right hand primary ring nodes for the part have the + corresponding left and right hand fragment archives. + + A fragment index (or entire partition) for which this node is + not the primary corresponding node, will create job(s) with + job_type REVERT to ensure that fragment archives are pushed to + the correct node and removed from this one. + + A partition may result in multiple jobs. Potentially many + REVERT jobs, and zero or one SYNC job. + + :param local_dev: the local device + :param part_path: full path to partition + :param partition: partition number + :param policy: the policy + + :returns: a list of dicts of job info + """ + # find all the fi's in the part, and which suffixes have them + hashes = self._get_hashes(policy, part_path, do_listdir=True) + non_data_fragment_suffixes = [] + data_fi_to_suffixes = defaultdict(list) + for suffix, fi_hash in hashes.items(): + if not fi_hash: + # this is for sanity and clarity, normally an empty + # suffix would get del'd from the hashes dict, but an + # OSError trying to re-hash the suffix could leave the + # value empty - it will log the exception; but there's + # no way to properly address this suffix at this time. + continue + data_frag_indexes = [f for f in fi_hash if f is not None] + if not data_frag_indexes: + non_data_fragment_suffixes.append(suffix) + else: + for fi in data_frag_indexes: + data_fi_to_suffixes[fi].append(suffix) + + # helper to ensure consistent structure of jobs + def build_job(job_type, frag_index, suffixes, sync_to): + return { + 'job_type': job_type, + 'frag_index': frag_index, + 'suffixes': suffixes, + 'sync_to': sync_to, + 'partition': partition, + 'path': part_path, + 'hashes': hashes, + 'policy': policy, + 'local_dev': local_dev, + # ssync likes to have it handy + 'device': local_dev['device'], + } + + # aggregate jobs for all the fragment index in this part + jobs = [] + + # check the primary nodes - to see if the part belongs here + part_nodes = policy.object_ring.get_part_nodes(partition) + for node in part_nodes: + if node['id'] == local_dev['id']: + # this partition belongs here, we'll need a sync job + frag_index = node['index'] + try: + suffixes = data_fi_to_suffixes.pop(frag_index) + except KeyError: + suffixes = [] + sync_job = build_job( + job_type=SYNC, + frag_index=frag_index, + suffixes=suffixes, + sync_to=_get_partners(frag_index, part_nodes), + ) + # ssync callback to rebuild missing fragment_archives + sync_job['sync_diskfile_builder'] = self.reconstruct_fa + jobs.append(sync_job) + break + + # assign remaining data fragment suffixes to revert jobs + ordered_fis = sorted((len(suffixes), fi) for fi, suffixes + in data_fi_to_suffixes.items()) + for count, fi in ordered_fis: + revert_job = build_job( + job_type=REVERT, + frag_index=fi, + suffixes=data_fi_to_suffixes[fi], + sync_to=[part_nodes[fi]], + ) + jobs.append(revert_job) + + # now we need to assign suffixes that have no data fragments + if non_data_fragment_suffixes: + if jobs: + # the first job will be either the sync_job, or the + # revert_job for the fragment index that is most common + # among the suffixes + jobs[0]['suffixes'].extend(non_data_fragment_suffixes) + else: + # this is an unfortunate situation, we need a revert job to + # push partitions off this node, but none of the suffixes + # have any data fragments to hint at which node would be a + # good candidate to receive the tombstones. + jobs.append(build_job( + job_type=REVERT, + frag_index=None, + suffixes=non_data_fragment_suffixes, + # this is super safe + sync_to=part_nodes, + # something like this would be probably be better + # sync_to=random.sample(part_nodes, 3), + )) + # return a list of jobs for this part + return jobs + + def collect_parts(self, override_devices=None, + override_partitions=None): + """ + Helper for yielding partitions in the top level reconstructor + """ + override_devices = override_devices or [] + override_partitions = override_partitions or [] + ips = whataremyips() + for policy in POLICIES: + if policy.policy_type != EC_POLICY: + continue + self._diskfile_mgr = self._df_router[policy] + self.load_object_ring(policy) + data_dir = get_data_dir(policy) + local_devices = itertools.ifilter( + lambda dev: dev and is_local_device( + ips, self.port, + dev['replication_ip'], dev['replication_port']), + policy.object_ring.devs) + for local_dev in local_devices: + if override_devices and (local_dev['device'] not in + override_devices): + continue + dev_path = join(self.devices_dir, local_dev['device']) + obj_path = join(dev_path, data_dir) + tmp_path = join(dev_path, get_tmp_dir(int(policy))) + if self.mount_check and not ismount(dev_path): + self.logger.warn(_('%s is not mounted'), + local_dev['device']) + continue + unlink_older_than(tmp_path, time.time() - + self.reclaim_age) + if not os.path.exists(obj_path): + try: + mkdirs(obj_path) + except Exception: + self.logger.exception( + 'Unable to create %s' % obj_path) + continue + try: + partitions = os.listdir(obj_path) + except OSError: + self.logger.exception( + 'Unable to list partitions in %r' % obj_path) + continue + for partition in partitions: + part_path = join(obj_path, partition) + if not (partition.isdigit() and + os.path.isdir(part_path)): + self.logger.warning( + 'Unexpected entity in data dir: %r' % part_path) + remove_file(part_path) + continue + partition = int(partition) + if override_partitions and (partition not in + override_partitions): + continue + part_info = { + 'local_dev': local_dev, + 'policy': policy, + 'partition': partition, + 'part_path': part_path, + } + yield part_info + + def build_reconstruction_jobs(self, part_info): + """ + Helper function for collect_jobs to build jobs for reconstruction + using EC style storage policy + """ + jobs = self._get_part_jobs(**part_info) + random.shuffle(jobs) + if self.handoffs_first: + # Move the handoff revert jobs to the front of the list + jobs.sort(key=lambda job: job['job_type'], reverse=True) + self.job_count += len(jobs) + return jobs + + def _reset_stats(self): + self.start = time.time() + self.job_count = 0 + self.suffix_count = 0 + self.suffix_sync = 0 + self.suffix_hash = 0 + self.reconstruction_count = 0 + self.last_reconstruction_count = -1 + + def delete_partition(self, path): + self.logger.info(_("Removing partition: %s"), path) + tpool.execute(shutil.rmtree, path, ignore_errors=True) + + def reconstruct(self, **kwargs): + """Run a reconstruction pass""" + self._reset_stats() + self.partition_times = [] + + stats = spawn(self.heartbeat) + lockup_detector = spawn(self.detect_lockups) + sleep() # Give spawns a cycle + + try: + self.run_pool = GreenPool(size=self.concurrency) + for part_info in self.collect_parts(**kwargs): + if not self.check_ring(part_info['policy'].object_ring): + self.logger.info(_("Ring change detected. Aborting " + "current reconstruction pass.")) + return + jobs = self.build_reconstruction_jobs(part_info) + if not jobs: + # If this part belongs on this node, _get_part_jobs + # will *always* build a sync_job - even if there's + # no suffixes in the partition that needs to sync. + # If there's any suffixes in the partition then our + # job list would have *at least* one revert job. + # Therefore we know this part a) doesn't belong on + # this node and b) doesn't have any suffixes in it. + self.run_pool.spawn(self.delete_partition, + part_info['part_path']) + for job in jobs: + self.run_pool.spawn(self.process_job, job) + with Timeout(self.lockup_timeout): + self.run_pool.waitall() + except (Exception, Timeout): + self.logger.exception(_("Exception in top-level" + "reconstruction loop")) + self.kill_coros() + finally: + stats.kill() + lockup_detector.kill() + self.stats_line() + + def run_once(self, *args, **kwargs): + start = time.time() + self.logger.info(_("Running object reconstructor in script mode.")) + override_devices = list_from_csv(kwargs.get('devices')) + override_partitions = [int(p) for p in + list_from_csv(kwargs.get('partitions'))] + self.reconstruct( + override_devices=override_devices, + override_partitions=override_partitions) + total = (time.time() - start) / 60 + self.logger.info( + _("Object reconstruction complete (once). (%.02f minutes)"), total) + if not (override_partitions or override_devices): + dump_recon_cache({'object_reconstruction_time': total, + 'object_reconstruction_last': time.time()}, + self.rcache, self.logger) + + def run_forever(self, *args, **kwargs): + self.logger.info(_("Starting object reconstructor in daemon mode.")) + # Run the reconstructor continually + while True: + start = time.time() + self.logger.info(_("Starting object reconstruction pass.")) + # Run the reconstructor + self.reconstruct() + total = (time.time() - start) / 60 + self.logger.info( + _("Object reconstruction complete. (%.02f minutes)"), total) + dump_recon_cache({'object_reconstruction_time': total, + 'object_reconstruction_last': time.time()}, + self.rcache, self.logger) + self.logger.debug('reconstruction sleeping for %s seconds.', + self.run_pause) + sleep(self.run_pause) diff --git a/swift/obj/replicator.py b/swift/obj/replicator.py index 5ee32884ca..580d1827e7 100644 --- a/swift/obj/replicator.py +++ b/swift/obj/replicator.py @@ -39,7 +39,7 @@ from swift.common.http import HTTP_OK, HTTP_INSUFFICIENT_STORAGE from swift.obj import ssync_sender from swift.obj.diskfile import (DiskFileManager, get_hashes, get_data_dir, get_tmp_dir) -from swift.common.storage_policy import POLICIES +from swift.common.storage_policy import POLICIES, REPL_POLICY hubs.use_hub(get_hub()) @@ -110,14 +110,15 @@ class ObjectReplicator(Daemon): """ return self.sync_method(node, job, suffixes, *args, **kwargs) - def get_object_ring(self, policy_idx): + def load_object_ring(self, policy): """ - Get the ring object to use to handle a request based on its policy. + Make sure the policy's rings are loaded. - :policy_idx: policy index as defined in swift.conf + :param policy: the StoragePolicy instance :returns: appropriate ring object """ - return POLICIES.get_object_ring(policy_idx, self.swift_dir) + policy.load_ring(self.swift_dir) + return policy.object_ring def _rsync(self, args): """ @@ -170,7 +171,7 @@ class ObjectReplicator(Daemon): sync method in Swift. """ if not os.path.exists(job['path']): - return False, set() + return False, {} args = [ 'rsync', '--recursive', @@ -195,11 +196,11 @@ class ObjectReplicator(Daemon): args.append(spath) had_any = True if not had_any: - return False, set() - data_dir = get_data_dir(job['policy_idx']) + return False, {} + data_dir = get_data_dir(job['policy']) args.append(join(rsync_module, node['device'], data_dir, job['partition'])) - return self._rsync(args) == 0, set() + return self._rsync(args) == 0, {} def ssync(self, node, job, suffixes, remote_check_objs=None): return ssync_sender.Sender( @@ -231,7 +232,7 @@ class ObjectReplicator(Daemon): if len(suff) == 3 and isdir(join(path, suff))] self.replication_count += 1 self.logger.increment('partition.delete.count.%s' % (job['device'],)) - self.headers['X-Backend-Storage-Policy-Index'] = job['policy_idx'] + self.headers['X-Backend-Storage-Policy-Index'] = int(job['policy']) begin = time.time() try: responses = [] @@ -245,8 +246,9 @@ class ObjectReplicator(Daemon): self.conf.get('sync_method', 'rsync') == 'ssync': kwargs['remote_check_objs'] = \ synced_remote_regions[node['region']] - # cand_objs is a list of objects for deletion - success, cand_objs = self.sync( + # candidates is a dict(hash=>timestamp) of objects + # for deletion + success, candidates = self.sync( node, job, suffixes, **kwargs) if success: with Timeout(self.http_timeout): @@ -257,7 +259,8 @@ class ObjectReplicator(Daemon): '/' + '-'.join(suffixes), headers=self.headers) conn.getresponse().read() if node['region'] != job['region']: - synced_remote_regions[node['region']] = cand_objs + synced_remote_regions[node['region']] = \ + candidates.keys() responses.append(success) for region, cand_objs in synced_remote_regions.iteritems(): if delete_objs is None: @@ -314,7 +317,7 @@ class ObjectReplicator(Daemon): """ self.replication_count += 1 self.logger.increment('partition.update.count.%s' % (job['device'],)) - self.headers['X-Backend-Storage-Policy-Index'] = job['policy_idx'] + self.headers['X-Backend-Storage-Policy-Index'] = int(job['policy']) begin = time.time() try: hashed, local_hash = tpool_reraise( @@ -328,7 +331,8 @@ class ObjectReplicator(Daemon): random.shuffle(job['nodes']) nodes = itertools.chain( job['nodes'], - job['object_ring'].get_more_nodes(int(job['partition']))) + job['policy'].object_ring.get_more_nodes( + int(job['partition']))) while attempts_left > 0: # If this throws StopIteration it will be caught way below node = next(nodes) @@ -460,16 +464,15 @@ class ObjectReplicator(Daemon): self.kill_coros() self.last_replication_count = self.replication_count - def process_repl(self, policy, ips, override_devices=None, - override_partitions=None): + def build_replication_jobs(self, policy, ips, override_devices=None, + override_partitions=None): """ Helper function for collect_jobs to build jobs for replication using replication style storage policy """ jobs = [] - obj_ring = self.get_object_ring(policy.idx) - data_dir = get_data_dir(policy.idx) - for local_dev in [dev for dev in obj_ring.devs + data_dir = get_data_dir(policy) + for local_dev in [dev for dev in policy.object_ring.devs if (dev and is_local_device(ips, self.port, @@ -479,7 +482,7 @@ class ObjectReplicator(Daemon): or dev['device'] in override_devices))]: dev_path = join(self.devices_dir, local_dev['device']) obj_path = join(dev_path, data_dir) - tmp_path = join(dev_path, get_tmp_dir(int(policy))) + tmp_path = join(dev_path, get_tmp_dir(policy)) if self.mount_check and not ismount(dev_path): self.logger.warn(_('%s is not mounted'), local_dev['device']) continue @@ -497,7 +500,8 @@ class ObjectReplicator(Daemon): try: job_path = join(obj_path, partition) - part_nodes = obj_ring.get_part_nodes(int(partition)) + part_nodes = policy.object_ring.get_part_nodes( + int(partition)) nodes = [node for node in part_nodes if node['id'] != local_dev['id']] jobs.append( @@ -506,9 +510,8 @@ class ObjectReplicator(Daemon): obj_path=obj_path, nodes=nodes, delete=len(nodes) > len(part_nodes) - 1, - policy_idx=policy.idx, + policy=policy, partition=partition, - object_ring=obj_ring, region=local_dev['region'])) except ValueError: continue @@ -530,13 +533,15 @@ class ObjectReplicator(Daemon): jobs = [] ips = whataremyips() for policy in POLICIES: - if (override_policies is not None - and str(policy.idx) not in override_policies): - continue - # may need to branch here for future policy types - jobs += self.process_repl(policy, ips, - override_devices=override_devices, - override_partitions=override_partitions) + if policy.policy_type == REPL_POLICY: + if (override_policies is not None and + str(policy.idx) not in override_policies): + continue + # ensure rings are loaded for policy + self.load_object_ring(policy) + jobs += self.build_replication_jobs( + policy, ips, override_devices=override_devices, + override_partitions=override_partitions) random.shuffle(jobs) if self.handoffs_first: # Move the handoff parts to the front of the list @@ -569,7 +574,7 @@ class ObjectReplicator(Daemon): if self.mount_check and not ismount(dev_path): self.logger.warn(_('%s is not mounted'), job['device']) continue - if not self.check_ring(job['object_ring']): + if not self.check_ring(job['policy'].object_ring): self.logger.info(_("Ring change detected. Aborting " "current replication pass.")) return diff --git a/swift/obj/server.py b/swift/obj/server.py index ad0f9faeb3..658f207a8d 100644 --- a/swift/obj/server.py +++ b/swift/obj/server.py @@ -16,10 +16,12 @@ """ Object Server for Swift """ import cPickle as pickle +import json import os import multiprocessing import time import traceback +import rfc822 import socket import math from swift import gettext_ as _ @@ -30,7 +32,7 @@ from eventlet import sleep, wsgi, Timeout from swift.common.utils import public, get_logger, \ config_true_value, timing_stats, replication, \ normalize_delete_at_timestamp, get_log_line, Timestamp, \ - get_expirer_container + get_expirer_container, iter_multipart_mime_documents from swift.common.bufferedhttp import http_connect from swift.common.constraints import check_object_creation, \ valid_timestamp, check_utf8 @@ -48,8 +50,35 @@ from swift.common.swob import HTTPAccepted, HTTPBadRequest, HTTPCreated, \ HTTPPreconditionFailed, HTTPRequestTimeout, HTTPUnprocessableEntity, \ HTTPClientDisconnect, HTTPMethodNotAllowed, Request, Response, \ HTTPInsufficientStorage, HTTPForbidden, HTTPException, HeaderKeyDict, \ - HTTPConflict -from swift.obj.diskfile import DATAFILE_SYSTEM_META, DiskFileManager + HTTPConflict, HTTPServerError +from swift.obj.diskfile import DATAFILE_SYSTEM_META, DiskFileRouter + + +def iter_mime_headers_and_bodies(wsgi_input, mime_boundary, read_chunk_size): + mime_documents_iter = iter_multipart_mime_documents( + wsgi_input, mime_boundary, read_chunk_size) + + for file_like in mime_documents_iter: + hdrs = HeaderKeyDict(rfc822.Message(file_like, 0)) + yield (hdrs, file_like) + + +def drain(file_like, read_size, timeout): + """ + Read and discard any bytes from file_like. + + :param file_like: file-like object to read from + :param read_size: how big a chunk to read at a time + :param timeout: how long to wait for a read (use None for no timeout) + + :raises ChunkReadTimeout: if no chunk was read in time + """ + + while True: + with ChunkReadTimeout(timeout): + chunk = file_like.read(read_size) + if not chunk: + break class EventletPlungerString(str): @@ -142,7 +171,7 @@ class ObjectController(BaseStorageServer): # Common on-disk hierarchy shared across account, container and object # servers. - self._diskfile_mgr = DiskFileManager(conf, self.logger) + self._diskfile_router = DiskFileRouter(conf, self.logger) # This is populated by global_conf_callback way below as the semaphore # is shared by all workers. if 'replication_semaphore' in conf: @@ -156,7 +185,7 @@ class ObjectController(BaseStorageServer): conf.get('replication_failure_ratio') or 1.0) def get_diskfile(self, device, partition, account, container, obj, - policy_idx, **kwargs): + policy, **kwargs): """ Utility method for instantiating a DiskFile object supporting a given REST API. @@ -165,11 +194,11 @@ class ObjectController(BaseStorageServer): DiskFile class would simply over-ride this method to provide that behavior. """ - return self._diskfile_mgr.get_diskfile( - device, partition, account, container, obj, policy_idx, **kwargs) + return self._diskfile_router[policy].get_diskfile( + device, partition, account, container, obj, policy, **kwargs) def async_update(self, op, account, container, obj, host, partition, - contdevice, headers_out, objdevice, policy_index): + contdevice, headers_out, objdevice, policy): """ Sends or saves an async update. @@ -183,7 +212,7 @@ class ObjectController(BaseStorageServer): :param headers_out: dictionary of headers to send in the container request :param objdevice: device name that the object is in - :param policy_index: the associated storage policy index + :param policy: the associated BaseStoragePolicy instance """ headers_out['user-agent'] = 'object-server %s' % os.getpid() full_path = '/%s/%s/%s' % (account, container, obj) @@ -213,12 +242,11 @@ class ObjectController(BaseStorageServer): data = {'op': op, 'account': account, 'container': container, 'obj': obj, 'headers': headers_out} timestamp = headers_out['x-timestamp'] - self._diskfile_mgr.pickle_async_update(objdevice, account, container, - obj, data, timestamp, - policy_index) + self._diskfile_router[policy].pickle_async_update( + objdevice, account, container, obj, data, timestamp, policy) def container_update(self, op, account, container, obj, request, - headers_out, objdevice, policy_idx): + headers_out, objdevice, policy): """ Update the container when objects are updated. @@ -230,6 +258,7 @@ class ObjectController(BaseStorageServer): :param headers_out: dictionary of headers to send in the container request(s) :param objdevice: device name that the object is in + :param policy: the BaseStoragePolicy instance """ headers_in = request.headers conthosts = [h.strip() for h in @@ -255,14 +284,14 @@ class ObjectController(BaseStorageServer): headers_out['x-trans-id'] = headers_in.get('x-trans-id', '-') headers_out['referer'] = request.as_referer() - headers_out['X-Backend-Storage-Policy-Index'] = policy_idx + headers_out['X-Backend-Storage-Policy-Index'] = int(policy) for conthost, contdevice in updates: self.async_update(op, account, container, obj, conthost, contpartition, contdevice, headers_out, - objdevice, policy_idx) + objdevice, policy) def delete_at_update(self, op, delete_at, account, container, obj, - request, objdevice, policy_index): + request, objdevice, policy): """ Update the expiring objects container when objects are updated. @@ -273,7 +302,7 @@ class ObjectController(BaseStorageServer): :param obj: object name :param request: the original request driving the update :param objdevice: device name that the object is in - :param policy_index: the policy index to be used for tmp dir + :param policy: the BaseStoragePolicy instance (used for tmp dir) """ if config_true_value( request.headers.get('x-backend-replication', 'f')): @@ -333,13 +362,66 @@ class ObjectController(BaseStorageServer): op, self.expiring_objects_account, delete_at_container, '%s-%s/%s/%s' % (delete_at, account, container, obj), host, partition, contdevice, headers_out, objdevice, - policy_index) + policy) + + def _make_timeout_reader(self, file_like): + def timeout_reader(): + with ChunkReadTimeout(self.client_timeout): + return file_like.read(self.network_chunk_size) + return timeout_reader + + def _read_put_commit_message(self, mime_documents_iter): + rcvd_commit = False + try: + with ChunkReadTimeout(self.client_timeout): + commit_hdrs, commit_iter = next(mime_documents_iter) + if commit_hdrs.get('X-Document', None) == "put commit": + rcvd_commit = True + drain(commit_iter, self.network_chunk_size, self.client_timeout) + except ChunkReadTimeout: + raise HTTPClientDisconnect() + except StopIteration: + raise HTTPBadRequest(body="couldn't find PUT commit MIME doc") + return rcvd_commit + + def _read_metadata_footer(self, mime_documents_iter): + try: + with ChunkReadTimeout(self.client_timeout): + footer_hdrs, footer_iter = next(mime_documents_iter) + except ChunkReadTimeout: + raise HTTPClientDisconnect() + except StopIteration: + raise HTTPBadRequest(body="couldn't find footer MIME doc") + + timeout_reader = self._make_timeout_reader(footer_iter) + try: + footer_body = ''.join(iter(timeout_reader, '')) + except ChunkReadTimeout: + raise HTTPClientDisconnect() + + footer_md5 = footer_hdrs.get('Content-MD5') + if not footer_md5: + raise HTTPBadRequest(body="no Content-MD5 in footer") + if footer_md5 != md5(footer_body).hexdigest(): + raise HTTPUnprocessableEntity(body="footer MD5 mismatch") + + try: + return HeaderKeyDict(json.loads(footer_body)) + except ValueError: + raise HTTPBadRequest("invalid JSON for footer doc") + + def _check_container_override(self, update_headers, metadata): + for key, val in metadata.iteritems(): + override_prefix = 'x-backend-container-update-override-' + if key.lower().startswith(override_prefix): + override = key.lower().replace(override_prefix, 'x-') + update_headers[override] = val @public @timing_stats() def POST(self, request): """Handle HTTP POST requests for the Swift Object Server.""" - device, partition, account, container, obj, policy_idx = \ + device, partition, account, container, obj, policy = \ get_name_and_placement(request, 5, 5, True) req_timestamp = valid_timestamp(request) new_delete_at = int(request.headers.get('X-Delete-At') or 0) @@ -349,7 +431,7 @@ class ObjectController(BaseStorageServer): try: disk_file = self.get_diskfile( device, partition, account, container, obj, - policy_idx=policy_idx) + policy=policy) except DiskFileDeviceUnavailable: return HTTPInsufficientStorage(drive=device, request=request) try: @@ -374,11 +456,11 @@ class ObjectController(BaseStorageServer): if orig_delete_at != new_delete_at: if new_delete_at: self.delete_at_update('PUT', new_delete_at, account, container, - obj, request, device, policy_idx) + obj, request, device, policy) if orig_delete_at: self.delete_at_update('DELETE', orig_delete_at, account, container, obj, request, device, - policy_idx) + policy) try: disk_file.write_metadata(metadata) except (DiskFileXattrNotSupported, DiskFileNoSpace): @@ -389,7 +471,7 @@ class ObjectController(BaseStorageServer): @timing_stats() def PUT(self, request): """Handle HTTP PUT requests for the Swift Object Server.""" - device, partition, account, container, obj, policy_idx = \ + device, partition, account, container, obj, policy = \ get_name_and_placement(request, 5, 5, True) req_timestamp = valid_timestamp(request) error_response = check_object_creation(request, obj) @@ -404,10 +486,22 @@ class ObjectController(BaseStorageServer): except ValueError as e: return HTTPBadRequest(body=str(e), request=request, content_type='text/plain') + + # In case of multipart-MIME put, the proxy sends a chunked request, + # but may let us know the real content length so we can verify that + # we have enough disk space to hold the object. + if fsize is None: + fsize = request.headers.get('X-Backend-Obj-Content-Length') + if fsize is not None: + try: + fsize = int(fsize) + except ValueError as e: + return HTTPBadRequest(body=str(e), request=request, + content_type='text/plain') try: disk_file = self.get_diskfile( device, partition, account, container, obj, - policy_idx=policy_idx) + policy=policy) except DiskFileDeviceUnavailable: return HTTPInsufficientStorage(drive=device, request=request) try: @@ -439,13 +533,51 @@ class ObjectController(BaseStorageServer): with disk_file.create(size=fsize) as writer: upload_size = 0 - def timeout_reader(): - with ChunkReadTimeout(self.client_timeout): - return request.environ['wsgi.input'].read( - self.network_chunk_size) + # If the proxy wants to send us object metadata after the + # object body, it sets some headers. We have to tell the + # proxy, in the 100 Continue response, that we're able to + # parse a multipart MIME document and extract the object and + # metadata from it. If we don't, then the proxy won't + # actually send the footer metadata. + have_metadata_footer = False + use_multiphase_commit = False + mime_documents_iter = iter([]) + obj_input = request.environ['wsgi.input'] + hundred_continue_headers = [] + if config_true_value( + request.headers.get( + 'X-Backend-Obj-Multiphase-Commit')): + use_multiphase_commit = True + hundred_continue_headers.append( + ('X-Obj-Multiphase-Commit', 'yes')) + + if config_true_value( + request.headers.get('X-Backend-Obj-Metadata-Footer')): + have_metadata_footer = True + hundred_continue_headers.append( + ('X-Obj-Metadata-Footer', 'yes')) + + if have_metadata_footer or use_multiphase_commit: + obj_input.set_hundred_continue_response_headers( + hundred_continue_headers) + mime_boundary = request.headers.get( + 'X-Backend-Obj-Multipart-Mime-Boundary') + if not mime_boundary: + return HTTPBadRequest("no MIME boundary") + + try: + with ChunkReadTimeout(self.client_timeout): + mime_documents_iter = iter_mime_headers_and_bodies( + request.environ['wsgi.input'], + mime_boundary, self.network_chunk_size) + _junk_hdrs, obj_input = next(mime_documents_iter) + except ChunkReadTimeout: + return HTTPRequestTimeout(request=request) + + timeout_reader = self._make_timeout_reader(obj_input) try: - for chunk in iter(lambda: timeout_reader(), ''): + for chunk in iter(timeout_reader, ''): start_time = time.time() if start_time > upload_expiration: self.logger.increment('PUT.timeouts') @@ -461,9 +593,16 @@ class ObjectController(BaseStorageServer): upload_size) if fsize is not None and fsize != upload_size: return HTTPClientDisconnect(request=request) + + footer_meta = {} + if have_metadata_footer: + footer_meta = self._read_metadata_footer( + mime_documents_iter) + + request_etag = (footer_meta.get('etag') or + request.headers.get('etag', '')).lower() etag = etag.hexdigest() - if 'etag' in request.headers and \ - request.headers['etag'].lower() != etag: + if request_etag and request_etag != etag: return HTTPUnprocessableEntity(request=request) metadata = { 'X-Timestamp': request.timestamp.internal, @@ -473,6 +612,8 @@ class ObjectController(BaseStorageServer): } metadata.update(val for val in request.headers.iteritems() if is_sys_or_user_meta('object', val[0])) + metadata.update(val for val in footer_meta.iteritems() + if is_sys_or_user_meta('object', val[0])) headers_to_copy = ( request.headers.get( 'X-Backend-Replication-Headers', '').split() + @@ -482,39 +623,63 @@ class ObjectController(BaseStorageServer): header_caps = header_key.title() metadata[header_caps] = request.headers[header_key] writer.put(metadata) + + # if the PUT requires a two-phase commit (a data and a commit + # phase) send the proxy server another 100-continue response + # to indicate that we are finished writing object data + if use_multiphase_commit: + request.environ['wsgi.input'].\ + send_hundred_continue_response() + if not self._read_put_commit_message(mime_documents_iter): + return HTTPServerError(request=request) + # got 2nd phase confirmation, write a timestamp.durable + # state file to indicate a successful PUT + + writer.commit(request.timestamp) + + # Drain any remaining MIME docs from the socket. There + # shouldn't be any, but we must read the whole request body. + try: + while True: + with ChunkReadTimeout(self.client_timeout): + _junk_hdrs, _junk_body = next(mime_documents_iter) + drain(_junk_body, self.network_chunk_size, + self.client_timeout) + except ChunkReadTimeout: + raise HTTPClientDisconnect() + except StopIteration: + pass + except (DiskFileXattrNotSupported, DiskFileNoSpace): return HTTPInsufficientStorage(drive=device, request=request) if orig_delete_at != new_delete_at: if new_delete_at: self.delete_at_update( 'PUT', new_delete_at, account, container, obj, request, - device, policy_idx) + device, policy) if orig_delete_at: self.delete_at_update( 'DELETE', orig_delete_at, account, container, obj, - request, device, policy_idx) + request, device, policy) update_headers = HeaderKeyDict({ 'x-size': metadata['Content-Length'], 'x-content-type': metadata['Content-Type'], 'x-timestamp': metadata['X-Timestamp'], 'x-etag': metadata['ETag']}) # apply any container update header overrides sent with request - for key, val in request.headers.iteritems(): - override_prefix = 'x-backend-container-update-override-' - if key.lower().startswith(override_prefix): - override = key.lower().replace(override_prefix, 'x-') - update_headers[override] = val + self._check_container_override(update_headers, request.headers) + self._check_container_override(update_headers, footer_meta) self.container_update( 'PUT', account, container, obj, request, update_headers, - device, policy_idx) + device, policy) return HTTPCreated(request=request, etag=etag) @public @timing_stats() def GET(self, request): """Handle HTTP GET requests for the Swift Object Server.""" - device, partition, account, container, obj, policy_idx = \ + device, partition, account, container, obj, policy = \ get_name_and_placement(request, 5, 5, True) keep_cache = self.keep_cache_private or ( 'X-Auth-Token' not in request.headers and @@ -522,7 +687,7 @@ class ObjectController(BaseStorageServer): try: disk_file = self.get_diskfile( device, partition, account, container, obj, - policy_idx=policy_idx) + policy=policy) except DiskFileDeviceUnavailable: return HTTPInsufficientStorage(drive=device, request=request) try: @@ -533,9 +698,14 @@ class ObjectController(BaseStorageServer): keep_cache = (self.keep_cache_private or ('X-Auth-Token' not in request.headers and 'X-Storage-Token' not in request.headers)) + conditional_etag = None + if 'X-Backend-Etag-Is-At' in request.headers: + conditional_etag = metadata.get( + request.headers['X-Backend-Etag-Is-At']) response = Response( app_iter=disk_file.reader(keep_cache=keep_cache), - request=request, conditional_response=True) + request=request, conditional_response=True, + conditional_etag=conditional_etag) response.headers['Content-Type'] = metadata.get( 'Content-Type', 'application/octet-stream') for key, value in metadata.iteritems(): @@ -567,12 +737,12 @@ class ObjectController(BaseStorageServer): @timing_stats(sample_rate=0.8) def HEAD(self, request): """Handle HTTP HEAD requests for the Swift Object Server.""" - device, partition, account, container, obj, policy_idx = \ + device, partition, account, container, obj, policy = \ get_name_and_placement(request, 5, 5, True) try: disk_file = self.get_diskfile( device, partition, account, container, obj, - policy_idx=policy_idx) + policy=policy) except DiskFileDeviceUnavailable: return HTTPInsufficientStorage(drive=device, request=request) try: @@ -585,7 +755,12 @@ class ObjectController(BaseStorageServer): headers['X-Backend-Timestamp'] = e.timestamp.internal return HTTPNotFound(request=request, headers=headers, conditional_response=True) - response = Response(request=request, conditional_response=True) + conditional_etag = None + if 'X-Backend-Etag-Is-At' in request.headers: + conditional_etag = metadata.get( + request.headers['X-Backend-Etag-Is-At']) + response = Response(request=request, conditional_response=True, + conditional_etag=conditional_etag) response.headers['Content-Type'] = metadata.get( 'Content-Type', 'application/octet-stream') for key, value in metadata.iteritems(): @@ -609,13 +784,13 @@ class ObjectController(BaseStorageServer): @timing_stats() def DELETE(self, request): """Handle HTTP DELETE requests for the Swift Object Server.""" - device, partition, account, container, obj, policy_idx = \ + device, partition, account, container, obj, policy = \ get_name_and_placement(request, 5, 5, True) req_timestamp = valid_timestamp(request) try: disk_file = self.get_diskfile( device, partition, account, container, obj, - policy_idx=policy_idx) + policy=policy) except DiskFileDeviceUnavailable: return HTTPInsufficientStorage(drive=device, request=request) try: @@ -667,13 +842,13 @@ class ObjectController(BaseStorageServer): if orig_delete_at: self.delete_at_update('DELETE', orig_delete_at, account, container, obj, request, device, - policy_idx) + policy) if orig_timestamp < req_timestamp: disk_file.delete(req_timestamp) self.container_update( 'DELETE', account, container, obj, request, HeaderKeyDict({'x-timestamp': req_timestamp.internal}), - device, policy_idx) + device, policy) return response_class( request=request, headers={'X-Backend-Timestamp': response_timestamp.internal}) @@ -685,12 +860,17 @@ class ObjectController(BaseStorageServer): """ Handle REPLICATE requests for the Swift Object Server. This is used by the object replicator to get hashes for directories. + + Note that the name REPLICATE is preserved for historical reasons as + this verb really just returns the hashes information for the specified + parameters and is used, for example, by both replication and EC. """ - device, partition, suffix, policy_idx = \ + device, partition, suffix_parts, policy = \ get_name_and_placement(request, 2, 3, True) + suffixes = suffix_parts.split('-') if suffix_parts else [] try: - hashes = self._diskfile_mgr.get_hashes(device, partition, suffix, - policy_idx) + hashes = self._diskfile_router[policy].get_hashes( + device, partition, suffixes, policy) except DiskFileDeviceUnavailable: resp = HTTPInsufficientStorage(drive=device, request=request) else: @@ -700,7 +880,7 @@ class ObjectController(BaseStorageServer): @public @replication @timing_stats(sample_rate=0.1) - def REPLICATION(self, request): + def SSYNC(self, request): return Response(app_iter=ssync_receiver.Receiver(self, request)()) def __call__(self, env, start_response): @@ -734,7 +914,7 @@ class ObjectController(BaseStorageServer): trans_time = time.time() - start_time if self.log_requests: log_line = get_log_line(req, res, trans_time, '') - if req.method in ('REPLICATE', 'REPLICATION') or \ + if req.method in ('REPLICATE', 'SSYNC') or \ 'X-Backend-Replication' in req.headers: self.logger.debug(log_line) else: diff --git a/swift/obj/ssync_receiver.py b/swift/obj/ssync_receiver.py index 248715d006..b636a16245 100644 --- a/swift/obj/ssync_receiver.py +++ b/swift/obj/ssync_receiver.py @@ -24,27 +24,28 @@ from swift.common import exceptions from swift.common import http from swift.common import swob from swift.common import utils +from swift.common import request_helpers class Receiver(object): """ - Handles incoming REPLICATION requests to the object server. + Handles incoming SSYNC requests to the object server. These requests come from the object-replicator daemon that uses :py:mod:`.ssync_sender`. - The number of concurrent REPLICATION requests is restricted by + The number of concurrent SSYNC requests is restricted by use of a replication_semaphore and can be configured with the object-server.conf [object-server] replication_concurrency setting. - A REPLICATION request is really just an HTTP conduit for + An SSYNC request is really just an HTTP conduit for sender/receiver replication communication. The overall - REPLICATION request should always succeed, but it will contain + SSYNC request should always succeed, but it will contain multiple requests within its request and response bodies. This "hack" is done so that replication concurrency can be managed. - The general process inside a REPLICATION request is: + The general process inside an SSYNC request is: 1. Initialize the request: Basic request validation, mount check, acquire semaphore lock, etc.. @@ -72,10 +73,10 @@ class Receiver(object): def __call__(self): """ - Processes a REPLICATION request. + Processes an SSYNC request. Acquires a semaphore lock and then proceeds through the steps - of the REPLICATION process. + of the SSYNC process. """ # The general theme for functions __call__ calls is that they should # raise exceptions.MessageTimeout for client timeouts (logged locally), @@ -88,7 +89,7 @@ class Receiver(object): try: # Double try blocks in case our main error handlers fail. try: - # intialize_request is for preamble items that can be done + # initialize_request is for preamble items that can be done # outside a replication semaphore lock. for data in self.initialize_request(): yield data @@ -98,7 +99,7 @@ class Receiver(object): if not self.app.replication_semaphore.acquire(False): raise swob.HTTPServiceUnavailable() try: - with self.app._diskfile_mgr.replication_lock(self.device): + with self.diskfile_mgr.replication_lock(self.device): for data in self.missing_check(): yield data for data in self.updates(): @@ -111,7 +112,7 @@ class Receiver(object): self.app.replication_semaphore.release() except exceptions.ReplicationLockTimeout as err: self.app.logger.debug( - '%s/%s/%s REPLICATION LOCK TIMEOUT: %s' % ( + '%s/%s/%s SSYNC LOCK TIMEOUT: %s' % ( self.request.remote_addr, self.device, self.partition, err)) yield ':ERROR: %d %r\n' % (0, str(err)) @@ -166,14 +167,17 @@ class Receiver(object): """ # The following is the setting we talk about above in _ensure_flush. self.request.environ['eventlet.minimum_write_chunk_size'] = 0 - self.device, self.partition = utils.split_path( - urllib.unquote(self.request.path), 2, 2, False) - self.policy_idx = \ - int(self.request.headers.get('X-Backend-Storage-Policy-Index', 0)) + self.device, self.partition, self.policy = \ + request_helpers.get_name_and_placement(self.request, 2, 2, False) + if 'X-Backend-Ssync-Frag-Index' in self.request.headers: + self.frag_index = int( + self.request.headers['X-Backend-Ssync-Frag-Index']) + else: + self.frag_index = None utils.validate_device_partition(self.device, self.partition) - if self.app._diskfile_mgr.mount_check and \ - not constraints.check_mount( - self.app._diskfile_mgr.devices, self.device): + self.diskfile_mgr = self.app._diskfile_router[self.policy] + if self.diskfile_mgr.mount_check and not constraints.check_mount( + self.diskfile_mgr.devices, self.device): raise swob.HTTPInsufficientStorage(drive=self.device) self.fp = self.request.environ['wsgi.input'] for data in self._ensure_flush(): @@ -182,7 +186,7 @@ class Receiver(object): def missing_check(self): """ Handles the receiver-side of the MISSING_CHECK step of a - REPLICATION request. + SSYNC request. Receives a list of hashes and timestamps of object information the sender can provide and responds with a list @@ -226,11 +230,13 @@ class Receiver(object): line = self.fp.readline(self.app.network_chunk_size) if not line or line.strip() == ':MISSING_CHECK: END': break - object_hash, timestamp = [urllib.unquote(v) for v in line.split()] + parts = line.split() + object_hash, timestamp = [urllib.unquote(v) for v in parts[:2]] want = False try: - df = self.app._diskfile_mgr.get_diskfile_from_hash( - self.device, self.partition, object_hash, self.policy_idx) + df = self.diskfile_mgr.get_diskfile_from_hash( + self.device, self.partition, object_hash, self.policy, + frag_index=self.frag_index) except exceptions.DiskFileNotExist: want = True else: @@ -253,7 +259,7 @@ class Receiver(object): def updates(self): """ - Handles the UPDATES step of a REPLICATION request. + Handles the UPDATES step of an SSYNC request. Receives a set of PUT and DELETE subrequests that will be routed to the object server itself for processing. These @@ -353,7 +359,7 @@ class Receiver(object): subreq_iter()) else: raise Exception('Invalid subrequest method %s' % method) - subreq.headers['X-Backend-Storage-Policy-Index'] = self.policy_idx + subreq.headers['X-Backend-Storage-Policy-Index'] = int(self.policy) subreq.headers['X-Backend-Replication'] = 'True' if replication_headers: subreq.headers['X-Backend-Replication-Headers'] = \ diff --git a/swift/obj/ssync_sender.py b/swift/obj/ssync_sender.py index 1058ab262d..8e9202c004 100644 --- a/swift/obj/ssync_sender.py +++ b/swift/obj/ssync_sender.py @@ -22,7 +22,7 @@ from swift.common import http class Sender(object): """ - Sends REPLICATION requests to the object server. + Sends SSYNC requests to the object server. These requests are eventually handled by :py:mod:`.ssync_receiver` and full documentation about the @@ -31,6 +31,7 @@ class Sender(object): def __init__(self, daemon, node, job, suffixes, remote_check_objs=None): self.daemon = daemon + self.df_mgr = self.daemon._diskfile_mgr self.node = node self.job = job self.suffixes = suffixes @@ -38,28 +39,28 @@ class Sender(object): self.response = None self.response_buffer = '' self.response_chunk_left = 0 - self.available_set = set() + # available_map has an entry for each object in given suffixes that + # is available to be sync'd; each entry is a hash => timestamp + self.available_map = {} # When remote_check_objs is given in job, ssync_sender trys only to # make sure those objects exist or not in remote. self.remote_check_objs = remote_check_objs + # send_list has an entry for each object that the receiver wants to + # be sync'ed; each entry is an object hash self.send_list = [] self.failures = 0 - @property - def policy_idx(self): - return int(self.job.get('policy_idx', 0)) - def __call__(self): """ Perform ssync with remote node. - :returns: a 2-tuple, in the form (success, can_delete_objs). - - Success is a boolean, and can_delete_objs is an iterable of strings - representing the hashes which are in sync with the remote node. + :returns: a 2-tuple, in the form (success, can_delete_objs) where + success is a boolean and can_delete_objs is the map of + objects that are in sync with the receiver. Each entry in + can_delete_objs maps a hash => timestamp """ if not self.suffixes: - return True, set() + return True, {} try: # Double try blocks in case our main error handler fails. try: @@ -72,18 +73,20 @@ class Sender(object): self.missing_check() if self.remote_check_objs is None: self.updates() - can_delete_obj = self.available_set + can_delete_obj = self.available_map else: # when we are initialized with remote_check_objs we don't # *send* any requested updates; instead we only collect # what's already in sync and safe for deletion - can_delete_obj = self.available_set.difference( - self.send_list) + in_sync_hashes = (set(self.available_map.keys()) - + set(self.send_list)) + can_delete_obj = dict((hash_, self.available_map[hash_]) + for hash_ in in_sync_hashes) self.disconnect() if not self.failures: return True, can_delete_obj else: - return False, set() + return False, {} except (exceptions.MessageTimeout, exceptions.ReplicationException) as err: self.daemon.logger.error( @@ -109,11 +112,11 @@ class Sender(object): # would only get called if the above except Exception handler # failed (bad node or job data). self.daemon.logger.exception('EXCEPTION in replication.Sender') - return False, set() + return False, {} def connect(self): """ - Establishes a connection and starts a REPLICATION request + Establishes a connection and starts an SSYNC request with the object server. """ with exceptions.MessageTimeout( @@ -121,11 +124,13 @@ class Sender(object): self.connection = bufferedhttp.BufferedHTTPConnection( '%s:%s' % (self.node['replication_ip'], self.node['replication_port'])) - self.connection.putrequest('REPLICATION', '/%s/%s' % ( + self.connection.putrequest('SSYNC', '/%s/%s' % ( self.node['device'], self.job['partition'])) self.connection.putheader('Transfer-Encoding', 'chunked') self.connection.putheader('X-Backend-Storage-Policy-Index', - self.policy_idx) + int(self.job['policy'])) + self.connection.putheader('X-Backend-Ssync-Frag-Index', + self.node['index']) self.connection.endheaders() with exceptions.MessageTimeout( self.daemon.node_timeout, 'connect receive'): @@ -137,7 +142,7 @@ class Sender(object): def readline(self): """ - Reads a line from the REPLICATION response body. + Reads a line from the SSYNC response body. httplib has no readline and will block on read(x) until x is read, so we have to do the work ourselves. A bit of this is @@ -183,7 +188,7 @@ class Sender(object): def missing_check(self): """ Handles the sender-side of the MISSING_CHECK step of a - REPLICATION request. + SSYNC request. Full documentation of this can be found at :py:meth:`.Receiver.missing_check`. @@ -193,14 +198,15 @@ class Sender(object): self.daemon.node_timeout, 'missing_check start'): msg = ':MISSING_CHECK: START\r\n' self.connection.send('%x\r\n%s\r\n' % (len(msg), msg)) - hash_gen = self.daemon._diskfile_mgr.yield_hashes( + hash_gen = self.df_mgr.yield_hashes( self.job['device'], self.job['partition'], - self.policy_idx, self.suffixes) + self.job['policy'], self.suffixes, + frag_index=self.job.get('frag_index')) if self.remote_check_objs is not None: hash_gen = ifilter(lambda (path, object_hash, timestamp): object_hash in self.remote_check_objs, hash_gen) for path, object_hash, timestamp in hash_gen: - self.available_set.add(object_hash) + self.available_map[object_hash] = timestamp with exceptions.MessageTimeout( self.daemon.node_timeout, 'missing_check send line'): @@ -234,12 +240,13 @@ class Sender(object): line = line.strip() if line == ':MISSING_CHECK: END': break - if line: - self.send_list.append(line) + parts = line.split() + if parts: + self.send_list.append(parts[0]) def updates(self): """ - Handles the sender-side of the UPDATES step of a REPLICATION + Handles the sender-side of the UPDATES step of an SSYNC request. Full documentation of this can be found at @@ -252,15 +259,19 @@ class Sender(object): self.connection.send('%x\r\n%s\r\n' % (len(msg), msg)) for object_hash in self.send_list: try: - df = self.daemon._diskfile_mgr.get_diskfile_from_hash( + df = self.df_mgr.get_diskfile_from_hash( self.job['device'], self.job['partition'], object_hash, - self.policy_idx) + self.job['policy'], frag_index=self.job.get('frag_index')) except exceptions.DiskFileNotExist: continue url_path = urllib.quote( '/%s/%s/%s' % (df.account, df.container, df.obj)) try: df.open() + # EC reconstructor may have passed a callback to build + # an alternative diskfile... + df = self.job.get('sync_diskfile_builder', lambda *args: df)( + self.job, self.node, df.get_metadata()) except exceptions.DiskFileDeleted as err: self.send_delete(url_path, err.timestamp) except exceptions.DiskFileError: @@ -328,7 +339,7 @@ class Sender(object): def disconnect(self): """ Closes down the connection to the object server once done - with the REPLICATION request. + with the SSYNC request. """ try: with exceptions.MessageTimeout( diff --git a/swift/obj/updater.py b/swift/obj/updater.py index 6c40c456ac..f5d1f37fa4 100644 --- a/swift/obj/updater.py +++ b/swift/obj/updater.py @@ -29,7 +29,8 @@ from swift.common.ring import Ring from swift.common.utils import get_logger, renamer, write_pickle, \ dump_recon_cache, config_true_value, ismount from swift.common.daemon import Daemon -from swift.obj.diskfile import get_tmp_dir, get_async_dir, ASYNCDIR_BASE +from swift.common.storage_policy import split_policy_string, PolicyError +from swift.obj.diskfile import get_tmp_dir, ASYNCDIR_BASE from swift.common.http import is_success, HTTP_NOT_FOUND, \ HTTP_INTERNAL_SERVER_ERROR @@ -148,28 +149,19 @@ class ObjectUpdater(Daemon): start_time = time.time() # loop through async pending dirs for all policies for asyncdir in self._listdir(device): - # skip stuff like "accounts", "containers", etc. - if not (asyncdir == ASYNCDIR_BASE or - asyncdir.startswith(ASYNCDIR_BASE + '-')): - continue - # we only care about directories async_pending = os.path.join(device, asyncdir) if not os.path.isdir(async_pending): continue - - if asyncdir == ASYNCDIR_BASE: - policy_idx = 0 - else: - _junk, policy_idx = asyncdir.split('-', 1) - try: - policy_idx = int(policy_idx) - get_async_dir(policy_idx) - except ValueError: - self.logger.warn(_('Directory %s does not map to a ' - 'valid policy') % asyncdir) - continue - + if not asyncdir.startswith(ASYNCDIR_BASE): + # skip stuff like "accounts", "containers", etc. + continue + try: + base, policy = split_policy_string(asyncdir) + except PolicyError as e: + self.logger.warn(_('Directory %r does not map ' + 'to a valid policy (%s)') % (asyncdir, e)) + continue for prefix in self._listdir(async_pending): prefix_path = os.path.join(async_pending, prefix) if not os.path.isdir(prefix_path): @@ -193,7 +185,7 @@ class ObjectUpdater(Daemon): os.unlink(update_path) else: self.process_object_update(update_path, device, - policy_idx) + policy) last_obj_hash = obj_hash time.sleep(self.slowdown) try: @@ -202,13 +194,13 @@ class ObjectUpdater(Daemon): pass self.logger.timing_since('timing', start_time) - def process_object_update(self, update_path, device, policy_idx): + def process_object_update(self, update_path, device, policy): """ Process the object information to be updated and update. :param update_path: path to pickled object update file :param device: path to device - :param policy_idx: storage policy index of object update + :param policy: storage policy of object update """ try: update = pickle.load(open(update_path, 'rb')) @@ -228,7 +220,7 @@ class ObjectUpdater(Daemon): headers_out = update['headers'].copy() headers_out['user-agent'] = 'object-updater %s' % os.getpid() headers_out.setdefault('X-Backend-Storage-Policy-Index', - str(policy_idx)) + str(int(policy))) events = [spawn(self.object_update, node, part, update['op'], obj, headers_out) for node in nodes if node['id'] not in successes] @@ -256,7 +248,7 @@ class ObjectUpdater(Daemon): if new_successes: update['successes'] = successes write_pickle(update, update_path, os.path.join( - device, get_tmp_dir(policy_idx))) + device, get_tmp_dir(policy))) def object_update(self, node, part, op, obj, headers_out): """ diff --git a/swift/proxy/controllers/__init__.py b/swift/proxy/controllers/__init__.py index de4c0145b0..706fd9165c 100644 --- a/swift/proxy/controllers/__init__.py +++ b/swift/proxy/controllers/__init__.py @@ -13,7 +13,7 @@ from swift.proxy.controllers.base import Controller from swift.proxy.controllers.info import InfoController -from swift.proxy.controllers.obj import ObjectController +from swift.proxy.controllers.obj import ObjectControllerRouter from swift.proxy.controllers.account import AccountController from swift.proxy.controllers.container import ContainerController @@ -22,5 +22,5 @@ __all__ = [ 'ContainerController', 'Controller', 'InfoController', - 'ObjectController', + 'ObjectControllerRouter', ] diff --git a/swift/proxy/controllers/account.py b/swift/proxy/controllers/account.py index ea2f8ae33a..915e1c481c 100644 --- a/swift/proxy/controllers/account.py +++ b/swift/proxy/controllers/account.py @@ -58,9 +58,10 @@ class AccountController(Controller): constraints.MAX_ACCOUNT_NAME_LENGTH) return resp - partition, nodes = self.app.account_ring.get_nodes(self.account_name) + partition = self.app.account_ring.get_part(self.account_name) + node_iter = self.app.iter_nodes(self.app.account_ring, partition) resp = self.GETorHEAD_base( - req, _('Account'), self.app.account_ring, partition, + req, _('Account'), node_iter, partition, req.swift_entity_path.rstrip('/')) if resp.status_int == HTTP_NOT_FOUND: if resp.headers.get('X-Account-Status', '').lower() == 'deleted': diff --git a/swift/proxy/controllers/base.py b/swift/proxy/controllers/base.py index 0aeb803f12..6bf7ea0ef6 100644 --- a/swift/proxy/controllers/base.py +++ b/swift/proxy/controllers/base.py @@ -28,6 +28,7 @@ import os import time import functools import inspect +import logging import operator from sys import exc_info from swift import gettext_ as _ @@ -39,16 +40,16 @@ from eventlet.timeout import Timeout from swift.common.wsgi import make_pre_authed_env from swift.common.utils import Timestamp, config_true_value, \ public, split_path, list_from_csv, GreenthreadSafeIterator, \ - quorum_size, GreenAsyncPile + GreenAsyncPile, quorum_size, parse_content_range from swift.common.bufferedhttp import http_connect from swift.common.exceptions import ChunkReadTimeout, ChunkWriteTimeout, \ ConnectionTimeout from swift.common.http import is_informational, is_success, is_redirection, \ is_server_error, HTTP_OK, HTTP_PARTIAL_CONTENT, HTTP_MULTIPLE_CHOICES, \ HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_SERVICE_UNAVAILABLE, \ - HTTP_INSUFFICIENT_STORAGE, HTTP_UNAUTHORIZED + HTTP_INSUFFICIENT_STORAGE, HTTP_UNAUTHORIZED, HTTP_CONTINUE from swift.common.swob import Request, Response, HeaderKeyDict, Range, \ - HTTPException, HTTPRequestedRangeNotSatisfiable + HTTPException, HTTPRequestedRangeNotSatisfiable, HTTPServiceUnavailable from swift.common.request_helpers import strip_sys_meta_prefix, \ strip_user_meta_prefix, is_user_meta, is_sys_meta, is_sys_or_user_meta from swift.common.storage_policy import POLICIES @@ -593,16 +594,37 @@ def close_swift_conn(src): pass +def bytes_to_skip(record_size, range_start): + """ + Assume an object is composed of N records, where the first N-1 are all + the same size and the last is at most that large, but may be smaller. + + When a range request is made, it might start with a partial record. This + must be discarded, lest the consumer get bad data. This is particularly + true of suffix-byte-range requests, e.g. "Range: bytes=-12345" where the + size of the object is unknown at the time the request is made. + + This function computes the number of bytes that must be discarded to + ensure only whole records are yielded. Erasure-code decoding needs this. + + This function could have been inlined, but it took enough tries to get + right that some targeted unit tests were desirable, hence its extraction. + """ + return (record_size - (range_start % record_size)) % record_size + + class GetOrHeadHandler(object): - def __init__(self, app, req, server_type, ring, partition, path, - backend_headers): + def __init__(self, app, req, server_type, node_iter, partition, path, + backend_headers, client_chunk_size=None): self.app = app - self.ring = ring + self.node_iter = node_iter self.server_type = server_type self.partition = partition self.path = path self.backend_headers = backend_headers + self.client_chunk_size = client_chunk_size + self.skip_bytes = 0 self.used_nodes = [] self.used_source_etag = '' @@ -649,6 +671,35 @@ class GetOrHeadHandler(object): else: self.backend_headers['Range'] = 'bytes=%d-' % num_bytes + def learn_size_from_content_range(self, start, end): + """ + If client_chunk_size is set, makes sure we yield things starting on + chunk boundaries based on the Content-Range header in the response. + + Sets our first Range header to the value learned from the + Content-Range header in the response; if we were given a + fully-specified range (e.g. "bytes=123-456"), this is a no-op. + + If we were given a half-specified range (e.g. "bytes=123-" or + "bytes=-456"), then this changes the Range header to a + semantically-equivalent one *and* it lets us resume on a proper + boundary instead of just in the middle of a piece somewhere. + + If the original request is for more than one range, this does not + affect our backend Range header, since we don't support resuming one + of those anyway. + """ + if self.client_chunk_size: + self.skip_bytes = bytes_to_skip(self.client_chunk_size, start) + + if 'Range' in self.backend_headers: + req_range = Range(self.backend_headers['Range']) + + if len(req_range.ranges) > 1: + return + + self.backend_headers['Range'] = "bytes=%d-%d" % (start, end) + def is_good_source(self, src): """ Indicates whether or not the request made to the backend found @@ -674,42 +725,74 @@ class GetOrHeadHandler(object): """ try: nchunks = 0 - bytes_read_from_source = 0 + client_chunk_size = self.client_chunk_size + bytes_consumed_from_backend = 0 node_timeout = self.app.node_timeout if self.server_type == 'Object': node_timeout = self.app.recoverable_node_timeout + buf = '' while True: try: with ChunkReadTimeout(node_timeout): chunk = source.read(self.app.object_chunk_size) nchunks += 1 - bytes_read_from_source += len(chunk) + buf += chunk except ChunkReadTimeout: exc_type, exc_value, exc_traceback = exc_info() if self.newest or self.server_type != 'Object': raise exc_type, exc_value, exc_traceback try: - self.fast_forward(bytes_read_from_source) + self.fast_forward(bytes_consumed_from_backend) except (NotImplementedError, HTTPException, ValueError): raise exc_type, exc_value, exc_traceback + buf = '' new_source, new_node = self._get_source_and_node() if new_source: self.app.exception_occurred( node, _('Object'), - _('Trying to read during GET (retrying)')) + _('Trying to read during GET (retrying)'), + level=logging.ERROR, exc_info=( + exc_type, exc_value, exc_traceback)) # Close-out the connection as best as possible. if getattr(source, 'swift_conn', None): close_swift_conn(source) source = new_source node = new_node - bytes_read_from_source = 0 continue else: raise exc_type, exc_value, exc_traceback + + if buf and self.skip_bytes: + if self.skip_bytes < len(buf): + buf = buf[self.skip_bytes:] + bytes_consumed_from_backend += self.skip_bytes + self.skip_bytes = 0 + else: + self.skip_bytes -= len(buf) + bytes_consumed_from_backend += len(buf) + buf = '' + if not chunk: + if buf: + with ChunkWriteTimeout(self.app.client_timeout): + bytes_consumed_from_backend += len(buf) + yield buf + buf = '' break - with ChunkWriteTimeout(self.app.client_timeout): - yield chunk + + if client_chunk_size is not None: + while len(buf) >= client_chunk_size: + client_chunk = buf[:client_chunk_size] + buf = buf[client_chunk_size:] + with ChunkWriteTimeout(self.app.client_timeout): + yield client_chunk + bytes_consumed_from_backend += len(client_chunk) + else: + with ChunkWriteTimeout(self.app.client_timeout): + yield buf + bytes_consumed_from_backend += len(buf) + buf = '' + # This is for fairness; if the network is outpacing the CPU, # we'll always be able to read and write data without # encountering an EWOULDBLOCK, and so eventlet will not switch @@ -757,7 +840,7 @@ class GetOrHeadHandler(object): node_timeout = self.app.node_timeout if self.server_type == 'Object' and not self.newest: node_timeout = self.app.recoverable_node_timeout - for node in self.app.iter_nodes(self.ring, self.partition): + for node in self.node_iter: if node in self.used_nodes: continue start_node_timing = time.time() @@ -793,8 +876,10 @@ class GetOrHeadHandler(object): src_headers = dict( (k.lower(), v) for k, v in possible_source.getheaders()) - if src_headers.get('etag', '').strip('"') != \ - self.used_source_etag: + + if self.used_source_etag != src_headers.get( + 'x-object-sysmeta-ec-etag', + src_headers.get('etag', '')).strip('"'): self.statuses.append(HTTP_NOT_FOUND) self.reasons.append('') self.bodies.append('') @@ -832,7 +917,9 @@ class GetOrHeadHandler(object): src_headers = dict( (k.lower(), v) for k, v in possible_source.getheaders()) - self.used_source_etag = src_headers.get('etag', '').strip('"') + self.used_source_etag = src_headers.get( + 'x-object-sysmeta-ec-etag', + src_headers.get('etag', '')).strip('"') return source, node return None, None @@ -841,13 +928,17 @@ class GetOrHeadHandler(object): res = None if source: res = Response(request=req) + res.status = source.status + update_headers(res, source.getheaders()) if req.method == 'GET' and \ source.status in (HTTP_OK, HTTP_PARTIAL_CONTENT): + cr = res.headers.get('Content-Range') + if cr: + start, end, total = parse_content_range(cr) + self.learn_size_from_content_range(start, end) res.app_iter = self._make_app_iter(req, node, source) # See NOTE: swift_conn at top of file about this. res.swift_conn = source.swift_conn - res.status = source.status - update_headers(res, source.getheaders()) if not res.environ: res.environ = {} res.environ['swift_x_timestamp'] = \ @@ -993,7 +1084,8 @@ class Controller(object): else: info['partition'] = part info['nodes'] = nodes - info.setdefault('storage_policy', '0') + if info.get('storage_policy') is None: + info['storage_policy'] = 0 return info def _make_request(self, nodes, part, method, path, headers, query, @@ -1098,6 +1190,13 @@ class Controller(object): '%s %s' % (self.server_type, req.method), overrides=overrides, headers=resp_headers) + def _quorum_size(self, n): + """ + Number of successful backend responses needed for the proxy to + consider the client request successful. + """ + return quorum_size(n) + def have_quorum(self, statuses, node_count): """ Given a list of statuses from several requests, determine if @@ -1107,16 +1206,18 @@ class Controller(object): :param node_count: number of nodes being queried (basically ring count) :returns: True or False, depending on if quorum is established """ - quorum = quorum_size(node_count) + quorum = self._quorum_size(node_count) if len(statuses) >= quorum: - for hundred in (HTTP_OK, HTTP_MULTIPLE_CHOICES, HTTP_BAD_REQUEST): + for hundred in (HTTP_CONTINUE, HTTP_OK, HTTP_MULTIPLE_CHOICES, + HTTP_BAD_REQUEST): if sum(1 for s in statuses if hundred <= s < hundred + 100) >= quorum: return True return False def best_response(self, req, statuses, reasons, bodies, server_type, - etag=None, headers=None, overrides=None): + etag=None, headers=None, overrides=None, + quorum_size=None): """ Given a list of responses from several servers, choose the best to return to the API. @@ -1128,10 +1229,16 @@ class Controller(object): :param server_type: type of server the responses came from :param etag: etag :param headers: headers of each response + :param overrides: overrides to apply when lacking quorum + :param quorum_size: quorum size to use :returns: swob.Response object with the correct status, body, etc. set """ + if quorum_size is None: + quorum_size = self._quorum_size(len(statuses)) + resp = self._compute_quorum_response( - req, statuses, reasons, bodies, etag, headers) + req, statuses, reasons, bodies, etag, headers, + quorum_size=quorum_size) if overrides and not resp: faked_up_status_indices = set() transformed = [] @@ -1145,25 +1252,25 @@ class Controller(object): statuses, reasons, headers, bodies = zip(*transformed) resp = self._compute_quorum_response( req, statuses, reasons, bodies, etag, headers, - indices_to_avoid=faked_up_status_indices) + indices_to_avoid=faked_up_status_indices, + quorum_size=quorum_size) if not resp: - resp = Response(request=req) + resp = HTTPServiceUnavailable(request=req) self.app.logger.error(_('%(type)s returning 503 for %(statuses)s'), {'type': server_type, 'statuses': statuses}) - resp.status = '503 Internal Server Error' return resp def _compute_quorum_response(self, req, statuses, reasons, bodies, etag, - headers, indices_to_avoid=()): + headers, quorum_size, indices_to_avoid=()): if not statuses: return None for hundred in (HTTP_OK, HTTP_MULTIPLE_CHOICES, HTTP_BAD_REQUEST): hstatuses = \ [(i, s) for i, s in enumerate(statuses) if hundred <= s < hundred + 100] - if len(hstatuses) >= quorum_size(len(statuses)): + if len(hstatuses) >= quorum_size: resp = Response(request=req) try: status_index, status = max( @@ -1228,22 +1335,25 @@ class Controller(object): else: self.app.logger.warning('Could not autocreate account %r' % path) - def GETorHEAD_base(self, req, server_type, ring, partition, path): + def GETorHEAD_base(self, req, server_type, node_iter, partition, path, + client_chunk_size=None): """ Base handler for HTTP GET or HEAD requests. :param req: swob.Request object :param server_type: server type used in logging - :param ring: the ring to obtain nodes from + :param node_iter: an iterator to obtain nodes from :param partition: partition :param path: path for the request + :param client_chunk_size: chunk size for response body iterator :returns: swob.Response object """ backend_headers = self.generate_request_headers( req, additional=req.headers) - handler = GetOrHeadHandler(self.app, req, self.server_type, ring, - partition, path, backend_headers) + handler = GetOrHeadHandler(self.app, req, self.server_type, node_iter, + partition, path, backend_headers, + client_chunk_size=client_chunk_size) res = handler.get_working_response(req) if not res: diff --git a/swift/proxy/controllers/container.py b/swift/proxy/controllers/container.py index fb422e68db..3e4a2bb031 100644 --- a/swift/proxy/controllers/container.py +++ b/swift/proxy/controllers/container.py @@ -93,8 +93,9 @@ class ContainerController(Controller): return HTTPNotFound(request=req) part = self.app.container_ring.get_part( self.account_name, self.container_name) + node_iter = self.app.iter_nodes(self.app.container_ring, part) resp = self.GETorHEAD_base( - req, _('Container'), self.app.container_ring, part, + req, _('Container'), node_iter, part, req.swift_entity_path) if 'swift.authorize' in req.environ: req.acl = resp.headers.get('x-container-read') diff --git a/swift/proxy/controllers/obj.py b/swift/proxy/controllers/obj.py index 1b9bcab61d..a83242b5f0 100644 --- a/swift/proxy/controllers/obj.py +++ b/swift/proxy/controllers/obj.py @@ -24,13 +24,17 @@ # These shenanigans are to ensure all related objects can be garbage # collected. We've seen objects hang around forever otherwise. +import collections import itertools import mimetypes import time import math +import random +from hashlib import md5 from swift import gettext_ as _ from urllib import unquote, quote +from greenlet import GreenletExit from eventlet import GreenPile from eventlet.queue import Queue from eventlet.timeout import Timeout @@ -38,7 +42,8 @@ from eventlet.timeout import Timeout from swift.common.utils import ( clean_content_type, config_true_value, ContextPool, csv_append, GreenAsyncPile, GreenthreadSafeIterator, json, Timestamp, - normalize_delete_at_timestamp, public, quorum_size, get_expirer_container) + normalize_delete_at_timestamp, public, get_expirer_container, + quorum_size) from swift.common.bufferedhttp import http_connect from swift.common.constraints import check_metadata, check_object_creation, \ check_copy_from_header, check_destination_header, \ @@ -46,20 +51,24 @@ from swift.common.constraints import check_metadata, check_object_creation, \ from swift.common import constraints from swift.common.exceptions import ChunkReadTimeout, \ ChunkWriteTimeout, ConnectionTimeout, ListingIterNotFound, \ - ListingIterNotAuthorized, ListingIterError + ListingIterNotAuthorized, ListingIterError, ResponseTimeout, \ + InsufficientStorage, FooterNotSupported, MultiphasePUTNotSupported, \ + PutterConnectError from swift.common.http import ( is_success, is_client_error, is_server_error, HTTP_CONTINUE, HTTP_CREATED, HTTP_MULTIPLE_CHOICES, HTTP_NOT_FOUND, HTTP_INTERNAL_SERVER_ERROR, HTTP_SERVICE_UNAVAILABLE, HTTP_INSUFFICIENT_STORAGE, - HTTP_PRECONDITION_FAILED, HTTP_CONFLICT) + HTTP_PRECONDITION_FAILED, HTTP_CONFLICT, is_informational) +from swift.common.storage_policy import (POLICIES, REPL_POLICY, EC_POLICY, + ECDriverError, PolicyError) from swift.proxy.controllers.base import Controller, delay_denial, \ cors_validation from swift.common.swob import HTTPAccepted, HTTPBadRequest, HTTPNotFound, \ HTTPPreconditionFailed, HTTPRequestEntityTooLarge, HTTPRequestTimeout, \ - HTTPServerError, HTTPServiceUnavailable, Request, \ - HTTPClientDisconnect, HeaderKeyDict + HTTPServerError, HTTPServiceUnavailable, Request, HeaderKeyDict, \ + HTTPClientDisconnect, HTTPUnprocessableEntity, Response, HTTPException from swift.common.request_helpers import is_sys_or_user_meta, is_sys_meta, \ - remove_items, copy_header_subset + remove_items, copy_header_subset, close_if_possible def copy_headers_into(from_r, to_r): @@ -84,8 +93,41 @@ def check_content_type(req): return None -class ObjectController(Controller): - """WSGI controller for object requests.""" +class ObjectControllerRouter(object): + + policy_type_to_controller_map = {} + + @classmethod + def register(cls, policy_type): + """ + Decorator for Storage Policy implemenations to register + their ObjectController implementations. + + This also fills in a policy_type attribute on the class. + """ + def register_wrapper(controller_cls): + if policy_type in cls.policy_type_to_controller_map: + raise PolicyError( + '%r is already registered for the policy_type %r' % ( + cls.policy_type_to_controller_map[policy_type], + policy_type)) + cls.policy_type_to_controller_map[policy_type] = controller_cls + controller_cls.policy_type = policy_type + return controller_cls + return register_wrapper + + def __init__(self): + self.policy_to_controller_cls = {} + for policy in POLICIES: + self.policy_to_controller_cls[policy] = \ + self.policy_type_to_controller_map[policy.policy_type] + + def __getitem__(self, policy): + return self.policy_to_controller_cls[policy] + + +class BaseObjectController(Controller): + """Base WSGI controller for object requests.""" server_type = 'Object' def __init__(self, app, account_name, container_name, object_name, @@ -113,8 +155,10 @@ class ObjectController(Controller): lreq.environ['QUERY_STRING'] = \ 'format=json&prefix=%s&marker=%s' % (quote(lprefix), quote(marker)) + container_node_iter = self.app.iter_nodes(self.app.container_ring, + lpartition) lresp = self.GETorHEAD_base( - lreq, _('Container'), self.app.container_ring, lpartition, + lreq, _('Container'), container_node_iter, lpartition, lreq.swift_entity_path) if 'swift.authorize' in env: lreq.acl = lresp.headers.get('x-container-read') @@ -179,6 +223,7 @@ class ObjectController(Controller): # pass the policy index to storage nodes via req header policy_index = req.headers.get('X-Backend-Storage-Policy-Index', container_info['storage_policy']) + policy = POLICIES.get_by_index(policy_index) obj_ring = self.app.get_object_ring(policy_index) req.headers['X-Backend-Storage-Policy-Index'] = policy_index if 'swift.authorize' in req.environ: @@ -187,9 +232,10 @@ class ObjectController(Controller): return aresp partition = obj_ring.get_part( self.account_name, self.container_name, self.object_name) - resp = self.GETorHEAD_base( - req, _('Object'), obj_ring, partition, - req.swift_entity_path) + node_iter = self.app.iter_nodes(obj_ring, partition) + + resp = self._reroute(policy)._get_or_head_response( + req, node_iter, partition, policy) if ';' in resp.headers.get('content-type', ''): resp.content_type = clean_content_type( @@ -321,7 +367,13 @@ class ObjectController(Controller): def _connect_put_node(self, nodes, part, path, headers, logger_thread_locals): - """Method for a file PUT connect""" + """ + Make a connection for a replicated object. + + Connects to the first working node that it finds in node_iter + and sends over the request headers. Returns an HTTPConnection + object to handle the rest of the streaming. + """ self.app.logger.thread_locals = logger_thread_locals for node in nodes: try: @@ -350,36 +402,44 @@ class ObjectController(Controller): self.app.error_limit(node, _('ERROR Insufficient Storage')) elif is_server_error(resp.status): self.app.error_occurred( - node, _('ERROR %(status)d Expect: 100-continue ' - 'From Object Server') % { - 'status': resp.status}) + node, + _('ERROR %(status)d Expect: 100-continue ' + 'From Object Server') % { + 'status': resp.status}) except (Exception, Timeout): self.app.exception_occurred( node, _('Object'), _('Expect: 100-continue on %s') % path) - def _get_put_responses(self, req, conns, nodes): + def _await_response(self, conn, **kwargs): + with Timeout(self.app.node_timeout): + if conn.resp: + return conn.resp + else: + return conn.getresponse() + + def _get_conn_response(self, conn, req, **kwargs): + try: + resp = self._await_response(conn, **kwargs) + return (conn, resp) + except (Exception, Timeout): + self.app.exception_occurred( + conn.node, _('Object'), + _('Trying to get final status of PUT to %s') % req.path) + return (None, None) + + def _get_put_responses(self, req, conns, nodes, **kwargs): + """ + Collect replicated object responses. + """ statuses = [] reasons = [] bodies = [] etags = set() - def get_conn_response(conn): - try: - with Timeout(self.app.node_timeout): - if conn.resp: - return (conn, conn.resp) - else: - return (conn, conn.getresponse()) - except (Exception, Timeout): - self.app.exception_occurred( - conn.node, _('Object'), - _('Trying to get final status of PUT to %s') % req.path) - return (None, None) - pile = GreenAsyncPile(len(conns)) for conn in conns: - pile.spawn(get_conn_response, conn) + pile.spawn(self._get_conn_response, conn, req) def _handle_response(conn, response): statuses.append(response.status) @@ -440,56 +500,136 @@ class ObjectController(Controller): return req, delete_at_container, delete_at_part, delete_at_nodes - @public - @cors_validation - @delay_denial - def PUT(self, req): - """HTTP PUT request handler.""" - if req.if_none_match is not None and '*' not in req.if_none_match: - # Sending an etag with if-none-match isn't currently supported - return HTTPBadRequest(request=req, content_type='text/plain', - body='If-None-Match only supports *') + def _handle_copy_request(self, req): + """ + This method handles copying objects based on values set in the headers + 'X-Copy-From' and 'X-Copy-From-Account' + + This method was added as part of the refactoring of the PUT method and + the functionality is expected to be moved to middleware + """ + if req.environ.get('swift.orig_req_method', req.method) != 'POST': + req.environ.setdefault('swift.log_info', []).append( + 'x-copy-from:%s' % req.headers['X-Copy-From']) + ver, acct, _rest = req.split_path(2, 3, True) + src_account_name = req.headers.get('X-Copy-From-Account', None) + if src_account_name: + src_account_name = check_account_format(req, src_account_name) + else: + src_account_name = acct + src_container_name, src_obj_name = check_copy_from_header(req) + source_header = '/%s/%s/%s/%s' % ( + ver, src_account_name, src_container_name, src_obj_name) + source_req = req.copy_get() + + # make sure the source request uses it's container_info + source_req.headers.pop('X-Backend-Storage-Policy-Index', None) + source_req.path_info = source_header + source_req.headers['X-Newest'] = 'true' + + orig_obj_name = self.object_name + orig_container_name = self.container_name + orig_account_name = self.account_name + sink_req = Request.blank(req.path_info, + environ=req.environ, headers=req.headers) + + self.object_name = src_obj_name + self.container_name = src_container_name + self.account_name = src_account_name + + source_resp = self.GET(source_req) + + # This gives middlewares a way to change the source; for example, + # this lets you COPY a SLO manifest and have the new object be the + # concatenation of the segments (like what a GET request gives + # the client), not a copy of the manifest file. + hook = req.environ.get( + 'swift.copy_hook', + (lambda source_req, source_resp, sink_req: source_resp)) + source_resp = hook(source_req, source_resp, sink_req) + + # reset names + self.object_name = orig_obj_name + self.container_name = orig_container_name + self.account_name = orig_account_name + + if source_resp.status_int >= HTTP_MULTIPLE_CHOICES: + # this is a bit of ugly code, but I'm willing to live with it + # until copy request handling moves to middleware + return source_resp, None, None, None + if source_resp.content_length is None: + # This indicates a transfer-encoding: chunked source object, + # which currently only happens because there are more than + # CONTAINER_LISTING_LIMIT segments in a segmented object. In + # this case, we're going to refuse to do the server-side copy. + raise HTTPRequestEntityTooLarge(request=req) + if source_resp.content_length > constraints.MAX_FILE_SIZE: + raise HTTPRequestEntityTooLarge(request=req) + + data_source = iter(source_resp.app_iter) + sink_req.content_length = source_resp.content_length + sink_req.etag = source_resp.etag + + # we no longer need the X-Copy-From header + del sink_req.headers['X-Copy-From'] + if 'X-Copy-From-Account' in sink_req.headers: + del sink_req.headers['X-Copy-From-Account'] + if not req.content_type_manually_set: + sink_req.headers['Content-Type'] = \ + source_resp.headers['Content-Type'] + if config_true_value( + sink_req.headers.get('x-fresh-metadata', 'false')): + # post-as-copy: ignore new sysmeta, copy existing sysmeta + condition = lambda k: is_sys_meta('object', k) + remove_items(sink_req.headers, condition) + copy_header_subset(source_resp, sink_req, condition) + else: + # copy/update existing sysmeta and user meta + copy_headers_into(source_resp, sink_req) + copy_headers_into(req, sink_req) + + # copy over x-static-large-object for POSTs and manifest copies + if 'X-Static-Large-Object' in source_resp.headers and \ + req.params.get('multipart-manifest') == 'get': + sink_req.headers['X-Static-Large-Object'] = \ + source_resp.headers['X-Static-Large-Object'] + + req = sink_req + + def update_response(req, resp): + acct, path = source_resp.environ['PATH_INFO'].split('/', 3)[2:4] + resp.headers['X-Copied-From-Account'] = quote(acct) + resp.headers['X-Copied-From'] = quote(path) + if 'last-modified' in source_resp.headers: + resp.headers['X-Copied-From-Last-Modified'] = \ + source_resp.headers['last-modified'] + copy_headers_into(req, resp) + return resp + + # this is a bit of ugly code, but I'm willing to live with it + # until copy request handling moves to middleware + return None, req, data_source, update_response + + def _handle_object_versions(self, req): + """ + This method handles versionining of objects in containers that + have the feature enabled. + + When a new PUT request is sent, the proxy checks for previous versions + of that same object name. If found, it is copied to a different + container and the new version is stored in its place. + + This method was added as part of the PUT method refactoring and the + functionality is expected to be moved to middleware + """ container_info = self.container_info( self.account_name, self.container_name, req) policy_index = req.headers.get('X-Backend-Storage-Policy-Index', container_info['storage_policy']) obj_ring = self.app.get_object_ring(policy_index) - - # pass the policy index to storage nodes via req header - req.headers['X-Backend-Storage-Policy-Index'] = policy_index - container_partition = container_info['partition'] - containers = container_info['nodes'] - req.acl = container_info['write_acl'] - req.environ['swift_sync_key'] = container_info['sync_key'] - object_versions = container_info['versions'] - if 'swift.authorize' in req.environ: - aresp = req.environ['swift.authorize'](req) - if aresp: - return aresp - - if not containers: - return HTTPNotFound(request=req) - - # Sometimes the 'content-type' header exists, but is set to None. - content_type_manually_set = True - detect_content_type = \ - config_true_value(req.headers.get('x-detect-content-type')) - if detect_content_type or not req.headers.get('content-type'): - guessed_type, _junk = mimetypes.guess_type(req.path_info) - req.headers['Content-Type'] = guessed_type or \ - 'application/octet-stream' - if detect_content_type: - req.headers.pop('x-detect-content-type') - else: - content_type_manually_set = False - - error_response = check_object_creation(req, self.object_name) or \ - check_content_type(req) - if error_response: - return error_response - partition, nodes = obj_ring.get_nodes( self.account_name, self.container_name, self.object_name) + object_versions = container_info['versions'] # do a HEAD request for checking object versions if object_versions and not req.environ.get('swift_versioned_copy'): @@ -498,24 +638,11 @@ class ObjectController(Controller): 'X-Newest': 'True'} hreq = Request.blank(req.path_info, headers=_headers, environ={'REQUEST_METHOD': 'HEAD'}) + hnode_iter = self.app.iter_nodes(obj_ring, partition) hresp = self.GETorHEAD_base( - hreq, _('Object'), obj_ring, partition, + hreq, _('Object'), hnode_iter, partition, hreq.swift_entity_path) - # Used by container sync feature - if 'x-timestamp' in req.headers: - try: - req_timestamp = Timestamp(req.headers['X-Timestamp']) - except ValueError: - return HTTPBadRequest( - request=req, content_type='text/plain', - body='X-Timestamp should be a UNIX timestamp float value; ' - 'was %r' % req.headers['x-timestamp']) - req.headers['X-Timestamp'] = req_timestamp.internal - else: - req.headers['X-Timestamp'] = Timestamp(time.time()).internal - - if object_versions and not req.environ.get('swift_versioned_copy'): is_manifest = 'X-Object-Manifest' in req.headers or \ 'X-Object-Manifest' in hresp.headers if hresp.status_int != HTTP_NOT_FOUND and not is_manifest: @@ -543,120 +670,44 @@ class ObjectController(Controller): copy_resp = self.COPY(copy_req) if is_client_error(copy_resp.status_int): # missing container or bad permissions - return HTTPPreconditionFailed(request=req) + raise HTTPPreconditionFailed(request=req) elif not is_success(copy_resp.status_int): # could not copy the data, bail - return HTTPServiceUnavailable(request=req) + raise HTTPServiceUnavailable(request=req) - reader = req.environ['wsgi.input'].read - data_source = iter(lambda: reader(self.app.client_chunk_size), '') - source_header = req.headers.get('X-Copy-From') - source_resp = None - if source_header: - if req.environ.get('swift.orig_req_method', req.method) != 'POST': - req.environ.setdefault('swift.log_info', []).append( - 'x-copy-from:%s' % source_header) - ver, acct, _rest = req.split_path(2, 3, True) - src_account_name = req.headers.get('X-Copy-From-Account', None) - if src_account_name: - src_account_name = check_account_format(req, src_account_name) + def _update_content_type(self, req): + # Sometimes the 'content-type' header exists, but is set to None. + req.content_type_manually_set = True + detect_content_type = \ + config_true_value(req.headers.get('x-detect-content-type')) + if detect_content_type or not req.headers.get('content-type'): + guessed_type, _junk = mimetypes.guess_type(req.path_info) + req.headers['Content-Type'] = guessed_type or \ + 'application/octet-stream' + if detect_content_type: + req.headers.pop('x-detect-content-type') else: - src_account_name = acct - src_container_name, src_obj_name = check_copy_from_header(req) - source_header = '/%s/%s/%s/%s' % ( - ver, src_account_name, src_container_name, src_obj_name) - source_req = req.copy_get() + req.content_type_manually_set = False - # make sure the source request uses it's container_info - source_req.headers.pop('X-Backend-Storage-Policy-Index', None) - source_req.path_info = source_header - source_req.headers['X-Newest'] = 'true' - orig_obj_name = self.object_name - orig_container_name = self.container_name - orig_account_name = self.account_name - self.object_name = src_obj_name - self.container_name = src_container_name - self.account_name = src_account_name - sink_req = Request.blank(req.path_info, - environ=req.environ, headers=req.headers) - source_resp = self.GET(source_req) - - # This gives middlewares a way to change the source; for example, - # this lets you COPY a SLO manifest and have the new object be the - # concatenation of the segments (like what a GET request gives - # the client), not a copy of the manifest file. - hook = req.environ.get( - 'swift.copy_hook', - (lambda source_req, source_resp, sink_req: source_resp)) - source_resp = hook(source_req, source_resp, sink_req) - - if source_resp.status_int >= HTTP_MULTIPLE_CHOICES: - return source_resp - self.object_name = orig_obj_name - self.container_name = orig_container_name - self.account_name = orig_account_name - data_source = iter(source_resp.app_iter) - sink_req.content_length = source_resp.content_length - if sink_req.content_length is None: - # This indicates a transfer-encoding: chunked source object, - # which currently only happens because there are more than - # CONTAINER_LISTING_LIMIT segments in a segmented object. In - # this case, we're going to refuse to do the server-side copy. - return HTTPRequestEntityTooLarge(request=req) - if sink_req.content_length > constraints.MAX_FILE_SIZE: - return HTTPRequestEntityTooLarge(request=req) - sink_req.etag = source_resp.etag - - # we no longer need the X-Copy-From header - del sink_req.headers['X-Copy-From'] - if 'X-Copy-From-Account' in sink_req.headers: - del sink_req.headers['X-Copy-From-Account'] - if not content_type_manually_set: - sink_req.headers['Content-Type'] = \ - source_resp.headers['Content-Type'] - if config_true_value( - sink_req.headers.get('x-fresh-metadata', 'false')): - # post-as-copy: ignore new sysmeta, copy existing sysmeta - condition = lambda k: is_sys_meta('object', k) - remove_items(sink_req.headers, condition) - copy_header_subset(source_resp, sink_req, condition) - else: - # copy/update existing sysmeta and user meta - copy_headers_into(source_resp, sink_req) - copy_headers_into(req, sink_req) - - # copy over x-static-large-object for POSTs and manifest copies - if 'X-Static-Large-Object' in source_resp.headers and \ - req.params.get('multipart-manifest') == 'get': - sink_req.headers['X-Static-Large-Object'] = \ - source_resp.headers['X-Static-Large-Object'] - - req = sink_req - - req, delete_at_container, delete_at_part, \ - delete_at_nodes = self._config_obj_expiration(req) - - node_iter = GreenthreadSafeIterator( - self.iter_nodes_local_first(obj_ring, partition)) - pile = GreenPile(len(nodes)) - te = req.headers.get('transfer-encoding', '') - chunked = ('chunked' in te) - - outgoing_headers = self._backend_requests( - req, len(nodes), container_partition, containers, - delete_at_container, delete_at_part, delete_at_nodes) - - for nheaders in outgoing_headers: - # RFC2616:8.2.3 disallows 100-continue without a body - if (req.content_length > 0) or chunked: - nheaders['Expect'] = '100-continue' - pile.spawn(self._connect_put_node, node_iter, partition, - req.swift_entity_path, nheaders, - self.app.logger.thread_locals) - - conns = [conn for conn in pile if conn] - min_conns = quorum_size(len(nodes)) + def _update_x_timestamp(self, req): + # Used by container sync feature + if 'x-timestamp' in req.headers: + try: + req_timestamp = Timestamp(req.headers['X-Timestamp']) + except ValueError: + raise HTTPBadRequest( + request=req, content_type='text/plain', + body='X-Timestamp should be a UNIX timestamp float value; ' + 'was %r' % req.headers['x-timestamp']) + req.headers['X-Timestamp'] = req_timestamp.internal + else: + req.headers['X-Timestamp'] = Timestamp(time.time()).internal + return None + def _check_failure_put_connections(self, conns, req, nodes, min_conns): + """ + Identify any failed connections and check minimum connection count. + """ if req.if_none_match is not None and '*' in req.if_none_match: statuses = [conn.resp.status for conn in conns if conn.resp] if HTTP_PRECONDITION_FAILED in statuses: @@ -664,7 +715,7 @@ class ObjectController(Controller): self.app.logger.debug( _('Object PUT returning 412, %(statuses)r'), {'statuses': statuses}) - return HTTPPreconditionFailed(request=req) + raise HTTPPreconditionFailed(request=req) if any(conn for conn in conns if conn.resp and conn.resp.status == HTTP_CONFLICT): @@ -675,14 +726,47 @@ class ObjectController(Controller): '%(req_timestamp)s <= %(timestamps)r'), {'req_timestamp': req.timestamp.internal, 'timestamps': ', '.join(timestamps)}) - return HTTPAccepted(request=req) + raise HTTPAccepted(request=req) + + self._check_min_conn(req, conns, min_conns) + + def _get_put_connections(self, req, nodes, partition, outgoing_headers, + policy, expect): + """ + Establish connections to storage nodes for PUT request + """ + obj_ring = policy.object_ring + node_iter = GreenthreadSafeIterator( + self.iter_nodes_local_first(obj_ring, partition)) + pile = GreenPile(len(nodes)) + + for nheaders in outgoing_headers: + if expect: + nheaders['Expect'] = '100-continue' + pile.spawn(self._connect_put_node, node_iter, partition, + req.swift_entity_path, nheaders, + self.app.logger.thread_locals) + + conns = [conn for conn in pile if conn] + + return conns + + def _check_min_conn(self, req, conns, min_conns, msg=None): + msg = msg or 'Object PUT returning 503, %(conns)s/%(nodes)s ' \ + 'required connections' if len(conns) < min_conns: - self.app.logger.error( - _('Object PUT returning 503, %(conns)s/%(nodes)s ' - 'required connections'), - {'conns': len(conns), 'nodes': min_conns}) - return HTTPServiceUnavailable(request=req) + self.app.logger.error((msg), + {'conns': len(conns), 'nodes': min_conns}) + raise HTTPServiceUnavailable(request=req) + + def _transfer_data(self, req, data_source, conns, nodes): + """ + Transfer data for a replicated object. + + This method was added in the PUT method extraction change + """ + min_conns = quorum_size(len(nodes)) bytes_transferred = 0 try: with ContextPool(len(nodes)) as pool: @@ -695,48 +779,90 @@ class ObjectController(Controller): try: chunk = next(data_source) except StopIteration: - if chunked: + if req.is_chunked: for conn in conns: conn.queue.put('0\r\n\r\n') break bytes_transferred += len(chunk) if bytes_transferred > constraints.MAX_FILE_SIZE: - return HTTPRequestEntityTooLarge(request=req) + raise HTTPRequestEntityTooLarge(request=req) for conn in list(conns): if not conn.failed: conn.queue.put( '%x\r\n%s\r\n' % (len(chunk), chunk) - if chunked else chunk) + if req.is_chunked else chunk) else: + conn.close() conns.remove(conn) - if len(conns) < min_conns: - self.app.logger.error(_( - 'Object PUT exceptions during' - ' send, %(conns)s/%(nodes)s required connections'), - {'conns': len(conns), 'nodes': min_conns}) - return HTTPServiceUnavailable(request=req) + self._check_min_conn( + req, conns, min_conns, + msg='Object PUT exceptions during' + ' send, %(conns)s/%(nodes)s required connections') for conn in conns: if conn.queue.unfinished_tasks: conn.queue.join() conns = [conn for conn in conns if not conn.failed] + self._check_min_conn( + req, conns, min_conns, + msg='Object PUT exceptions after last send, ' + '%(conns)s/%(nodes)s required connections') except ChunkReadTimeout as err: self.app.logger.warn( _('ERROR Client read timeout (%ss)'), err.seconds) self.app.logger.increment('client_timeouts') - return HTTPRequestTimeout(request=req) + raise HTTPRequestTimeout(request=req) + except HTTPException: + raise except (Exception, Timeout): self.app.logger.exception( _('ERROR Exception causing client disconnect')) - return HTTPClientDisconnect(request=req) + raise HTTPClientDisconnect(request=req) if req.content_length and bytes_transferred < req.content_length: req.client_disconnect = True self.app.logger.warn( _('Client disconnected without sending enough data')) self.app.logger.increment('client_disconnects') - return HTTPClientDisconnect(request=req) + raise HTTPClientDisconnect(request=req) - statuses, reasons, bodies, etags = self._get_put_responses(req, conns, - nodes) + def _store_object(self, req, data_source, nodes, partition, + outgoing_headers): + """ + Store a replicated object. + + This method is responsible for establishing connection + with storage nodes and sending object to each one of those + nodes. After sending the data, the "best" response will be + returned based on statuses from all connections + """ + policy_index = req.headers.get('X-Backend-Storage-Policy-Index') + policy = POLICIES.get_by_index(policy_index) + if not nodes: + return HTTPNotFound() + + # RFC2616:8.2.3 disallows 100-continue without a body + if (req.content_length > 0) or req.is_chunked: + expect = True + else: + expect = False + conns = self._get_put_connections(req, nodes, partition, + outgoing_headers, policy, expect) + min_conns = quorum_size(len(nodes)) + try: + # check that a minimum number of connections were established and + # meet all the correct conditions set in the request + self._check_failure_put_connections(conns, req, nodes, min_conns) + + # transfer data + self._transfer_data(req, data_source, conns, nodes) + + # get responses + statuses, reasons, bodies, etags = self._get_put_responses( + req, conns, nodes) + except HTTPException as resp: + return resp + finally: + for conn in conns: + conn.close() if len(etags) > 1: self.app.logger.error( @@ -745,18 +871,83 @@ class ObjectController(Controller): etag = etags.pop() if len(etags) else None resp = self.best_response(req, statuses, reasons, bodies, _('Object PUT'), etag=etag) - if source_header: - acct, path = source_header.split('/', 3)[2:4] - resp.headers['X-Copied-From-Account'] = quote(acct) - resp.headers['X-Copied-From'] = quote(path) - if 'last-modified' in source_resp.headers: - resp.headers['X-Copied-From-Last-Modified'] = \ - source_resp.headers['last-modified'] - copy_headers_into(req, resp) resp.last_modified = math.ceil( float(Timestamp(req.headers['X-Timestamp']))) return resp + @public + @cors_validation + @delay_denial + def PUT(self, req): + """HTTP PUT request handler.""" + if req.if_none_match is not None and '*' not in req.if_none_match: + # Sending an etag with if-none-match isn't currently supported + return HTTPBadRequest(request=req, content_type='text/plain', + body='If-None-Match only supports *') + container_info = self.container_info( + self.account_name, self.container_name, req) + policy_index = req.headers.get('X-Backend-Storage-Policy-Index', + container_info['storage_policy']) + obj_ring = self.app.get_object_ring(policy_index) + container_nodes = container_info['nodes'] + container_partition = container_info['partition'] + partition, nodes = obj_ring.get_nodes( + self.account_name, self.container_name, self.object_name) + + # pass the policy index to storage nodes via req header + req.headers['X-Backend-Storage-Policy-Index'] = policy_index + req.acl = container_info['write_acl'] + req.environ['swift_sync_key'] = container_info['sync_key'] + + # is request authorized + if 'swift.authorize' in req.environ: + aresp = req.environ['swift.authorize'](req) + if aresp: + return aresp + + if not container_info['nodes']: + return HTTPNotFound(request=req) + + # update content type in case it is missing + self._update_content_type(req) + + # check constraints on object name and request headers + error_response = check_object_creation(req, self.object_name) or \ + check_content_type(req) + if error_response: + return error_response + + self._update_x_timestamp(req) + + # check if versioning is enabled and handle copying previous version + self._handle_object_versions(req) + + # check if request is a COPY of an existing object + source_header = req.headers.get('X-Copy-From') + if source_header: + error_response, req, data_source, update_response = \ + self._handle_copy_request(req) + if error_response: + return error_response + else: + reader = req.environ['wsgi.input'].read + data_source = iter(lambda: reader(self.app.client_chunk_size), '') + update_response = lambda req, resp: resp + + # check if object is set to be automaticaly deleted (i.e. expired) + req, delete_at_container, delete_at_part, \ + delete_at_nodes = self._config_obj_expiration(req) + + # add special headers to be handled by storage nodes + outgoing_headers = self._backend_requests( + req, len(nodes), container_partition, container_nodes, + delete_at_container, delete_at_part, delete_at_nodes) + + # send object to storage nodes + resp = self._store_object( + req, data_source, nodes, partition, outgoing_headers) + return update_response(req, resp) + @public @cors_validation @delay_denial @@ -775,6 +966,10 @@ class ObjectController(Controller): req.acl = container_info['write_acl'] req.environ['swift_sync_key'] = container_info['sync_key'] object_versions = container_info['versions'] + if 'swift.authorize' in req.environ: + aresp = req.environ['swift.authorize'](req) + if aresp: + return aresp if object_versions: # this is a version manifest and needs to be handled differently object_versions = unquote(object_versions) @@ -845,11 +1040,11 @@ class ObjectController(Controller): # remove 'X-If-Delete-At', since it is not for the older copy if 'X-If-Delete-At' in req.headers: del req.headers['X-If-Delete-At'] + if 'swift.authorize' in req.environ: + aresp = req.environ['swift.authorize'](req) + if aresp: + return aresp break - if 'swift.authorize' in req.environ: - aresp = req.environ['swift.authorize'](req) - if aresp: - return aresp if not containers: return HTTPNotFound(request=req) partition, nodes = obj_ring.get_nodes( @@ -876,6 +1071,21 @@ class ObjectController(Controller): headers, overrides=status_overrides) return resp + def _reroute(self, policy): + """ + For COPY requests we need to make sure the controller instance the + request is routed through is the correct type for the policy. + """ + if not policy: + raise HTTPServiceUnavailable('Unknown Storage Policy') + if policy.policy_type != self.policy_type: + controller = self.app.obj_controller_router[policy]( + self.app, self.account_name, self.container_name, + self.object_name) + else: + controller = self + return controller + @public @cors_validation @delay_denial @@ -892,6 +1102,7 @@ class ObjectController(Controller): self.account_name = dest_account del req.headers['Destination-Account'] dest_container, dest_object = check_destination_header(req) + source = '/%s/%s' % (self.container_name, self.object_name) self.container_name = dest_container self.object_name = dest_object @@ -903,4 +1114,1109 @@ class ObjectController(Controller): req.headers['Content-Length'] = 0 req.headers['X-Copy-From'] = quote(source) del req.headers['Destination'] - return self.PUT(req) + + container_info = self.container_info( + dest_account, dest_container, req) + dest_policy = POLICIES.get_by_index(container_info['storage_policy']) + + return self._reroute(dest_policy).PUT(req) + + +@ObjectControllerRouter.register(REPL_POLICY) +class ReplicatedObjectController(BaseObjectController): + + def _get_or_head_response(self, req, node_iter, partition, policy): + resp = self.GETorHEAD_base( + req, _('Object'), node_iter, partition, + req.swift_entity_path) + return resp + + +class ECAppIter(object): + """ + WSGI iterable that decodes EC fragment archives (or portions thereof) + into the original object (or portions thereof). + + :param path: path for the request + + :param policy: storage policy for this object + + :param internal_app_iters: list of the WSGI iterables from object server + GET responses for fragment archives. For an M+K erasure code, the + caller must supply M such iterables. + + :param range_specs: list of dictionaries describing the ranges requested + by the client. Each dictionary contains the start and end of the + client's requested byte range as well as the start and end of the EC + segments containing that byte range. + + :param obj_length: length of the object, in bytes. Learned from the + headers in the GET response from the object server. + + :param logger: a logger + """ + def __init__(self, path, policy, internal_app_iters, range_specs, + obj_length, logger): + self.path = path + self.policy = policy + self.internal_app_iters = internal_app_iters + self.range_specs = range_specs + self.obj_length = obj_length + self.boundary = '' + self.logger = logger + + def close(self): + for it in self.internal_app_iters: + close_if_possible(it) + + def __iter__(self): + segments_iter = self.decode_segments_from_fragments() + + if len(self.range_specs) == 0: + # plain GET; just yield up segments + for seg in segments_iter: + yield seg + return + + if len(self.range_specs) > 1: + raise NotImplementedError("multi-range GETs not done yet") + + for range_spec in self.range_specs: + client_start = range_spec['client_start'] + client_end = range_spec['client_end'] + segment_start = range_spec['segment_start'] + segment_end = range_spec['segment_end'] + + seg_size = self.policy.ec_segment_size + is_suffix = client_start is None + + if is_suffix: + # Suffix byte ranges (i.e. requests for the last N bytes of + # an object) are likely to end up not on a segment boundary. + client_range_len = client_end + client_start = max(self.obj_length - client_range_len, 0) + client_end = self.obj_length - 1 + + # may be mid-segment; if it is, then everything up to the + # first segment boundary is garbage, and is discarded before + # ever getting into this function. + unaligned_segment_start = max(self.obj_length - segment_end, 0) + alignment_offset = ( + (seg_size - (unaligned_segment_start % seg_size)) + % seg_size) + segment_start = unaligned_segment_start + alignment_offset + segment_end = self.obj_length - 1 + else: + # It's entirely possible that the client asked for a range that + # includes some bytes we have and some we don't; for example, a + # range of bytes 1000-20000000 on a 1500-byte object. + segment_end = (min(segment_end, self.obj_length - 1) + if segment_end is not None + else self.obj_length - 1) + client_end = (min(client_end, self.obj_length - 1) + if client_end is not None + else self.obj_length - 1) + + num_segments = int( + math.ceil(float(segment_end + 1 - segment_start) + / self.policy.ec_segment_size)) + # We get full segments here, but the client may have requested a + # byte range that begins or ends in the middle of a segment. + # Thus, we have some amount of overrun (extra decoded bytes) + # that we trim off so the client gets exactly what they + # requested. + start_overrun = client_start - segment_start + end_overrun = segment_end - client_end + + for i, next_seg in enumerate(segments_iter): + # We may have a start_overrun of more than one segment in + # the case of suffix-byte-range requests. However, we never + # have an end_overrun of more than one segment. + if start_overrun > 0: + seglen = len(next_seg) + if seglen <= start_overrun: + start_overrun -= seglen + continue + else: + next_seg = next_seg[start_overrun:] + start_overrun = 0 + + if i == (num_segments - 1) and end_overrun: + next_seg = next_seg[:-end_overrun] + + yield next_seg + + def decode_segments_from_fragments(self): + # Decodes the fragments from the object servers and yields one + # segment at a time. + queues = [Queue(1) for _junk in range(len(self.internal_app_iters))] + + def put_fragments_in_queue(frag_iter, queue): + try: + for fragment in frag_iter: + if fragment[0] == ' ': + raise Exception('Leading whitespace on fragment.') + queue.put(fragment) + except GreenletExit: + # killed by contextpool + pass + except ChunkReadTimeout: + # unable to resume in GetOrHeadHandler + pass + except: # noqa + self.logger.exception("Exception fetching fragments for %r" % + self.path) + finally: + queue.resize(2) # ensure there's room + queue.put(None) + + with ContextPool(len(self.internal_app_iters)) as pool: + for app_iter, queue in zip( + self.internal_app_iters, queues): + pool.spawn(put_fragments_in_queue, app_iter, queue) + + while True: + fragments = [] + for qi, queue in enumerate(queues): + fragment = queue.get() + queue.task_done() + fragments.append(fragment) + + # If any object server connection yields out a None; we're + # done. Either they are all None, and we've finished + # successfully; or some un-recoverable failure has left us + # with an un-reconstructible list of fragments - so we'll + # break out of the iter so WSGI can tear down the broken + # connection. + if not all(fragments): + break + try: + segment = self.policy.pyeclib_driver.decode(fragments) + except ECDriverError: + self.logger.exception("Error decoding fragments for %r" % + self.path) + raise + + yield segment + + def app_iter_range(self, start, end): + return self + + def app_iter_ranges(self, content_type, boundary, content_size): + self.boundary = boundary + + +def client_range_to_segment_range(client_start, client_end, segment_size): + """ + Takes a byterange from the client and converts it into a byterange + spanning the necessary segments. + + Handles prefix, suffix, and fully-specified byte ranges. + + Examples: + client_range_to_segment_range(100, 700, 512) = (0, 1023) + client_range_to_segment_range(100, 700, 256) = (0, 767) + client_range_to_segment_range(300, None, 256) = (256, None) + + :param client_start: first byte of the range requested by the client + :param client_end: last byte of the range requested by the client + :param segment_size: size of an EC segment, in bytes + + :returns: a 2-tuple (seg_start, seg_end) where + + * seg_start is the first byte of the first segment, or None if this is + a suffix byte range + + * seg_end is the last byte of the last segment, or None if this is a + prefix byte range + """ + # the index of the first byte of the first segment + segment_start = ( + int(client_start // segment_size) + * segment_size) if client_start is not None else None + # the index of the last byte of the last segment + segment_end = ( + # bytes M- + None if client_end is None else + # bytes M-N + (((int(client_end // segment_size) + 1) + * segment_size) - 1) if client_start is not None else + # bytes -N: we get some extra bytes to make sure we + # have all we need. + # + # To see why, imagine a 100-byte segment size, a + # 340-byte object, and a request for the last 50 + # bytes. Naively requesting the last 100 bytes would + # result in a truncated first segment and hence a + # truncated download. (Of course, the actual + # obj-server requests are for fragments, not + # segments, but that doesn't change the + # calculation.) + # + # This does mean that we fetch an extra segment if + # the object size is an exact multiple of the + # segment size. It's a little wasteful, but it's + # better to be a little wasteful than to get some + # range requests completely wrong. + (int(math.ceil(( + float(client_end) / segment_size) + 1)) # nsegs + * segment_size)) + return (segment_start, segment_end) + + +def segment_range_to_fragment_range(segment_start, segment_end, segment_size, + fragment_size): + """ + Takes a byterange spanning some segments and converts that into a + byterange spanning the corresponding fragments within their fragment + archives. + + Handles prefix, suffix, and fully-specified byte ranges. + + :param segment_start: first byte of the first segment + :param segment_end: last byte of the last segment + :param segment_size: size of an EC segment, in bytes + :param fragment_size: size of an EC fragment, in bytes + + :returns: a 2-tuple (frag_start, frag_end) where + + * frag_start is the first byte of the first fragment, or None if this + is a suffix byte range + + * frag_end is the last byte of the last fragment, or None if this is a + prefix byte range + """ + # Note: segment_start and (segment_end + 1) are + # multiples of segment_size, so we don't have to worry + # about integer math giving us rounding troubles. + # + # There's a whole bunch of +1 and -1 in here; that's because HTTP wants + # byteranges to be inclusive of the start and end, so e.g. bytes 200-300 + # is a range containing 101 bytes. Python has half-inclusive ranges, of + # course, so we have to convert back and forth. We try to keep things in + # HTTP-style byteranges for consistency. + + # the index of the first byte of the first fragment + fragment_start = (( + segment_start / segment_size * fragment_size) + if segment_start is not None else None) + # the index of the last byte of the last fragment + fragment_end = ( + # range unbounded on the right + None if segment_end is None else + # range unbounded on the left; no -1 since we're + # asking for the last N bytes, not to have a + # particular byte be the last one + ((segment_end + 1) / segment_size + * fragment_size) if segment_start is None else + # range bounded on both sides; the -1 is because the + # rest of the expression computes the length of the + # fragment, and a range of N bytes starts at index M + # and ends at M + N - 1. + ((segment_end + 1) / segment_size * fragment_size) - 1) + return (fragment_start, fragment_end) + + +NO_DATA_SENT = 1 +SENDING_DATA = 2 +DATA_SENT = 3 +DATA_ACKED = 4 +COMMIT_SENT = 5 + + +class ECPutter(object): + """ + This is here mostly to wrap up the fact that all EC PUTs are + chunked because of the mime boundary footer trick and the first + half of the two-phase PUT conversation handling. + + An HTTP PUT request that supports streaming. + + Probably deserves more docs than this, but meh. + """ + def __init__(self, conn, node, resp, path, connect_duration, + mime_boundary): + # Note: you probably want to call Putter.connect() instead of + # instantiating one of these directly. + self.conn = conn + self.node = node + self.resp = resp + self.path = path + self.connect_duration = connect_duration + # for handoff nodes node_index is None + self.node_index = node.get('index') + self.mime_boundary = mime_boundary + self.chunk_hasher = md5() + + self.failed = False + self.queue = None + self.state = NO_DATA_SENT + + def current_status(self): + """ + Returns the current status of the response. + + A response starts off with no current status, then may or may not have + a status of 100 for some time, and then ultimately has a final status + like 200, 404, et cetera. + """ + return self.resp.status + + def await_response(self, timeout, informational=False): + """ + Get 100-continue response indicating the end of 1st phase of a 2-phase + commit or the final response, i.e. the one with status >= 200. + + Might or might not actually wait for anything. If we said Expect: + 100-continue but got back a non-100 response, that'll be the thing + returned, and we won't do any network IO to get it. OTOH, if we got + a 100 Continue response and sent up the PUT request's body, then + we'll actually read the 2xx-5xx response off the network here. + + :returns: HTTPResponse + :raises: Timeout if the response took too long + """ + conn = self.conn + with Timeout(timeout): + if not conn.resp: + if informational: + self.resp = conn.getexpect() + else: + self.resp = conn.getresponse() + return self.resp + + def spawn_sender_greenthread(self, pool, queue_depth, write_timeout, + exception_handler): + """Call before sending the first chunk of request body""" + self.queue = Queue(queue_depth) + pool.spawn(self._send_file, write_timeout, exception_handler) + + def wait(self): + if self.queue.unfinished_tasks: + self.queue.join() + + def _start_mime_doc_object_body(self): + self.queue.put("--%s\r\nX-Document: object body\r\n\r\n" % + (self.mime_boundary,)) + + def send_chunk(self, chunk): + if not chunk: + # If we're not using chunked transfer-encoding, sending a 0-byte + # chunk is just wasteful. If we *are* using chunked + # transfer-encoding, sending a 0-byte chunk terminates the + # request body. Neither one of these is good. + return + elif self.state == DATA_SENT: + raise ValueError("called send_chunk after end_of_object_data") + + if self.state == NO_DATA_SENT and self.mime_boundary: + # We're sending the object plus other stuff in the same request + # body, all wrapped up in multipart MIME, so we'd better start + # off the MIME document before sending any object data. + self._start_mime_doc_object_body() + self.state = SENDING_DATA + + self.queue.put(chunk) + + def end_of_object_data(self, footer_metadata): + """ + Call when there is no more data to send. + + :param footer_metadata: dictionary of metadata items + """ + if self.state == DATA_SENT: + raise ValueError("called end_of_object_data twice") + elif self.state == NO_DATA_SENT and self.mime_boundary: + self._start_mime_doc_object_body() + + footer_body = json.dumps(footer_metadata) + footer_md5 = md5(footer_body).hexdigest() + + tail_boundary = ("--%s" % (self.mime_boundary,)) + + message_parts = [ + ("\r\n--%s\r\n" % self.mime_boundary), + "X-Document: object metadata\r\n", + "Content-MD5: %s\r\n" % footer_md5, + "\r\n", + footer_body, "\r\n", + tail_boundary, "\r\n", + ] + self.queue.put("".join(message_parts)) + + self.queue.put('') + self.state = DATA_SENT + + def send_commit_confirmation(self): + """ + Call when there are > quorum 2XX responses received. Send commit + confirmations to all object nodes to finalize the PUT. + """ + if self.state == COMMIT_SENT: + raise ValueError("called send_commit_confirmation twice") + + self.state = DATA_ACKED + + if self.mime_boundary: + body = "put_commit_confirmation" + tail_boundary = ("--%s--" % (self.mime_boundary,)) + message_parts = [ + "X-Document: put commit\r\n", + "\r\n", + body, "\r\n", + tail_boundary, + ] + self.queue.put("".join(message_parts)) + + self.queue.put('') + self.state = COMMIT_SENT + + def _send_file(self, write_timeout, exception_handler): + """ + Method for a file PUT coro. Takes chunks from a queue and sends them + down a socket. + + If something goes wrong, the "failed" attribute will be set to true + and the exception handler will be called. + """ + while True: + chunk = self.queue.get() + if not self.failed: + to_send = "%x\r\n%s\r\n" % (len(chunk), chunk) + try: + with ChunkWriteTimeout(write_timeout): + self.conn.send(to_send) + except (Exception, ChunkWriteTimeout): + self.failed = True + exception_handler(self.conn.node, _('Object'), + _('Trying to write to %s') % self.path) + self.queue.task_done() + + @classmethod + def connect(cls, node, part, path, headers, conn_timeout, node_timeout, + chunked=False): + """ + Connect to a backend node and send the headers. + + :returns: Putter instance + + :raises: ConnectionTimeout if initial connection timed out + :raises: ResponseTimeout if header retrieval timed out + :raises: InsufficientStorage on 507 response from node + :raises: PutterConnectError on non-507 server error response from node + :raises: FooterNotSupported if need_metadata_footer is set but + backend node can't process footers + :raises: MultiphasePUTNotSupported if need_multiphase_support is + set but backend node can't handle multiphase PUT + """ + mime_boundary = "%.64x" % random.randint(0, 16 ** 64) + headers = HeaderKeyDict(headers) + # We're going to be adding some unknown amount of data to the + # request, so we can't use an explicit content length, and thus + # we must use chunked encoding. + headers['Transfer-Encoding'] = 'chunked' + headers['Expect'] = '100-continue' + if 'Content-Length' in headers: + headers['X-Backend-Obj-Content-Length'] = \ + headers.pop('Content-Length') + + headers['X-Backend-Obj-Multipart-Mime-Boundary'] = mime_boundary + + headers['X-Backend-Obj-Metadata-Footer'] = 'yes' + + headers['X-Backend-Obj-Multiphase-Commit'] = 'yes' + + start_time = time.time() + with ConnectionTimeout(conn_timeout): + conn = http_connect(node['ip'], node['port'], node['device'], + part, 'PUT', path, headers) + connect_duration = time.time() - start_time + + with ResponseTimeout(node_timeout): + resp = conn.getexpect() + + if resp.status == HTTP_INSUFFICIENT_STORAGE: + raise InsufficientStorage + + if is_server_error(resp.status): + raise PutterConnectError(resp.status) + + if is_informational(resp.status): + continue_headers = HeaderKeyDict(resp.getheaders()) + can_send_metadata_footer = config_true_value( + continue_headers.get('X-Obj-Metadata-Footer', 'no')) + can_handle_multiphase_put = config_true_value( + continue_headers.get('X-Obj-Multiphase-Commit', 'no')) + + if not can_send_metadata_footer: + raise FooterNotSupported() + + if not can_handle_multiphase_put: + raise MultiphasePUTNotSupported() + + conn.node = node + conn.resp = None + if is_success(resp.status) or resp.status == HTTP_CONFLICT: + conn.resp = resp + elif (headers.get('If-None-Match', None) is not None and + resp.status == HTTP_PRECONDITION_FAILED): + conn.resp = resp + + return cls(conn, node, resp, path, connect_duration, mime_boundary) + + +def chunk_transformer(policy, nstreams): + segment_size = policy.ec_segment_size + + buf = collections.deque() + total_buf_len = 0 + + chunk = yield + while chunk: + buf.append(chunk) + total_buf_len += len(chunk) + if total_buf_len >= segment_size: + chunks_to_encode = [] + # extract as many chunks as we can from the input buffer + while total_buf_len >= segment_size: + to_take = segment_size + pieces = [] + while to_take > 0: + piece = buf.popleft() + if len(piece) > to_take: + buf.appendleft(piece[to_take:]) + piece = piece[:to_take] + pieces.append(piece) + to_take -= len(piece) + total_buf_len -= len(piece) + chunks_to_encode.append(''.join(pieces)) + + frags_by_byte_order = [] + for chunk_to_encode in chunks_to_encode: + frags_by_byte_order.append( + policy.pyeclib_driver.encode(chunk_to_encode)) + # Sequential calls to encode() have given us a list that + # looks like this: + # + # [[frag_A1, frag_B1, frag_C1, ...], + # [frag_A2, frag_B2, frag_C2, ...], ...] + # + # What we need is a list like this: + # + # [(frag_A1 + frag_A2 + ...), # destined for node A + # (frag_B1 + frag_B2 + ...), # destined for node B + # (frag_C1 + frag_C2 + ...), # destined for node C + # ...] + obj_data = [''.join(frags) + for frags in zip(*frags_by_byte_order)] + chunk = yield obj_data + else: + # didn't have enough data to encode + chunk = yield None + + # Now we've gotten an empty chunk, which indicates end-of-input. + # Take any leftover bytes and encode them. + last_bytes = ''.join(buf) + if last_bytes: + last_frags = policy.pyeclib_driver.encode(last_bytes) + yield last_frags + else: + yield [''] * nstreams + + +def trailing_metadata(policy, client_obj_hasher, + bytes_transferred_from_client, + fragment_archive_index): + return { + # etag and size values are being added twice here. + # The container override header is used to update the container db + # with these values as they represent the correct etag and size for + # the whole object and not just the FA. + # The object sysmeta headers will be saved on each FA of the object. + 'X-Object-Sysmeta-EC-Etag': client_obj_hasher.hexdigest(), + 'X-Object-Sysmeta-EC-Content-Length': + str(bytes_transferred_from_client), + 'X-Backend-Container-Update-Override-Etag': + client_obj_hasher.hexdigest(), + 'X-Backend-Container-Update-Override-Size': + str(bytes_transferred_from_client), + 'X-Object-Sysmeta-Ec-Frag-Index': str(fragment_archive_index), + # These fields are for debuggability, + # AKA "what is this thing?" + 'X-Object-Sysmeta-EC-Scheme': policy.ec_scheme_description, + 'X-Object-Sysmeta-EC-Segment-Size': str(policy.ec_segment_size), + } + + +@ObjectControllerRouter.register(EC_POLICY) +class ECObjectController(BaseObjectController): + + def _get_or_head_response(self, req, node_iter, partition, policy): + req.headers.setdefault("X-Backend-Etag-Is-At", + "X-Object-Sysmeta-Ec-Etag") + + if req.method == 'HEAD': + # no fancy EC decoding here, just one plain old HEAD request to + # one object server because all fragments hold all metadata + # information about the object. + resp = self.GETorHEAD_base( + req, _('Object'), node_iter, partition, + req.swift_entity_path) + else: # GET request + orig_range = None + range_specs = [] + if req.range: + orig_range = req.range + # Since segments and fragments have different sizes, we need + # to modify the Range header sent to the object servers to + # make sure we get the right fragments out of the fragment + # archives. + segment_size = policy.ec_segment_size + fragment_size = policy.fragment_size + + range_specs = [] + new_ranges = [] + for client_start, client_end in req.range.ranges: + + segment_start, segment_end = client_range_to_segment_range( + client_start, client_end, segment_size) + + fragment_start, fragment_end = \ + segment_range_to_fragment_range( + segment_start, segment_end, + segment_size, fragment_size) + + new_ranges.append((fragment_start, fragment_end)) + range_specs.append({'client_start': client_start, + 'client_end': client_end, + 'segment_start': segment_start, + 'segment_end': segment_end}) + + req.range = "bytes=" + ",".join( + "%s-%s" % (s if s is not None else "", + e if e is not None else "") + for s, e in new_ranges) + + node_iter = GreenthreadSafeIterator(node_iter) + num_gets = policy.ec_ndata + with ContextPool(num_gets) as pool: + pile = GreenAsyncPile(pool) + for _junk in range(num_gets): + pile.spawn(self.GETorHEAD_base, + req, 'Object', node_iter, partition, + req.swift_entity_path, + client_chunk_size=policy.fragment_size) + + responses = list(pile) + good_responses = [] + bad_responses = [] + for response in responses: + if is_success(response.status_int): + good_responses.append(response) + else: + bad_responses.append(response) + + req.range = orig_range + if len(good_responses) == num_gets: + # If these aren't all for the same object, then error out so + # at least the client doesn't get garbage. We can do a lot + # better here with more work, but this'll work for now. + found_obj_etags = set( + resp.headers['X-Object-Sysmeta-Ec-Etag'] + for resp in good_responses) + if len(found_obj_etags) > 1: + self.app.logger.debug( + "Returning 503 for %s; found too many etags (%s)", + req.path, + ", ".join(found_obj_etags)) + return HTTPServiceUnavailable(request=req) + + # we found enough pieces to decode the object, so now let's + # decode the object + resp_headers = HeaderKeyDict(good_responses[0].headers.items()) + resp_headers.pop('Content-Range', None) + eccl = resp_headers.get('X-Object-Sysmeta-Ec-Content-Length') + obj_length = int(eccl) if eccl is not None else None + + resp = Response( + request=req, + headers=resp_headers, + conditional_response=True, + app_iter=ECAppIter( + req.swift_entity_path, + policy, + [r.app_iter for r in good_responses], + range_specs, + obj_length, + logger=self.app.logger)) + else: + resp = self.best_response( + req, + [r.status_int for r in bad_responses], + [r.status.split(' ', 1)[1] for r in bad_responses], + [r.body for r in bad_responses], + 'Object', + headers=[r.headers for r in bad_responses]) + + self._fix_response_headers(resp) + return resp + + def _fix_response_headers(self, resp): + # EC fragment archives each have different bytes, hence different + # etags. However, they all have the original object's etag stored in + # sysmeta, so we copy that here so the client gets it. + resp.headers['Etag'] = resp.headers.get( + 'X-Object-Sysmeta-Ec-Etag') + resp.headers['Content-Length'] = resp.headers.get( + 'X-Object-Sysmeta-Ec-Content-Length') + + return resp + + def _connect_put_node(self, node_iter, part, path, headers, + logger_thread_locals): + """ + Make a connection for a erasure encoded object. + + Connects to the first working node that it finds in node_iter and sends + over the request headers. Returns a Putter to handle the rest of the + streaming, or None if no working nodes were found. + """ + # the object server will get different bytes, so these + # values do not apply (Content-Length might, in general, but + # in the specific case of replication vs. EC, it doesn't). + headers.pop('Content-Length', None) + headers.pop('Etag', None) + + self.app.logger.thread_locals = logger_thread_locals + for node in node_iter: + try: + putter = ECPutter.connect( + node, part, path, headers, + conn_timeout=self.app.conn_timeout, + node_timeout=self.app.node_timeout) + self.app.set_node_timing(node, putter.connect_duration) + return putter + except InsufficientStorage: + self.app.error_limit(node, _('ERROR Insufficient Storage')) + except PutterConnectError as e: + self.app.error_occurred( + node, _('ERROR %(status)d Expect: 100-continue ' + 'From Object Server') % { + 'status': e.status}) + except (Exception, Timeout): + self.app.exception_occurred( + node, _('Object'), + _('Expect: 100-continue on %s') % path) + + def _determine_chunk_destinations(self, putters): + """ + Given a list of putters, return a dict where the key is the putter + and the value is the node index to use. + + This is done so that we line up handoffs using the same node index + (in the primary part list) as the primary that the handoff is standing + in for. This lets erasure-code fragment archives wind up on the + preferred local primary nodes when possible. + """ + # Give each putter a "chunk index": the index of the + # transformed chunk that we'll send to it. + # + # For primary nodes, that's just its index (primary 0 gets + # chunk 0, primary 1 gets chunk 1, and so on). For handoffs, + # we assign the chunk index of a missing primary. + handoff_conns = [] + chunk_index = {} + for p in putters: + if p.node_index is not None: + chunk_index[p] = p.node_index + else: + handoff_conns.append(p) + + # Note: we may have more holes than handoffs. This is okay; it + # just means that we failed to connect to one or more storage + # nodes. Holes occur when a storage node is down, in which + # case the connection is not replaced, and when a storage node + # returns 507, in which case a handoff is used to replace it. + holes = [x for x in range(len(putters)) + if x not in chunk_index.values()] + + for hole, p in zip(holes, handoff_conns): + chunk_index[p] = hole + return chunk_index + + def _transfer_data(self, req, policy, data_source, putters, nodes, + min_conns, etag_hasher): + """ + Transfer data for an erasure coded object. + + This method was added in the PUT method extraction change + """ + bytes_transferred = 0 + chunk_transform = chunk_transformer(policy, len(nodes)) + chunk_transform.send(None) + + def send_chunk(chunk): + if etag_hasher: + etag_hasher.update(chunk) + backend_chunks = chunk_transform.send(chunk) + if backend_chunks is None: + # If there's not enough bytes buffered for erasure-encoding + # or whatever we're doing, the transform will give us None. + return + + for putter in list(putters): + backend_chunk = backend_chunks[chunk_index[putter]] + if not putter.failed: + putter.chunk_hasher.update(backend_chunk) + putter.send_chunk(backend_chunk) + else: + putters.remove(putter) + self._check_min_conn( + req, putters, min_conns, msg='Object PUT exceptions during' + ' send, %(conns)s/%(nodes)s required connections') + + try: + with ContextPool(len(putters)) as pool: + + # build our chunk index dict to place handoffs in the + # same part nodes index as the primaries they are covering + chunk_index = self._determine_chunk_destinations(putters) + + for putter in putters: + putter.spawn_sender_greenthread( + pool, self.app.put_queue_depth, self.app.node_timeout, + self.app.exception_occurred) + while True: + with ChunkReadTimeout(self.app.client_timeout): + try: + chunk = next(data_source) + except StopIteration: + computed_etag = (etag_hasher.hexdigest() + if etag_hasher else None) + received_etag = req.headers.get( + 'etag', '').strip('"') + if (computed_etag and received_etag and + computed_etag != received_etag): + raise HTTPUnprocessableEntity(request=req) + + send_chunk('') # flush out any buffered data + + for putter in putters: + trail_md = trailing_metadata( + policy, etag_hasher, + bytes_transferred, + chunk_index[putter]) + trail_md['Etag'] = \ + putter.chunk_hasher.hexdigest() + putter.end_of_object_data(trail_md) + break + bytes_transferred += len(chunk) + if bytes_transferred > constraints.MAX_FILE_SIZE: + raise HTTPRequestEntityTooLarge(request=req) + + send_chunk(chunk) + + for putter in putters: + putter.wait() + + # for storage policies requiring 2-phase commit (e.g. + # erasure coding), enforce >= 'quorum' number of + # 100-continue responses - this indicates successful + # object data and metadata commit and is a necessary + # condition to be met before starting 2nd PUT phase + final_phase = False + need_quorum = True + statuses, reasons, bodies, _junk, quorum = \ + self._get_put_responses( + req, putters, len(nodes), final_phase, + min_conns, need_quorum=need_quorum) + if not quorum: + self.app.logger.error( + _('Not enough object servers ack\'ed (got %d)'), + statuses.count(HTTP_CONTINUE)) + raise HTTPServiceUnavailable(request=req) + # quorum achieved, start 2nd phase - send commit + # confirmation to participating object servers + # so they write a .durable state file indicating + # a successful PUT + for putter in putters: + putter.send_commit_confirmation() + for putter in putters: + putter.wait() + except ChunkReadTimeout as err: + self.app.logger.warn( + _('ERROR Client read timeout (%ss)'), err.seconds) + self.app.logger.increment('client_timeouts') + raise HTTPRequestTimeout(request=req) + except HTTPException: + raise + except (Exception, Timeout): + self.app.logger.exception( + _('ERROR Exception causing client disconnect')) + raise HTTPClientDisconnect(request=req) + if req.content_length and bytes_transferred < req.content_length: + req.client_disconnect = True + self.app.logger.warn( + _('Client disconnected without sending enough data')) + self.app.logger.increment('client_disconnects') + raise HTTPClientDisconnect(request=req) + + def _have_adequate_successes(self, statuses, min_responses): + """ + Given a list of statuses from several requests, determine if a + satisfactory number of nodes have responded with 2xx statuses to + deem the transaction for a succssful response to the client. + + :param statuses: list of statuses returned so far + :param min_responses: minimal pass criterion for number of successes + :returns: True or False, depending on current number of successes + """ + if sum(1 for s in statuses if is_success(s)) >= min_responses: + return True + return False + + def _await_response(self, conn, final_phase): + return conn.await_response( + self.app.node_timeout, not final_phase) + + def _get_conn_response(self, conn, req, final_phase, **kwargs): + try: + resp = self._await_response(conn, final_phase=final_phase, + **kwargs) + except (Exception, Timeout): + resp = None + if final_phase: + status_type = 'final' + else: + status_type = 'commit' + self.app.exception_occurred( + conn.node, _('Object'), + _('Trying to get %s status of PUT to %s') % ( + status_type, req.path)) + return (conn, resp) + + def _get_put_responses(self, req, putters, num_nodes, final_phase, + min_responses, need_quorum=True): + """ + Collect erasure coded object responses. + + Collect object responses to a PUT request and determine if + satisfactory number of nodes have returned success. Return + statuses, quorum result if indicated by 'need_quorum' and + etags if this is a final phase or a multiphase PUT transaction. + + :param req: the request + :param putters: list of putters for the request + :param num_nodes: number of nodes involved + :param final_phase: boolean indicating if this is the last phase + :param min_responses: minimum needed when not requiring quorum + :param need_quorum: boolean indicating if quorum is required + """ + statuses = [] + reasons = [] + bodies = [] + etags = set() + + pile = GreenAsyncPile(len(putters)) + for putter in putters: + if putter.failed: + continue + pile.spawn(self._get_conn_response, putter, req, + final_phase=final_phase) + + def _handle_response(putter, response): + statuses.append(response.status) + reasons.append(response.reason) + if final_phase: + body = response.read() + bodies.append(body) + else: + body = '' + if response.status == HTTP_INSUFFICIENT_STORAGE: + putter.failed = True + self.app.error_limit(putter.node, + _('ERROR Insufficient Storage')) + elif response.status >= HTTP_INTERNAL_SERVER_ERROR: + putter.failed = True + self.app.error_occurred( + putter.node, + _('ERROR %(status)d %(body)s From Object Server ' + 're: %(path)s') % + {'status': response.status, + 'body': body[:1024], 'path': req.path}) + elif is_success(response.status): + etags.add(response.getheader('etag').strip('"')) + + quorum = False + for (putter, response) in pile: + if response: + _handle_response(putter, response) + if self._have_adequate_successes(statuses, min_responses): + break + else: + putter.failed = True + + # give any pending requests *some* chance to finish + finished_quickly = pile.waitall(self.app.post_quorum_timeout) + for (putter, response) in finished_quickly: + if response: + _handle_response(putter, response) + + if need_quorum: + if final_phase: + while len(statuses) < num_nodes: + statuses.append(HTTP_SERVICE_UNAVAILABLE) + reasons.append('') + bodies.append('') + else: + # intermediate response phase - set return value to true only + # if there are enough 100-continue acknowledgements + if self.have_quorum(statuses, num_nodes): + quorum = True + + return statuses, reasons, bodies, etags, quorum + + def _store_object(self, req, data_source, nodes, partition, + outgoing_headers): + """ + Store an erasure coded object. + """ + policy_index = int(req.headers.get('X-Backend-Storage-Policy-Index')) + policy = POLICIES.get_by_index(policy_index) + # Since the request body sent from client -> proxy is not + # the same as the request body sent proxy -> object, we + # can't rely on the object-server to do the etag checking - + # so we have to do it here. + etag_hasher = md5() + + min_conns = policy.quorum + putters = self._get_put_connections( + req, nodes, partition, outgoing_headers, + policy, expect=True) + + try: + # check that a minimum number of connections were established and + # meet all the correct conditions set in the request + self._check_failure_put_connections(putters, req, nodes, min_conns) + + self._transfer_data(req, policy, data_source, putters, + nodes, min_conns, etag_hasher) + final_phase = True + need_quorum = False + min_resp = 2 + putters = [p for p in putters if not p.failed] + # ignore response etags, and quorum boolean + statuses, reasons, bodies, _etags, _quorum = \ + self._get_put_responses(req, putters, len(nodes), + final_phase, min_resp, + need_quorum=need_quorum) + except HTTPException as resp: + return resp + + etag = etag_hasher.hexdigest() + resp = self.best_response(req, statuses, reasons, bodies, + _('Object PUT'), etag=etag, + quorum_size=min_conns) + resp.last_modified = math.ceil( + float(Timestamp(req.headers['X-Timestamp']))) + return resp diff --git a/swift/proxy/server.py b/swift/proxy/server.py index 28d41df554..b631542f60 100644 --- a/swift/proxy/server.py +++ b/swift/proxy/server.py @@ -20,6 +20,8 @@ from swift import gettext_ as _ from random import shuffle from time import time import itertools +import functools +import sys from eventlet import Timeout @@ -31,12 +33,14 @@ from swift.common.utils import cache_from_env, get_logger, \ get_remote_client, split_path, config_true_value, generate_trans_id, \ affinity_key_function, affinity_locality_predicate, list_from_csv, \ register_swift_info -from swift.common.constraints import check_utf8 -from swift.proxy.controllers import AccountController, ObjectController, \ - ContainerController, InfoController +from swift.common.constraints import check_utf8, valid_api_version +from swift.proxy.controllers import AccountController, ContainerController, \ + ObjectControllerRouter, InfoController +from swift.proxy.controllers.base import get_container_info from swift.common.swob import HTTPBadRequest, HTTPForbidden, \ HTTPMethodNotAllowed, HTTPNotFound, HTTPPreconditionFailed, \ - HTTPServerError, HTTPException, Request + HTTPServerError, HTTPException, Request, HTTPServiceUnavailable +from swift.common.exceptions import APIVersionError # List of entry points for mandatory middlewares. @@ -109,6 +113,7 @@ class Application(object): # ensure rings are loaded for all configured storage policies for policy in POLICIES: policy.load_ring(swift_dir) + self.obj_controller_router = ObjectControllerRouter() self.memcache = memcache mimetypes.init(mimetypes.knownfiles + [os.path.join(swift_dir, 'mime.types')]) @@ -206,7 +211,7 @@ class Application(object): self.expose_info = config_true_value( conf.get('expose_info', 'yes')) self.disallowed_sections = list_from_csv( - conf.get('disallowed_sections')) + conf.get('disallowed_sections', 'swift.valid_api_versions')) self.admin_key = conf.get('admin_key', None) register_swift_info( version=swift_version, @@ -235,29 +240,46 @@ class Application(object): """ return POLICIES.get_object_ring(policy_idx, self.swift_dir) - def get_controller(self, path): + def get_controller(self, req): """ Get the controller to handle a request. - :param path: path from request + :param req: the request :returns: tuple of (controller class, path dictionary) :raises: ValueError (thrown by split_path) if given invalid path """ - if path == '/info': + if req.path == '/info': d = dict(version=None, expose_info=self.expose_info, disallowed_sections=self.disallowed_sections, admin_key=self.admin_key) return InfoController, d - version, account, container, obj = split_path(path, 1, 4, True) + version, account, container, obj = split_path(req.path, 1, 4, True) d = dict(version=version, account_name=account, container_name=container, object_name=obj) + if account and not valid_api_version(version): + raise APIVersionError('Invalid path') if obj and container and account: - return ObjectController, d + info = get_container_info(req.environ, self) + policy_index = req.headers.get('X-Backend-Storage-Policy-Index', + info['storage_policy']) + policy = POLICIES.get_by_index(policy_index) + if not policy: + # This indicates that a new policy has been created, + # with rings, deployed, released (i.e. deprecated = + # False), used by a client to create a container via + # another proxy that was restarted after the policy + # was released, and is now cached - all before this + # worker was HUPed to stop accepting new + # connections. There should never be an "unknown" + # index - but when there is - it's probably operator + # error and hopefully temporary. + raise HTTPServiceUnavailable('Unknown Storage Policy') + return self.obj_controller_router[policy], d elif container and account: return ContainerController, d elif account and not container and not obj: @@ -317,10 +339,13 @@ class Application(object): request=req, body='Invalid UTF8 or contains NULL') try: - controller, path_parts = self.get_controller(req.path) + controller, path_parts = self.get_controller(req) p = req.path_info if isinstance(p, unicode): p = p.encode('utf-8') + except APIVersionError: + self.logger.increment('errors') + return HTTPBadRequest(request=req) except ValueError: self.logger.increment('errors') return HTTPNotFound(request=req) @@ -474,9 +499,9 @@ class Application(object): def iter_nodes(self, ring, partition, node_iter=None): """ Yields nodes for a ring partition, skipping over error - limited nodes and stopping at the configurable number of - nodes. If a node yielded subsequently gets error limited, an - extra node will be yielded to take its place. + limited nodes and stopping at the configurable number of nodes. If a + node yielded subsequently gets error limited, an extra node will be + yielded to take its place. Note that if you're going to iterate over this concurrently from multiple greenthreads, you'll want to use a @@ -527,7 +552,8 @@ class Application(object): if nodes_left <= 0: return - def exception_occurred(self, node, typ, additional_info): + def exception_occurred(self, node, typ, additional_info, + **kwargs): """ Handle logging of generic exceptions. @@ -536,11 +562,18 @@ class Application(object): :param additional_info: additional information to log """ self._incr_node_errors(node) - self.logger.exception( - _('ERROR with %(type)s server %(ip)s:%(port)s/%(device)s re: ' - '%(info)s'), - {'type': typ, 'ip': node['ip'], 'port': node['port'], - 'device': node['device'], 'info': additional_info}) + if 'level' in kwargs: + log = functools.partial(self.logger.log, kwargs.pop('level')) + if 'exc_info' not in kwargs: + kwargs['exc_info'] = sys.exc_info() + else: + log = self.logger.exception + log(_('ERROR with %(type)s server %(ip)s:%(port)s/%(device)s' + ' re: %(info)s'), { + 'type': typ, 'ip': node['ip'], 'port': + node['port'], 'device': node['device'], + 'info': additional_info + }, **kwargs) def modify_wsgi_pipeline(self, pipe): """ diff --git a/test/functional/__init__.py b/test/functional/__init__.py index c4d7642680..73e5006638 100644 --- a/test/functional/__init__.py +++ b/test/functional/__init__.py @@ -23,6 +23,7 @@ import eventlet import eventlet.debug import functools import random +from ConfigParser import ConfigParser, NoSectionError from time import time, sleep from httplib import HTTPException from urlparse import urlparse @@ -32,6 +33,7 @@ from gzip import GzipFile from shutil import rmtree from tempfile import mkdtemp from swift.common.middleware.memcache import MemcacheMiddleware +from swift.common.storage_policy import parse_storage_policies, PolicyError from test import get_config from test.functional.swift_test_client import Account, Connection, \ @@ -50,6 +52,9 @@ from swift.container import server as container_server from swift.obj import server as object_server, mem_server as mem_object_server import swift.proxy.controllers.obj + +DEBUG = True + # In order to get the proper blocking behavior of sockets without using # threads, where we can set an arbitrary timeout for some piece of code under # test, we use eventlet with the standard socket library patched. We have to @@ -99,7 +104,7 @@ orig_hash_path_suff_pref = ('', '') orig_swift_conf_name = None in_process = False -_testdir = _test_servers = _test_sockets = _test_coros = None +_testdir = _test_servers = _test_coros = None class FakeMemcacheMiddleware(MemcacheMiddleware): @@ -113,29 +118,187 @@ class FakeMemcacheMiddleware(MemcacheMiddleware): self.memcache = FakeMemcache() -# swift.conf contents for in-process functional test runs -functests_swift_conf = ''' -[swift-hash] -swift_hash_path_suffix = inprocfunctests -swift_hash_path_prefix = inprocfunctests +class InProcessException(BaseException): + pass -[swift-constraints] -max_file_size = %d -''' % ((8 * 1024 * 1024) + 2) # 8 MB + 2 + +def _info(msg): + print >> sys.stderr, msg + + +def _debug(msg): + if DEBUG: + _info('DEBUG: ' + msg) + + +def _in_process_setup_swift_conf(swift_conf_src, testdir): + # override swift.conf contents for in-process functional test runs + conf = ConfigParser() + conf.read(swift_conf_src) + try: + section = 'swift-hash' + conf.set(section, 'swift_hash_path_suffix', 'inprocfunctests') + conf.set(section, 'swift_hash_path_prefix', 'inprocfunctests') + section = 'swift-constraints' + max_file_size = (8 * 1024 * 1024) + 2 # 8 MB + 2 + conf.set(section, 'max_file_size', max_file_size) + except NoSectionError: + msg = 'Conf file %s is missing section %s' % (swift_conf_src, section) + raise InProcessException(msg) + + test_conf_file = os.path.join(testdir, 'swift.conf') + with open(test_conf_file, 'w') as fp: + conf.write(fp) + + return test_conf_file + + +def _in_process_find_conf_file(conf_src_dir, conf_file_name, use_sample=True): + """ + Look for a file first in conf_src_dir, if it exists, otherwise optionally + look in the source tree sample 'etc' dir. + + :param conf_src_dir: Directory in which to search first for conf file. May + be None + :param conf_file_name: Name of conf file + :param use_sample: If True and the conf_file_name is not found, then return + any sample conf file found in the source tree sample + 'etc' dir by appending '-sample' to conf_file_name + :returns: Path to conf file + :raises InProcessException: If no conf file is found + """ + dflt_src_dir = os.path.normpath(os.path.join(os.path.abspath(__file__), + os.pardir, os.pardir, os.pardir, + 'etc')) + conf_src_dir = dflt_src_dir if conf_src_dir is None else conf_src_dir + conf_file_path = os.path.join(conf_src_dir, conf_file_name) + if os.path.exists(conf_file_path): + return conf_file_path + + if use_sample: + # fall back to using the corresponding sample conf file + conf_file_name += '-sample' + conf_file_path = os.path.join(dflt_src_dir, conf_file_name) + if os.path.exists(conf_file_path): + return conf_file_path + + msg = 'Failed to find config file %s' % conf_file_name + raise InProcessException(msg) + + +def _in_process_setup_ring(swift_conf, conf_src_dir, testdir): + """ + If SWIFT_TEST_POLICY is set: + - look in swift.conf file for specified policy + - move this to be policy-0 but preserving its options + - copy its ring file to test dir, changing its devices to suit + in process testing, and renaming it to suit policy-0 + Otherwise, create a default ring file. + """ + conf = ConfigParser() + conf.read(swift_conf) + sp_prefix = 'storage-policy:' + + try: + # policy index 0 will be created if no policy exists in conf + policies = parse_storage_policies(conf) + except PolicyError as e: + raise InProcessException(e) + + # clear all policies from test swift.conf before adding test policy back + for policy in policies: + conf.remove_section(sp_prefix + str(policy.idx)) + + policy_specified = os.environ.get('SWIFT_TEST_POLICY') + if policy_specified: + policy_to_test = policies.get_by_name(policy_specified) + if policy_to_test is None: + raise InProcessException('Failed to find policy name "%s"' + % policy_specified) + _info('Using specified policy %s' % policy_to_test.name) + else: + policy_to_test = policies.default + _info('Defaulting to policy %s' % policy_to_test.name) + + # make policy_to_test be policy index 0 and default for the test config + sp_zero_section = sp_prefix + '0' + conf.add_section(sp_zero_section) + for (k, v) in policy_to_test.get_info(config=True).items(): + conf.set(sp_zero_section, k, v) + conf.set(sp_zero_section, 'default', True) + + with open(swift_conf, 'w') as fp: + conf.write(fp) + + # look for a source ring file + ring_file_src = ring_file_test = 'object.ring.gz' + if policy_to_test.idx: + ring_file_src = 'object-%s.ring.gz' % policy_to_test.idx + try: + ring_file_src = _in_process_find_conf_file(conf_src_dir, ring_file_src, + use_sample=False) + except InProcessException as e: + if policy_specified: + raise InProcessException('Failed to find ring file %s' + % ring_file_src) + ring_file_src = None + + ring_file_test = os.path.join(testdir, ring_file_test) + if ring_file_src: + # copy source ring file to a policy-0 test ring file, re-homing servers + _info('Using source ring file %s' % ring_file_src) + ring_data = ring.RingData.load(ring_file_src) + obj_sockets = [] + for dev in ring_data.devs: + device = 'sd%c1' % chr(len(obj_sockets) + ord('a')) + utils.mkdirs(os.path.join(_testdir, 'sda1')) + utils.mkdirs(os.path.join(_testdir, 'sda1', 'tmp')) + obj_socket = eventlet.listen(('localhost', 0)) + obj_sockets.append(obj_socket) + dev['port'] = obj_socket.getsockname()[1] + dev['ip'] = '127.0.0.1' + dev['device'] = device + dev['replication_port'] = dev['port'] + dev['replication_ip'] = dev['ip'] + ring_data.save(ring_file_test) + else: + # make default test ring, 2 replicas, 4 partitions, 2 devices + _info('No source object ring file, creating 2rep/4part/2dev ring') + obj_sockets = [eventlet.listen(('localhost', 0)) for _ in (0, 1)] + ring_data = ring.RingData( + [[0, 1, 0, 1], [1, 0, 1, 0]], + [{'id': 0, 'zone': 0, 'device': 'sda1', 'ip': '127.0.0.1', + 'port': obj_sockets[0].getsockname()[1]}, + {'id': 1, 'zone': 1, 'device': 'sdb1', 'ip': '127.0.0.1', + 'port': obj_sockets[1].getsockname()[1]}], + 30) + with closing(GzipFile(ring_file_test, 'wb')) as f: + pickle.dump(ring_data, f) + + for dev in ring_data.devs: + _debug('Ring file dev: %s' % dev) + + return obj_sockets def in_process_setup(the_object_server=object_server): - print >>sys.stderr, 'IN-PROCESS SERVERS IN USE FOR FUNCTIONAL TESTS' - print >>sys.stderr, 'Using object_server: %s' % the_object_server.__name__ - _dir = os.path.normpath(os.path.join(os.path.abspath(__file__), - os.pardir, os.pardir, os.pardir)) - proxy_conf = os.path.join(_dir, 'etc', 'proxy-server.conf-sample') - if os.path.exists(proxy_conf): - print >>sys.stderr, 'Using proxy-server config from %s' % proxy_conf + _info('IN-PROCESS SERVERS IN USE FOR FUNCTIONAL TESTS') + _info('Using object_server class: %s' % the_object_server.__name__) + conf_src_dir = os.environ.get('SWIFT_TEST_IN_PROCESS_CONF_DIR') - else: - print >>sys.stderr, 'Failed to find conf file %s' % proxy_conf - return + if conf_src_dir is not None: + if not os.path.isdir(conf_src_dir): + msg = 'Config source %s is not a dir' % conf_src_dir + raise InProcessException(msg) + _info('Using config source dir: %s' % conf_src_dir) + + # If SWIFT_TEST_IN_PROCESS_CONF specifies a config source dir then + # prefer config files from there, otherwise read config from source tree + # sample files. A mixture of files from the two sources is allowed. + proxy_conf = _in_process_find_conf_file(conf_src_dir, 'proxy-server.conf') + _info('Using proxy config from %s' % proxy_conf) + swift_conf_src = _in_process_find_conf_file(conf_src_dir, 'swift.conf') + _info('Using swift config from %s' % swift_conf_src) monkey_patch_mimetools() @@ -148,9 +311,8 @@ def in_process_setup(the_object_server=object_server): utils.mkdirs(os.path.join(_testdir, 'sdb1')) utils.mkdirs(os.path.join(_testdir, 'sdb1', 'tmp')) - swift_conf = os.path.join(_testdir, "swift.conf") - with open(swift_conf, "w") as scfp: - scfp.write(functests_swift_conf) + swift_conf = _in_process_setup_swift_conf(swift_conf_src, _testdir) + obj_sockets = _in_process_setup_ring(swift_conf, conf_src_dir, _testdir) global orig_swift_conf_name orig_swift_conf_name = utils.SWIFT_CONF_FILE @@ -221,11 +383,6 @@ def in_process_setup(the_object_server=object_server): acc2lis = eventlet.listen(('localhost', 0)) con1lis = eventlet.listen(('localhost', 0)) con2lis = eventlet.listen(('localhost', 0)) - obj1lis = eventlet.listen(('localhost', 0)) - obj2lis = eventlet.listen(('localhost', 0)) - global _test_sockets - _test_sockets = \ - (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, obj2lis) account_ring_path = os.path.join(_testdir, 'account.ring.gz') with closing(GzipFile(account_ring_path, 'wb')) as f: @@ -243,14 +400,6 @@ def in_process_setup(the_object_server=object_server): {'id': 1, 'zone': 1, 'device': 'sdb1', 'ip': '127.0.0.1', 'port': con2lis.getsockname()[1]}], 30), f) - object_ring_path = os.path.join(_testdir, 'object.ring.gz') - with closing(GzipFile(object_ring_path, 'wb')) as f: - pickle.dump(ring.RingData([[0, 1, 0, 1], [1, 0, 1, 0]], - [{'id': 0, 'zone': 0, 'device': 'sda1', 'ip': '127.0.0.1', - 'port': obj1lis.getsockname()[1]}, - {'id': 1, 'zone': 1, 'device': 'sdb1', 'ip': '127.0.0.1', - 'port': obj2lis.getsockname()[1]}], 30), - f) eventlet.wsgi.HttpProtocol.default_request_version = "HTTP/1.0" # Turn off logging requests by the underlying WSGI software. @@ -270,10 +419,13 @@ def in_process_setup(the_object_server=object_server): config, logger=debug_logger('cont1')) con2srv = container_server.ContainerController( config, logger=debug_logger('cont2')) - obj1srv = the_object_server.ObjectController( - config, logger=debug_logger('obj1')) - obj2srv = the_object_server.ObjectController( - config, logger=debug_logger('obj2')) + + objsrvs = [ + (obj_sockets[index], + the_object_server.ObjectController( + config, logger=debug_logger('obj%d' % (index + 1)))) + for index in range(len(obj_sockets)) + ] logger = debug_logger('proxy') @@ -283,7 +435,10 @@ def in_process_setup(the_object_server=object_server): with mock.patch('swift.common.utils.get_logger', get_logger): with mock.patch('swift.common.middleware.memcache.MemcacheMiddleware', FakeMemcacheMiddleware): - app = loadapp(proxy_conf, global_conf=config) + try: + app = loadapp(proxy_conf, global_conf=config) + except Exception as e: + raise InProcessException(e) nl = utils.NullLogger() prospa = eventlet.spawn(eventlet.wsgi.server, prolis, app, nl) @@ -291,11 +446,13 @@ def in_process_setup(the_object_server=object_server): acc2spa = eventlet.spawn(eventlet.wsgi.server, acc2lis, acc2srv, nl) con1spa = eventlet.spawn(eventlet.wsgi.server, con1lis, con1srv, nl) con2spa = eventlet.spawn(eventlet.wsgi.server, con2lis, con2srv, nl) - obj1spa = eventlet.spawn(eventlet.wsgi.server, obj1lis, obj1srv, nl) - obj2spa = eventlet.spawn(eventlet.wsgi.server, obj2lis, obj2srv, nl) + + objspa = [eventlet.spawn(eventlet.wsgi.server, objsrv[0], objsrv[1], nl) + for objsrv in objsrvs] + global _test_coros _test_coros = \ - (prospa, acc1spa, acc2spa, con1spa, con2spa, obj1spa, obj2spa) + (prospa, acc1spa, acc2spa, con1spa, con2spa) + tuple(objspa) # Create accounts "test" and "test2" def create_account(act): @@ -396,8 +553,13 @@ def setup_package(): if in_process: in_mem_obj_env = os.environ.get('SWIFT_TEST_IN_MEMORY_OBJ') in_mem_obj = utils.config_true_value(in_mem_obj_env) - in_process_setup(the_object_server=( - mem_object_server if in_mem_obj else object_server)) + try: + in_process_setup(the_object_server=( + mem_object_server if in_mem_obj else object_server)) + except InProcessException as exc: + print >> sys.stderr, ('Exception during in-process setup: %s' + % str(exc)) + raise global web_front_end web_front_end = config.get('web_front_end', 'integral') diff --git a/test/functional/tests.py b/test/functional/tests.py index 931f3640a5..95f168e6e8 100644 --- a/test/functional/tests.py +++ b/test/functional/tests.py @@ -1317,7 +1317,12 @@ class TestFile(Base): self.assertEqual(file_types, file_types_read) def testRangedGets(self): - file_length = 10000 + # We set the file_length to a strange multiple here. This is to check + # that ranges still work in the EC case when the requested range + # spans EC segment boundaries. The 1 MiB base value is chosen because + # that's a common EC segment size. The 1.33 multiple is to ensure we + # aren't aligned on segment boundaries + file_length = int(1048576 * 1.33) range_size = file_length / 10 file_item = self.env.container.file(Utils.create_name()) data = file_item.write_random(file_length) @@ -2409,6 +2414,14 @@ class TestObjectVersioningEnv(object): cls.account = Account(cls.conn, tf.config.get('account', tf.config['username'])) + # Second connection for ACL tests + config2 = deepcopy(tf.config) + config2['account'] = tf.config['account2'] + config2['username'] = tf.config['username2'] + config2['password'] = tf.config['password2'] + cls.conn2 = Connection(config2) + cls.conn2.authenticate() + # avoid getting a prefix that stops halfway through an encoded # character prefix = Utils.create_name().decode("utf-8")[:10].encode("utf-8") @@ -2462,6 +2475,14 @@ class TestCrossPolicyObjectVersioningEnv(object): cls.account = Account(cls.conn, tf.config.get('account', tf.config['username'])) + # Second connection for ACL tests + config2 = deepcopy(tf.config) + config2['account'] = tf.config['account2'] + config2['username'] = tf.config['username2'] + config2['password'] = tf.config['password2'] + cls.conn2 = Connection(config2) + cls.conn2.authenticate() + # avoid getting a prefix that stops halfway through an encoded # character prefix = Utils.create_name().decode("utf-8")[:10].encode("utf-8") @@ -2496,6 +2517,15 @@ class TestObjectVersioning(Base): "Expected versioning_enabled to be True/False, got %r" % (self.env.versioning_enabled,)) + def tearDown(self): + super(TestObjectVersioning, self).tearDown() + try: + # delete versions first! + self.env.versions_container.delete_files() + self.env.container.delete_files() + except ResponseError: + pass + def test_overwriting(self): container = self.env.container versions_container = self.env.versions_container @@ -2555,6 +2585,33 @@ class TestObjectVersioning(Base): self.assertEqual(3, versions_container.info()['object_count']) self.assertEqual("112233", man_file.read()) + def test_versioning_check_acl(self): + container = self.env.container + versions_container = self.env.versions_container + versions_container.create(hdrs={'X-Container-Read': '.r:*,.rlistings'}) + + obj_name = Utils.create_name() + versioned_obj = container.file(obj_name) + versioned_obj.write("aaaaa") + self.assertEqual("aaaaa", versioned_obj.read()) + + versioned_obj.write("bbbbb") + self.assertEqual("bbbbb", versioned_obj.read()) + + # Use token from second account and try to delete the object + org_token = self.env.account.conn.storage_token + self.env.account.conn.storage_token = self.env.conn2.storage_token + try: + self.assertRaises(ResponseError, versioned_obj.delete) + finally: + self.env.account.conn.storage_token = org_token + + # Verify with token from first account + self.assertEqual("bbbbb", versioned_obj.read()) + + versioned_obj.delete() + self.assertEqual("aaaaa", versioned_obj.read()) + class TestObjectVersioningUTF8(Base2, TestObjectVersioning): set_up = False @@ -2768,10 +2825,23 @@ class TestContainerTempurlEnv(object): cls.conn, tf.config.get('account', tf.config['username'])) cls.account.delete_containers() + # creating another account and connection + # for ACL tests + config2 = deepcopy(tf.config) + config2['account'] = tf.config['account2'] + config2['username'] = tf.config['username2'] + config2['password'] = tf.config['password2'] + cls.conn2 = Connection(config2) + cls.conn2.authenticate() + cls.account2 = Account( + cls.conn2, config2.get('account', config2['username'])) + cls.account2 = cls.conn2.get_account() + cls.container = cls.account.container(Utils.create_name()) if not cls.container.create({ 'x-container-meta-temp-url-key': cls.tempurl_key, - 'x-container-meta-temp-url-key-2': cls.tempurl_key2}): + 'x-container-meta-temp-url-key-2': cls.tempurl_key2, + 'x-container-read': cls.account2.name}): raise ResponseError(cls.conn.response) cls.obj = cls.container.file(Utils.create_name()) @@ -2914,6 +2984,28 @@ class TestContainerTempurl(Base): parms=parms) self.assert_status([401]) + def test_tempurl_keys_visible_to_account_owner(self): + if not tf.cluster_info.get('tempauth'): + raise SkipTest('TEMP AUTH SPECIFIC TEST') + metadata = self.env.container.info() + self.assertEqual(metadata.get('tempurl_key'), self.env.tempurl_key) + self.assertEqual(metadata.get('tempurl_key2'), self.env.tempurl_key2) + + def test_tempurl_keys_hidden_from_acl_readonly(self): + if not tf.cluster_info.get('tempauth'): + raise SkipTest('TEMP AUTH SPECIFIC TEST') + original_token = self.env.container.conn.storage_token + self.env.container.conn.storage_token = self.env.conn2.storage_token + metadata = self.env.container.info() + self.env.container.conn.storage_token = original_token + + self.assertTrue('tempurl_key' not in metadata, + 'Container TempURL key found, should not be visible ' + 'to readonly ACLs') + self.assertTrue('tempurl_key2' not in metadata, + 'Container TempURL key-2 found, should not be visible ' + 'to readonly ACLs') + class TestContainerTempurlUTF8(Base2, TestContainerTempurl): set_up = False diff --git a/test/probe/brain.py b/test/probe/brain.py index cbb5ef7cf9..9ca931aac1 100644 --- a/test/probe/brain.py +++ b/test/probe/brain.py @@ -67,7 +67,7 @@ class BrainSplitter(object): __metaclass__ = meta_command def __init__(self, url, token, container_name='test', object_name='test', - server_type='container'): + server_type='container', policy=None): self.url = url self.token = token self.account = utils.split_path(urlparse(url).path, 2, 2)[1] @@ -81,9 +81,26 @@ class BrainSplitter(object): o = object_name if server_type == 'object' else None c = container_name if server_type in ('object', 'container') else None - part, nodes = ring.Ring( - '/etc/swift/%s.ring.gz' % server_type).get_nodes( - self.account, c, o) + if server_type in ('container', 'account'): + if policy: + raise TypeError('Metadata server brains do not ' + 'support specific storage policies') + self.policy = None + self.ring = ring.Ring( + '/etc/swift/%s.ring.gz' % server_type) + elif server_type == 'object': + if not policy: + raise TypeError('Object BrainSplitters need to ' + 'specify the storage policy') + self.policy = policy + policy.load_ring('/etc/swift') + self.ring = policy.object_ring + else: + raise ValueError('Unkonwn server_type: %r' % server_type) + self.server_type = server_type + + part, nodes = self.ring.get_nodes(self.account, c, o) + node_ids = [n['id'] for n in nodes] if all(n_id in node_ids for n_id in (0, 1)): self.primary_numbers = (1, 2) @@ -172,6 +189,8 @@ parser.add_option('-o', '--object', default='object-%s' % uuid.uuid4(), help='set object name') parser.add_option('-s', '--server_type', default='container', help='set server type') +parser.add_option('-P', '--policy_name', default=None, + help='set policy') def main(): @@ -186,8 +205,17 @@ def main(): return 'ERROR: unknown command %s' % cmd url, token = get_auth('http://127.0.0.1:8080/auth/v1.0', 'test:tester', 'testing') + if options.server_type == 'object' and not options.policy_name: + options.policy_name = POLICIES.default.name + if options.policy_name: + options.server_type = 'object' + policy = POLICIES.get_by_name(options.policy_name) + if not policy: + return 'ERROR: unknown policy %r' % options.policy_name + else: + policy = None brain = BrainSplitter(url, token, options.container, options.object, - options.server_type) + options.server_type, policy=policy) for cmd_args in commands: parts = cmd_args.split(':', 1) command = parts[0] diff --git a/test/probe/common.py b/test/probe/common.py index 62988835c6..7d1e754014 100644 --- a/test/probe/common.py +++ b/test/probe/common.py @@ -24,15 +24,19 @@ from nose import SkipTest from swiftclient import get_auth, head_account +from swift.obj.diskfile import get_data_dir from swift.common.ring import Ring from swift.common.utils import readconf from swift.common.manager import Manager -from swift.common.storage_policy import POLICIES +from swift.common.storage_policy import POLICIES, EC_POLICY, REPL_POLICY from test.probe import CHECK_SERVER_TIMEOUT, VALIDATE_RSYNC ENABLED_POLICIES = [p for p in POLICIES if not p.is_deprecated] +POLICIES_BY_TYPE = defaultdict(list) +for p in POLICIES: + POLICIES_BY_TYPE[p.policy_type].append(p) def get_server_number(port, port2server): @@ -138,6 +142,17 @@ def kill_nonprimary_server(primary_nodes, port2server, pids): return port +def build_port_to_conf(server): + # map server to config by port + port_to_config = {} + for server_ in Manager([server]): + for config_path in server_.conf_files(): + conf = readconf(config_path, + section_name='%s-replicator' % server_.type) + port_to_config[int(conf['bind_port'])] = conf + return port_to_config + + def get_ring(ring_name, required_replicas, required_devices, server=None, force_validate=None): if not server: @@ -152,13 +167,7 @@ def get_ring(ring_name, required_replicas, required_devices, if len(ring.devs) != required_devices: raise SkipTest('%s has %s devices instead of %s' % ( ring.serialized_path, len(ring.devs), required_devices)) - # map server to config by port - port_to_config = {} - for server_ in Manager([server]): - for config_path in server_.conf_files(): - conf = readconf(config_path, - section_name='%s-replicator' % server_.type) - port_to_config[int(conf['bind_port'])] = conf + port_to_config = build_port_to_conf(server) for dev in ring.devs: # verify server is exposing mounted device conf = port_to_config[dev['port']] @@ -198,7 +207,8 @@ def get_ring(ring_name, required_replicas, required_devices, def get_policy(**kwargs): kwargs.setdefault('is_deprecated', False) - # go thru the policies and make sure they match the requirements of kwargs + # go through the policies and make sure they match the + # requirements of kwargs for policy in POLICIES: # TODO: for EC, pop policy type here and check it first matches = True @@ -261,6 +271,10 @@ class ProbeTest(unittest.TestCase): ['account-replicator', 'container-replicator', 'object-replicator']) self.updaters = Manager(['container-updater', 'object-updater']) + self.server_port_to_conf = {} + # get some configs backend daemon configs loaded up + for server in ('account', 'container', 'object'): + self.server_port_to_conf[server] = build_port_to_conf(server) except BaseException: try: raise @@ -273,6 +287,23 @@ class ProbeTest(unittest.TestCase): def tearDown(self): Manager(['all']).kill() + def device_dir(self, server, node): + conf = self.server_port_to_conf[server][node['port']] + return os.path.join(conf['devices'], node['device']) + + def storage_dir(self, server, node, part=None, policy=None): + policy = policy or self.policy + device_path = self.device_dir(server, node) + path_parts = [device_path, get_data_dir(policy)] + if part is not None: + path_parts.append(str(part)) + return os.path.join(*path_parts) + + def config_number(self, node): + _server_type, config_number = get_server_number( + node['port'], self.port2server) + return config_number + def get_to_final_state(self): # these .stop()s are probably not strictly necessary, # but may prevent race conditions @@ -290,7 +321,16 @@ class ReplProbeTest(ProbeTest): acct_cont_required_devices = 4 obj_required_replicas = 3 obj_required_devices = 4 - policy_requirements = {'is_default': True} + policy_requirements = {'policy_type': REPL_POLICY} + + +class ECProbeTest(ProbeTest): + + acct_cont_required_replicas = 3 + acct_cont_required_devices = 4 + obj_required_replicas = 6 + obj_required_devices = 8 + policy_requirements = {'policy_type': EC_POLICY} if __name__ == "__main__": diff --git a/test/probe/test_container_merge_policy_index.py b/test/probe/test_container_merge_policy_index.py index dd4e504778..d604b13716 100644 --- a/test/probe/test_container_merge_policy_index.py +++ b/test/probe/test_container_merge_policy_index.py @@ -26,7 +26,8 @@ from swift.common import utils, direct_client from swift.common.storage_policy import POLICIES from swift.common.http import HTTP_NOT_FOUND from test.probe.brain import BrainSplitter -from test.probe.common import ReplProbeTest, ENABLED_POLICIES +from test.probe.common import (ReplProbeTest, ENABLED_POLICIES, + POLICIES_BY_TYPE, REPL_POLICY) from swiftclient import client, ClientException @@ -234,6 +235,18 @@ class TestContainerMergePolicyIndex(ReplProbeTest): orig_policy_index, node)) def test_reconcile_manifest(self): + # this test is not only testing a split brain scenario on + # multiple policies with mis-placed objects - it even writes out + # a static large object directly to the storage nodes while the + # objects are unavailably mis-placed from *behind* the proxy and + # doesn't know how to do that for EC_POLICY (clayg: why did you + # guys let me write a test that does this!?) - so we force + # wrong_policy (where the manifest gets written) to be one of + # any of your configured REPL_POLICY (we know you have one + # because this is a ReplProbeTest) + wrong_policy = random.choice(POLICIES_BY_TYPE[REPL_POLICY]) + policy = random.choice([p for p in ENABLED_POLICIES + if p is not wrong_policy]) manifest_data = [] def write_part(i): @@ -250,17 +263,14 @@ class TestContainerMergePolicyIndex(ReplProbeTest): # get an old container stashed self.brain.stop_primary_half() - policy = random.choice(ENABLED_POLICIES) - self.brain.put_container(policy.idx) + self.brain.put_container(int(policy)) self.brain.start_primary_half() # write some parts for i in range(10): write_part(i) self.brain.stop_handoff_half() - wrong_policy = random.choice([p for p in ENABLED_POLICIES - if p is not policy]) - self.brain.put_container(wrong_policy.idx) + self.brain.put_container(int(wrong_policy)) # write some more parts for i in range(10, 20): write_part(i) diff --git a/test/probe/test_empty_device_handoff.py b/test/probe/test_empty_device_handoff.py index 7002fa4879..e0e450a4b4 100755 --- a/test/probe/test_empty_device_handoff.py +++ b/test/probe/test_empty_device_handoff.py @@ -44,7 +44,9 @@ class TestEmptyDevice(ReplProbeTest): def test_main(self): # Create container container = 'container-%s' % uuid4() - client.put_container(self.url, self.token, container) + client.put_container(self.url, self.token, container, + headers={'X-Storage-Policy': + self.policy.name}) cpart, cnodes = self.container_ring.get_nodes(self.account, container) cnode = cnodes[0] @@ -58,7 +60,7 @@ class TestEmptyDevice(ReplProbeTest): # Delete the default data directory for objects on the primary server obj_dir = '%s/%s' % (self._get_objects_dir(onode), - get_data_dir(self.policy.idx)) + get_data_dir(self.policy)) shutil.rmtree(obj_dir, True) self.assertFalse(os.path.exists(obj_dir)) diff --git a/test/probe/test_object_async_update.py b/test/probe/test_object_async_update.py index 34ec08253d..05d05b3adf 100755 --- a/test/probe/test_object_async_update.py +++ b/test/probe/test_object_async_update.py @@ -108,7 +108,9 @@ class TestUpdateOverrides(ReplProbeTest): 'X-Backend-Container-Update-Override-Etag': 'override-etag', 'X-Backend-Container-Update-Override-Content-Type': 'override-type' } - client.put_container(self.url, self.token, 'c1') + client.put_container(self.url, self.token, 'c1', + headers={'X-Storage-Policy': + self.policy.name}) self.int_client.upload_object(StringIO(u'stuff'), self.account, 'c1', 'o1', headers) diff --git a/test/probe/test_object_failures.py b/test/probe/test_object_failures.py index 9147b1ed5a..469683a10e 100755 --- a/test/probe/test_object_failures.py +++ b/test/probe/test_object_failures.py @@ -52,7 +52,9 @@ def get_data_file_path(obj_dir): class TestObjectFailures(ReplProbeTest): def _setup_data_file(self, container, obj, data): - client.put_container(self.url, self.token, container) + client.put_container(self.url, self.token, container, + headers={'X-Storage-Policy': + self.policy.name}) client.put_object(self.url, self.token, container, obj, data) odata = client.get_object(self.url, self.token, container, obj)[-1] self.assertEquals(odata, data) @@ -65,7 +67,7 @@ class TestObjectFailures(ReplProbeTest): obj_server_conf = readconf(self.configs['object-server'][node_id]) devices = obj_server_conf['app:object-server']['devices'] obj_dir = '%s/%s/%s/%s/%s/%s/' % (devices, device, - get_data_dir(self.policy.idx), + get_data_dir(self.policy), opart, hash_str[-3:], hash_str) data_file = get_data_file_path(obj_dir) return onode, opart, data_file diff --git a/test/probe/test_object_handoff.py b/test/probe/test_object_handoff.py index 41a67cf281..f513eef2ec 100755 --- a/test/probe/test_object_handoff.py +++ b/test/probe/test_object_handoff.py @@ -30,7 +30,9 @@ class TestObjectHandoff(ReplProbeTest): def test_main(self): # Create container container = 'container-%s' % uuid4() - client.put_container(self.url, self.token, container) + client.put_container(self.url, self.token, container, + headers={'X-Storage-Policy': + self.policy.name}) # Kill one container/obj primary server cpart, cnodes = self.container_ring.get_nodes(self.account, container) diff --git a/test/probe/test_object_metadata_replication.py b/test/probe/test_object_metadata_replication.py index 357cfec5b4..c278e5f81a 100644 --- a/test/probe/test_object_metadata_replication.py +++ b/test/probe/test_object_metadata_replication.py @@ -73,7 +73,8 @@ class Test(ReplProbeTest): self.container_name = 'container-%s' % uuid.uuid4() self.object_name = 'object-%s' % uuid.uuid4() self.brain = BrainSplitter(self.url, self.token, self.container_name, - self.object_name, 'object') + self.object_name, 'object', + policy=self.policy) self.tempdir = mkdtemp() conf_path = os.path.join(self.tempdir, 'internal_client.conf') conf_body = """ @@ -128,7 +129,7 @@ class Test(ReplProbeTest): self.object_name) def test_object_delete_is_replicated(self): - self.brain.put_container(policy_index=0) + self.brain.put_container(policy_index=int(self.policy)) # put object self._put_object() @@ -174,7 +175,7 @@ class Test(ReplProbeTest): def test_sysmeta_after_replication_with_subsequent_post(self): sysmeta = {'x-object-sysmeta-foo': 'sysmeta-foo'} usermeta = {'x-object-meta-bar': 'meta-bar'} - self.brain.put_container(policy_index=0) + self.brain.put_container(policy_index=int(self.policy)) # put object self._put_object() # put newer object with sysmeta to first server subset @@ -221,7 +222,7 @@ class Test(ReplProbeTest): def test_sysmeta_after_replication_with_prior_post(self): sysmeta = {'x-object-sysmeta-foo': 'sysmeta-foo'} usermeta = {'x-object-meta-bar': 'meta-bar'} - self.brain.put_container(policy_index=0) + self.brain.put_container(policy_index=int(self.policy)) # put object self._put_object() diff --git a/test/probe/test_reconstructor_durable.py b/test/probe/test_reconstructor_durable.py new file mode 100644 index 0000000000..eeef00e62c --- /dev/null +++ b/test/probe/test_reconstructor_durable.py @@ -0,0 +1,157 @@ +#!/usr/bin/python -u +# Copyright (c) 2010-2012 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from hashlib import md5 +import unittest +import uuid +import random +import os +import errno + +from test.probe.common import ECProbeTest + +from swift.common import direct_client +from swift.common.storage_policy import EC_POLICY +from swift.common.manager import Manager + +from swiftclient import client + + +class Body(object): + + def __init__(self, total=3.5 * 2 ** 20): + self.total = total + self.hasher = md5() + self.size = 0 + self.chunk = 'test' * 16 * 2 ** 10 + + @property + def etag(self): + return self.hasher.hexdigest() + + def __iter__(self): + return self + + def next(self): + if self.size > self.total: + raise StopIteration() + self.size += len(self.chunk) + self.hasher.update(self.chunk) + return self.chunk + + def __next__(self): + return self.next() + + +class TestReconstructorPropDurable(ECProbeTest): + + def setUp(self): + super(TestReconstructorPropDurable, self).setUp() + self.container_name = 'container-%s' % uuid.uuid4() + self.object_name = 'object-%s' % uuid.uuid4() + # sanity + self.assertEqual(self.policy.policy_type, EC_POLICY) + self.reconstructor = Manager(["object-reconstructor"]) + + def direct_get(self, node, part): + req_headers = {'X-Backend-Storage-Policy-Index': int(self.policy)} + headers, data = direct_client.direct_get_object( + node, part, self.account, self.container_name, + self.object_name, headers=req_headers, + resp_chunk_size=64 * 2 ** 20) + hasher = md5() + for chunk in data: + hasher.update(chunk) + return hasher.hexdigest() + + def _check_node(self, node, part, etag, headers_post): + # get fragment archive etag + fragment_archive_etag = self.direct_get(node, part) + + # remove the .durable from the selected node + part_dir = self.storage_dir('object', node, part=part) + for dirs, subdirs, files in os.walk(part_dir): + for fname in files: + if fname.endswith('.durable'): + durable = os.path.join(dirs, fname) + os.remove(durable) + break + try: + os.remove(os.path.join(part_dir, 'hashes.pkl')) + except OSError as e: + if e.errno != errno.ENOENT: + raise + + # fire up reconstructor to propogate the .durable + self.reconstructor.once() + + # fragment is still exactly as it was before! + self.assertEqual(fragment_archive_etag, + self.direct_get(node, part)) + + # check meta + meta = client.head_object(self.url, self.token, + self.container_name, + self.object_name) + for key in headers_post: + self.assertTrue(key in meta) + self.assertEqual(meta[key], headers_post[key]) + + def _format_node(self, node): + return '%s#%s' % (node['device'], node['index']) + + def test_main(self): + # create EC container + headers = {'X-Storage-Policy': self.policy.name} + client.put_container(self.url, self.token, self.container_name, + headers=headers) + + # PUT object + contents = Body() + headers = {'x-object-meta-foo': 'meta-foo'} + headers_post = {'x-object-meta-bar': 'meta-bar'} + + etag = client.put_object(self.url, self.token, + self.container_name, + self.object_name, + contents=contents, headers=headers) + client.post_object(self.url, self.token, self.container_name, + self.object_name, headers=headers_post) + del headers_post['X-Auth-Token'] # WTF, where did this come from? + + # built up a list of node lists to kill a .durable from, + # first try a single node + # then adjacent nodes and then nodes >1 node apart + opart, onodes = self.object_ring.get_nodes( + self.account, self.container_name, self.object_name) + single_node = [random.choice(onodes)] + adj_nodes = [onodes[0], onodes[-1]] + far_nodes = [onodes[0], onodes[-2]] + test_list = [single_node, adj_nodes, far_nodes] + + for node_list in test_list: + for onode in node_list: + try: + self._check_node(onode, opart, etag, headers_post) + except AssertionError as e: + self.fail( + str(e) + '\n... for node %r of scenario %r' % ( + self._format_node(onode), + [self._format_node(n) for n in node_list])) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/probe/test_reconstructor_rebuild.py b/test/probe/test_reconstructor_rebuild.py new file mode 100644 index 0000000000..5edfcc52d1 --- /dev/null +++ b/test/probe/test_reconstructor_rebuild.py @@ -0,0 +1,170 @@ +#!/usr/bin/python -u +# Copyright (c) 2010-2012 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from hashlib import md5 +import unittest +import uuid +import shutil +import random + +from test.probe.common import ECProbeTest + +from swift.common import direct_client +from swift.common.storage_policy import EC_POLICY +from swift.common.manager import Manager + +from swiftclient import client + + +class Body(object): + + def __init__(self, total=3.5 * 2 ** 20): + self.total = total + self.hasher = md5() + self.size = 0 + self.chunk = 'test' * 16 * 2 ** 10 + + @property + def etag(self): + return self.hasher.hexdigest() + + def __iter__(self): + return self + + def next(self): + if self.size > self.total: + raise StopIteration() + self.size += len(self.chunk) + self.hasher.update(self.chunk) + return self.chunk + + def __next__(self): + return self.next() + + +class TestReconstructorRebuild(ECProbeTest): + + def setUp(self): + super(TestReconstructorRebuild, self).setUp() + self.container_name = 'container-%s' % uuid.uuid4() + self.object_name = 'object-%s' % uuid.uuid4() + # sanity + self.assertEqual(self.policy.policy_type, EC_POLICY) + self.reconstructor = Manager(["object-reconstructor"]) + + def proxy_get(self): + # GET object + headers, body = client.get_object(self.url, self.token, + self.container_name, + self.object_name, + resp_chunk_size=64 * 2 ** 10) + resp_checksum = md5() + for chunk in body: + resp_checksum.update(chunk) + return resp_checksum.hexdigest() + + def direct_get(self, node, part): + req_headers = {'X-Backend-Storage-Policy-Index': int(self.policy)} + headers, data = direct_client.direct_get_object( + node, part, self.account, self.container_name, + self.object_name, headers=req_headers, + resp_chunk_size=64 * 2 ** 20) + hasher = md5() + for chunk in data: + hasher.update(chunk) + return hasher.hexdigest() + + def _check_node(self, node, part, etag, headers_post): + # get fragment archive etag + fragment_archive_etag = self.direct_get(node, part) + + # remove data from the selected node + part_dir = self.storage_dir('object', node, part=part) + shutil.rmtree(part_dir, True) + + # this node can't servce the data any more + try: + self.direct_get(node, part) + except direct_client.DirectClientException as err: + self.assertEqual(err.http_status, 404) + else: + self.fail('Node data on %r was not fully destoryed!' % + (node,)) + + # make sure we can still GET the object and its correct, the + # proxy is doing decode on remaining fragments to get the obj + self.assertEqual(etag, self.proxy_get()) + + # fire up reconstructor + self.reconstructor.once() + + # fragment is rebuilt exactly as it was before! + self.assertEqual(fragment_archive_etag, + self.direct_get(node, part)) + + # check meta + meta = client.head_object(self.url, self.token, + self.container_name, + self.object_name) + for key in headers_post: + self.assertTrue(key in meta) + self.assertEqual(meta[key], headers_post[key]) + + def _format_node(self, node): + return '%s#%s' % (node['device'], node['index']) + + def test_main(self): + # create EC container + headers = {'X-Storage-Policy': self.policy.name} + client.put_container(self.url, self.token, self.container_name, + headers=headers) + + # PUT object + contents = Body() + headers = {'x-object-meta-foo': 'meta-foo'} + headers_post = {'x-object-meta-bar': 'meta-bar'} + + etag = client.put_object(self.url, self.token, + self.container_name, + self.object_name, + contents=contents, headers=headers) + client.post_object(self.url, self.token, self.container_name, + self.object_name, headers=headers_post) + del headers_post['X-Auth-Token'] # WTF, where did this come from? + + # built up a list of node lists to kill data from, + # first try a single node + # then adjacent nodes and then nodes >1 node apart + opart, onodes = self.object_ring.get_nodes( + self.account, self.container_name, self.object_name) + single_node = [random.choice(onodes)] + adj_nodes = [onodes[0], onodes[-1]] + far_nodes = [onodes[0], onodes[-2]] + test_list = [single_node, adj_nodes, far_nodes] + + for node_list in test_list: + for onode in node_list: + try: + self._check_node(onode, opart, etag, headers_post) + except AssertionError as e: + self.fail( + str(e) + '\n... for node %r of scenario %r' % ( + self._format_node(onode), + [self._format_node(n) for n in node_list])) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/probe/test_reconstructor_revert.py b/test/probe/test_reconstructor_revert.py new file mode 100755 index 0000000000..39739b617d --- /dev/null +++ b/test/probe/test_reconstructor_revert.py @@ -0,0 +1,376 @@ +#!/usr/bin/python -u +# Copyright (c) 2010-2012 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from hashlib import md5 +import unittest +import uuid +import os +import random +import shutil +from collections import defaultdict + +from test.probe.common import ECProbeTest + +from swift.common import direct_client +from swift.common.storage_policy import EC_POLICY +from swift.common.manager import Manager +from swift.common.utils import renamer +from swift.obj import reconstructor + +from swiftclient import client + + +class Body(object): + + def __init__(self, total=3.5 * 2 ** 20): + self.total = total + self.hasher = md5() + self.size = 0 + self.chunk = 'test' * 16 * 2 ** 10 + + @property + def etag(self): + return self.hasher.hexdigest() + + def __iter__(self): + return self + + def next(self): + if self.size > self.total: + raise StopIteration() + self.size += len(self.chunk) + self.hasher.update(self.chunk) + return self.chunk + + def __next__(self): + return self.next() + + +class TestReconstructorRevert(ECProbeTest): + + def setUp(self): + super(TestReconstructorRevert, self).setUp() + self.container_name = 'container-%s' % uuid.uuid4() + self.object_name = 'object-%s' % uuid.uuid4() + + # sanity + self.assertEqual(self.policy.policy_type, EC_POLICY) + self.reconstructor = Manager(["object-reconstructor"]) + + def kill_drive(self, device): + if os.path.ismount(device): + os.system('sudo umount %s' % device) + else: + renamer(device, device + "X") + + def revive_drive(self, device): + disabled_name = device + "X" + if os.path.isdir(disabled_name): + renamer(device + "X", device) + else: + os.system('sudo mount %s' % device) + + def proxy_get(self): + # GET object + headers, body = client.get_object(self.url, self.token, + self.container_name, + self.object_name, + resp_chunk_size=64 * 2 ** 10) + resp_checksum = md5() + for chunk in body: + resp_checksum.update(chunk) + return resp_checksum.hexdigest() + + def direct_get(self, node, part): + req_headers = {'X-Backend-Storage-Policy-Index': int(self.policy)} + headers, data = direct_client.direct_get_object( + node, part, self.account, self.container_name, + self.object_name, headers=req_headers, + resp_chunk_size=64 * 2 ** 20) + hasher = md5() + for chunk in data: + hasher.update(chunk) + return hasher.hexdigest() + + def test_revert_object(self): + # create EC container + headers = {'X-Storage-Policy': self.policy.name} + client.put_container(self.url, self.token, self.container_name, + headers=headers) + + # get our node lists + opart, onodes = self.object_ring.get_nodes( + self.account, self.container_name, self.object_name) + hnodes = self.object_ring.get_more_nodes(opart) + + # kill 2 a parity count number of primary nodes so we can + # force data onto handoffs, we do that by renaming dev dirs + # to induce 507 + p_dev1 = self.device_dir('object', onodes[0]) + p_dev2 = self.device_dir('object', onodes[1]) + self.kill_drive(p_dev1) + self.kill_drive(p_dev2) + + # PUT object + contents = Body() + headers = {'x-object-meta-foo': 'meta-foo'} + headers_post = {'x-object-meta-bar': 'meta-bar'} + client.put_object(self.url, self.token, self.container_name, + self.object_name, contents=contents, + headers=headers) + client.post_object(self.url, self.token, self.container_name, + self.object_name, headers=headers_post) + del headers_post['X-Auth-Token'] # WTF, where did this come from? + + # these primaries can't servce the data any more, we expect 507 + # here and not 404 because we're using mount_check to kill nodes + for onode in (onodes[0], onodes[1]): + try: + self.direct_get(onode, opart) + except direct_client.DirectClientException as err: + self.assertEqual(err.http_status, 507) + else: + self.fail('Node data on %r was not fully destoryed!' % + (onode,)) + + # now take out another primary + p_dev3 = self.device_dir('object', onodes[2]) + self.kill_drive(p_dev3) + + # this node can't servce the data any more + try: + self.direct_get(onodes[2], opart) + except direct_client.DirectClientException as err: + self.assertEqual(err.http_status, 507) + else: + self.fail('Node data on %r was not fully destoryed!' % + (onode,)) + + # make sure we can still GET the object and its correct + # we're now pulling from handoffs and reconstructing + etag = self.proxy_get() + self.assertEqual(etag, contents.etag) + + # rename the dev dirs so they don't 507 anymore + self.revive_drive(p_dev1) + self.revive_drive(p_dev2) + self.revive_drive(p_dev3) + + # fire up reconstructor on handoff nodes only + for hnode in hnodes: + hnode_id = (hnode['port'] - 6000) / 10 + self.reconstructor.once(number=hnode_id) + + # first threee primaries have data again + for onode in (onodes[0], onodes[2]): + self.direct_get(onode, opart) + + # check meta + meta = client.head_object(self.url, self.token, + self.container_name, + self.object_name) + for key in headers_post: + self.assertTrue(key in meta) + self.assertEqual(meta[key], headers_post[key]) + + # handoffs are empty + for hnode in hnodes: + try: + self.direct_get(hnode, opart) + except direct_client.DirectClientException as err: + self.assertEqual(err.http_status, 404) + else: + self.fail('Node data on %r was not fully destoryed!' % + (hnode,)) + + def test_delete_propogate(self): + # create EC container + headers = {'X-Storage-Policy': self.policy.name} + client.put_container(self.url, self.token, self.container_name, + headers=headers) + + # get our node lists + opart, onodes = self.object_ring.get_nodes( + self.account, self.container_name, self.object_name) + hnodes = self.object_ring.get_more_nodes(opart) + p_dev2 = self.device_dir('object', onodes[1]) + + # PUT object + contents = Body() + client.put_object(self.url, self.token, self.container_name, + self.object_name, contents=contents) + + # now lets shut one down + self.kill_drive(p_dev2) + + # delete on the ones that are left + client.delete_object(self.url, self.token, + self.container_name, + self.object_name) + + # spot check a node + try: + self.direct_get(onodes[0], opart) + except direct_client.DirectClientException as err: + self.assertEqual(err.http_status, 404) + else: + self.fail('Node data on %r was not fully destoryed!' % + (onodes[0],)) + + # enable the first node again + self.revive_drive(p_dev2) + + # propogate the delete... + # fire up reconstructor on handoff nodes only + for hnode in hnodes: + hnode_id = (hnode['port'] - 6000) / 10 + self.reconstructor.once(number=hnode_id) + + # check the first node to make sure its gone + try: + self.direct_get(onodes[1], opart) + except direct_client.DirectClientException as err: + self.assertEqual(err.http_status, 404) + else: + self.fail('Node data on %r was not fully destoryed!' % + (onodes[0])) + + # make sure proxy get can't find it + try: + self.proxy_get() + except Exception as err: + self.assertEqual(err.http_status, 404) + else: + self.fail('Node data on %r was not fully destoryed!' % + (onodes[0])) + + def test_reconstruct_from_reverted_fragment_archive(self): + headers = {'X-Storage-Policy': self.policy.name} + client.put_container(self.url, self.token, self.container_name, + headers=headers) + + # get our node lists + opart, onodes = self.object_ring.get_nodes( + self.account, self.container_name, self.object_name) + + # find a primary server that only has one of it's devices in the + # primary node list + group_nodes_by_config = defaultdict(list) + for n in onodes: + group_nodes_by_config[self.config_number(n)].append(n) + for config_number, node_list in group_nodes_by_config.items(): + if len(node_list) == 1: + break + else: + self.fail('ring balancing did not use all available nodes') + primary_node = node_list[0] + primary_device = self.device_dir('object', primary_node) + self.kill_drive(primary_device) + + # PUT object + contents = Body() + etag = client.put_object(self.url, self.token, self.container_name, + self.object_name, contents=contents) + self.assertEqual(contents.etag, etag) + + # fix the primary device and sanity GET + self.revive_drive(primary_device) + self.assertEqual(etag, self.proxy_get()) + + # find a handoff holding the fragment + for hnode in self.object_ring.get_more_nodes(opart): + try: + reverted_fragment_etag = self.direct_get(hnode, opart) + except direct_client.DirectClientException as err: + if err.http_status != 404: + raise + else: + break + else: + self.fail('Unable to find handoff fragment!') + + # we'll force the handoff device to revert instead of potentially + # racing with rebuild by deleting any other fragments that may be on + # the same server + handoff_fragment_etag = None + for node in onodes: + if node['port'] == hnode['port']: + # we'll keep track of the etag of this fragment we're removing + # in case we need it later (queue forshadowing music)... + try: + handoff_fragment_etag = self.direct_get(node, opart) + except direct_client.DirectClientException as err: + if err.http_status != 404: + raise + # this just means our handoff device was on the same + # machine as the primary! + continue + # use the primary nodes device - not the hnode device + part_dir = self.storage_dir('object', node, part=opart) + shutil.rmtree(part_dir, True) + + # revert from handoff device with reconstructor + self.reconstructor.once(number=self.config_number(hnode)) + + # verify fragment reverted to primary server + self.assertEqual(reverted_fragment_etag, + self.direct_get(primary_node, opart)) + + # now we'll remove some data on one of the primary node's partners + partner = random.choice(reconstructor._get_partners( + primary_node['index'], onodes)) + + try: + rebuilt_fragment_etag = self.direct_get(partner, opart) + except direct_client.DirectClientException as err: + if err.http_status != 404: + raise + # partner already had it's fragment removed + if (handoff_fragment_etag is not None and + hnode['port'] == partner['port']): + # oh, well that makes sense then... + rebuilt_fragment_etag = handoff_fragment_etag + else: + # I wonder what happened? + self.fail('Partner inexplicably missing fragment!') + part_dir = self.storage_dir('object', partner, part=opart) + shutil.rmtree(part_dir, True) + + # sanity, it's gone + try: + self.direct_get(partner, opart) + except direct_client.DirectClientException as err: + if err.http_status != 404: + raise + else: + self.fail('successful GET of removed partner fragment archive!?') + + # and force the primary node to do a rebuild + self.reconstructor.once(number=self.config_number(primary_node)) + + # and validate the partners rebuilt_fragment_etag + try: + self.assertEqual(rebuilt_fragment_etag, + self.direct_get(partner, opart)) + except direct_client.DirectClientException as err: + if err.http_status != 404: + raise + else: + self.fail('Did not find rebuilt fragment on partner node') + + +if __name__ == "__main__": + unittest.main() diff --git a/test/probe/test_replication_servers_working.py b/test/probe/test_replication_servers_working.py index db657f3e7f..64b906fdc9 100644 --- a/test/probe/test_replication_servers_working.py +++ b/test/probe/test_replication_servers_working.py @@ -21,7 +21,6 @@ import time import shutil from swiftclient import client -from swift.common.storage_policy import POLICIES from swift.obj.diskfile import get_data_dir from test.probe.common import ReplProbeTest @@ -88,7 +87,7 @@ class TestReplicatorFunctions(ReplProbeTest): # Delete file "hashes.pkl". # Check, that all files were replicated. path_list = [] - data_dir = get_data_dir(POLICIES.default.idx) + data_dir = get_data_dir(self.policy) # Figure out where the devices are for node_id in range(1, 5): conf = readconf(self.configs['object-server'][node_id]) @@ -100,7 +99,9 @@ class TestReplicatorFunctions(ReplProbeTest): # Put data to storage nodes container = 'container-%s' % uuid4() - client.put_container(self.url, self.token, container) + client.put_container(self.url, self.token, container, + headers={'X-Storage-Policy': + self.policy.name}) obj = 'object-%s' % uuid4() client.put_object(self.url, self.token, container, obj, 'VERIFY') diff --git a/test/unit/__init__.py b/test/unit/__init__.py index 0e10d3bac0..372fb58bbf 100644 --- a/test/unit/__init__.py +++ b/test/unit/__init__.py @@ -22,24 +22,30 @@ import errno import sys from contextlib import contextmanager, closing from collections import defaultdict, Iterable +import itertools from numbers import Number from tempfile import NamedTemporaryFile import time +import eventlet from eventlet.green import socket from tempfile import mkdtemp from shutil import rmtree +from swift.common.utils import Timestamp from test import get_config from swift.common import swob, utils from swift.common.ring import Ring, RingData from hashlib import md5 -from eventlet import sleep, Timeout import logging.handlers from httplib import HTTPException from swift.common import storage_policy +from swift.common.storage_policy import StoragePolicy, ECStoragePolicy import functools import cPickle as pickle from gzip import GzipFile import mock as mocklib +import inspect + +EMPTY_ETAG = md5().hexdigest() # try not to import this module from swift if not os.path.basename(sys.argv[0]).startswith('swift'): @@ -47,26 +53,40 @@ if not os.path.basename(sys.argv[0]).startswith('swift'): utils.HASH_PATH_SUFFIX = 'endcap' -def patch_policies(thing_or_policies=None, legacy_only=False): - if legacy_only: - default_policies = [storage_policy.StoragePolicy( - 0, 'legacy', True, object_ring=FakeRing())] - else: - default_policies = [ - storage_policy.StoragePolicy( - 0, 'nulo', True, object_ring=FakeRing()), - storage_policy.StoragePolicy( - 1, 'unu', object_ring=FakeRing()), - ] - - thing_or_policies = thing_or_policies or default_policies - +def patch_policies(thing_or_policies=None, legacy_only=False, + with_ec_default=False, fake_ring_args=None): if isinstance(thing_or_policies, ( Iterable, storage_policy.StoragePolicyCollection)): - return PatchPolicies(thing_or_policies) + return PatchPolicies(thing_or_policies, fake_ring_args=fake_ring_args) + + if legacy_only: + default_policies = [ + StoragePolicy(0, name='legacy', is_default=True), + ] + default_ring_args = [{}] + elif with_ec_default: + default_policies = [ + ECStoragePolicy(0, name='ec', is_default=True, + ec_type='jerasure_rs_vand', ec_ndata=10, + ec_nparity=4, ec_segment_size=4096), + StoragePolicy(1, name='unu'), + ] + default_ring_args = [{'replicas': 14}, {}] else: - # it's a thing! - return PatchPolicies(default_policies)(thing_or_policies) + default_policies = [ + StoragePolicy(0, name='nulo', is_default=True), + StoragePolicy(1, name='unu'), + ] + default_ring_args = [{}, {}] + + fake_ring_args = fake_ring_args or default_ring_args + decorator = PatchPolicies(default_policies, fake_ring_args=fake_ring_args) + + if not thing_or_policies: + return decorator + else: + # it's a thing, we return the wrapped thing instead of the decorator + return decorator(thing_or_policies) class PatchPolicies(object): @@ -76,11 +96,33 @@ class PatchPolicies(object): patched yet) """ - def __init__(self, policies): + def __init__(self, policies, fake_ring_args=None): if isinstance(policies, storage_policy.StoragePolicyCollection): self.policies = policies else: self.policies = storage_policy.StoragePolicyCollection(policies) + self.fake_ring_args = fake_ring_args or [None] * len(self.policies) + + def _setup_rings(self): + """ + Our tests tend to use the policies rings like their own personal + playground - which can be a problem in the particular case of a + patched TestCase class where the FakeRing objects are scoped in the + call to the patch_policies wrapper outside of the TestCase instance + which can lead to some bled state. + + To help tests get better isolation without having to think about it, + here we're capturing the args required to *build* a new FakeRing + instances so we can ensure each test method gets a clean ring setup. + + The TestCase can always "tweak" these fresh rings in setUp - or if + they'd prefer to get the same "reset" behavior with custom FakeRing's + they can pass in their own fake_ring_args to patch_policies instead of + setting the object_ring on the policy definitions. + """ + for policy, fake_ring_arg in zip(self.policies, self.fake_ring_args): + if fake_ring_arg is not None: + policy.object_ring = FakeRing(**fake_ring_arg) def __call__(self, thing): if isinstance(thing, type): @@ -89,24 +131,33 @@ class PatchPolicies(object): return self._patch_method(thing) def _patch_class(self, cls): + """ + Creating a new class that inherits from decorated class is the more + common way I've seen class decorators done - but it seems to cause + infinite recursion when super is called from inside methods in the + decorated class. + """ - class NewClass(cls): + orig_setUp = cls.setUp + orig_tearDown = cls.tearDown - already_patched = False + def setUp(cls_self): + self._orig_POLICIES = storage_policy._POLICIES + if not getattr(cls_self, '_policies_patched', False): + storage_policy._POLICIES = self.policies + self._setup_rings() + cls_self._policies_patched = True - def setUp(cls_self): - self._orig_POLICIES = storage_policy._POLICIES - if not cls_self.already_patched: - storage_policy._POLICIES = self.policies - cls_self.already_patched = True - super(NewClass, cls_self).setUp() + orig_setUp(cls_self) - def tearDown(cls_self): - super(NewClass, cls_self).tearDown() - storage_policy._POLICIES = self._orig_POLICIES + def tearDown(cls_self): + orig_tearDown(cls_self) + storage_policy._POLICIES = self._orig_POLICIES - NewClass.__name__ = cls.__name__ - return NewClass + cls.setUp = setUp + cls.tearDown = tearDown + + return cls def _patch_method(self, f): @functools.wraps(f) @@ -114,6 +165,7 @@ class PatchPolicies(object): self._orig_POLICIES = storage_policy._POLICIES try: storage_policy._POLICIES = self.policies + self._setup_rings() return f(*args, **kwargs) finally: storage_policy._POLICIES = self._orig_POLICIES @@ -171,14 +223,16 @@ class FakeRing(Ring): return self.replicas def _get_part_nodes(self, part): - return list(self._devs) + return [dict(node, index=i) for i, node in enumerate(list(self._devs))] def get_more_nodes(self, part): # replicas^2 is the true cap for x in xrange(self.replicas, min(self.replicas + self.max_more_nodes, self.replicas * self.replicas)): yield {'ip': '10.0.0.%s' % x, + 'replication_ip': '10.0.0.%s' % x, 'port': self._base_port + x, + 'replication_port': self._base_port + x, 'device': 'sda', 'zone': x % 3, 'region': x % 2, @@ -206,6 +260,48 @@ def write_fake_ring(path, *devs): pickle.dump(RingData(replica2part2dev_id, devs, part_shift), f) +class FabricatedRing(Ring): + """ + When a FakeRing just won't do - you can fabricate one to meet + your tests needs. + """ + + def __init__(self, replicas=6, devices=8, nodes=4, port=6000, + part_power=4): + self.devices = devices + self.nodes = nodes + self.port = port + self.replicas = 6 + self.part_power = part_power + self._part_shift = 32 - self.part_power + self._reload() + + def _reload(self, *args, **kwargs): + self._rtime = time.time() * 2 + if hasattr(self, '_replica2part2dev_id'): + return + self._devs = [{ + 'region': 1, + 'zone': 1, + 'weight': 1.0, + 'id': i, + 'device': 'sda%d' % i, + 'ip': '10.0.0.%d' % (i % self.nodes), + 'replication_ip': '10.0.0.%d' % (i % self.nodes), + 'port': self.port, + 'replication_port': self.port, + } for i in range(self.devices)] + + self._replica2part2dev_id = [ + [None] * 2 ** self.part_power + for i in range(self.replicas) + ] + dev_ids = itertools.cycle(range(self.devices)) + for p in range(2 ** self.part_power): + for r in range(self.replicas): + self._replica2part2dev_id[r][p] = next(dev_ids) + + class FakeMemcache(object): def __init__(self): @@ -363,8 +459,8 @@ class UnmockTimeModule(object): logging.time = UnmockTimeModule() -class FakeLogger(logging.Logger): - # a thread safe logger +class FakeLogger(logging.Logger, object): + # a thread safe fake logger def __init__(self, *args, **kwargs): self._clear() @@ -376,22 +472,31 @@ class FakeLogger(logging.Logger): self.thread_locals = None self.parent = None + store_in = { + logging.ERROR: 'error', + logging.WARNING: 'warning', + logging.INFO: 'info', + logging.DEBUG: 'debug', + logging.CRITICAL: 'critical', + } + + def _log(self, level, msg, *args, **kwargs): + store_name = self.store_in[level] + cargs = [msg] + if any(args): + cargs.extend(args) + captured = dict(kwargs) + if 'exc_info' in kwargs and \ + not isinstance(kwargs['exc_info'], tuple): + captured['exc_info'] = sys.exc_info() + self.log_dict[store_name].append((tuple(cargs), captured)) + super(FakeLogger, self)._log(level, msg, *args, **kwargs) + def _clear(self): self.log_dict = defaultdict(list) self.lines_dict = {'critical': [], 'error': [], 'info': [], 'warning': [], 'debug': []} - def _store_in(store_name): - def stub_fn(self, *args, **kwargs): - self.log_dict[store_name].append((args, kwargs)) - return stub_fn - - def _store_and_log_in(store_name, level): - def stub_fn(self, *args, **kwargs): - self.log_dict[store_name].append((args, kwargs)) - self._log(level, args[0], args[1:], **kwargs) - return stub_fn - def get_lines_for_level(self, level): if level not in self.lines_dict: raise KeyError( @@ -404,16 +509,10 @@ class FakeLogger(logging.Logger): return dict((level, msgs) for level, msgs in self.lines_dict.items() if len(msgs) > 0) - error = _store_and_log_in('error', logging.ERROR) - info = _store_and_log_in('info', logging.INFO) - warning = _store_and_log_in('warning', logging.WARNING) - warn = _store_and_log_in('warning', logging.WARNING) - debug = _store_and_log_in('debug', logging.DEBUG) - - def exception(self, *args, **kwargs): - self.log_dict['exception'].append((args, kwargs, - str(sys.exc_info()[1]))) - print 'FakeLogger Exception: %s' % self.log_dict + def _store_in(store_name): + def stub_fn(self, *args, **kwargs): + self.log_dict[store_name].append((args, kwargs)) + return stub_fn # mock out the StatsD logging methods: update_stats = _store_in('update_stats') @@ -605,19 +704,53 @@ def mock(update): delattr(module, attr) +class SlowBody(object): + """ + This will work with our fake_http_connect, if you hand in these + instead of strings it will make reads take longer by the given + amount. It should be a little bit easier to extend than the + current slow kwarg - which inserts whitespace in the response. + Also it should be easy to detect if you have one of these (or a + subclass) for the body inside of FakeConn if we wanted to do + something smarter than just duck-type the str/buffer api + enough to get by. + """ + + def __init__(self, body, slowness): + self.body = body + self.slowness = slowness + + def slowdown(self): + eventlet.sleep(self.slowness) + + def __getitem__(self, s): + return SlowBody(self.body[s], self.slowness) + + def __len__(self): + return len(self.body) + + def __radd__(self, other): + self.slowdown() + return other + self.body + + def fake_http_connect(*code_iter, **kwargs): class FakeConn(object): def __init__(self, status, etag=None, body='', timestamp='1', - headers=None): + headers=None, expect_headers=None, connection_id=None, + give_send=None): # connect exception - if isinstance(status, (Exception, Timeout)): + if isinstance(status, (Exception, eventlet.Timeout)): raise status if isinstance(status, tuple): - self.expect_status, self.status = status + self.expect_status = list(status[:-1]) + self.status = status[-1] + self.explicit_expect_list = True else: - self.expect_status, self.status = (None, status) + self.expect_status, self.status = ([], status) + self.explicit_expect_list = False if not self.expect_status: # when a swift backend service returns a status before reading # from the body (mostly an error response) eventlet.wsgi will @@ -628,9 +761,9 @@ def fake_http_connect(*code_iter, **kwargs): # our backend services and return certain types of responses # as expect statuses just like a real backend server would do. if self.status in (507, 412, 409): - self.expect_status = status + self.expect_status = [status] else: - self.expect_status = 100 + self.expect_status = [100, 100] self.reason = 'Fake' self.host = '1.2.3.4' self.port = '1234' @@ -639,32 +772,41 @@ def fake_http_connect(*code_iter, **kwargs): self.etag = etag self.body = body self.headers = headers or {} + self.expect_headers = expect_headers or {} self.timestamp = timestamp + self.connection_id = connection_id + self.give_send = give_send if 'slow' in kwargs and isinstance(kwargs['slow'], list): try: self._next_sleep = kwargs['slow'].pop(0) except IndexError: self._next_sleep = None + # be nice to trixy bits with node_iter's + eventlet.sleep() def getresponse(self): - if isinstance(self.status, (Exception, Timeout)): + if self.expect_status and self.explicit_expect_list: + raise Exception('Test did not consume all fake ' + 'expect status: %r' % (self.expect_status,)) + if isinstance(self.status, (Exception, eventlet.Timeout)): raise self.status exc = kwargs.get('raise_exc') if exc: - if isinstance(exc, (Exception, Timeout)): + if isinstance(exc, (Exception, eventlet.Timeout)): raise exc raise Exception('test') if kwargs.get('raise_timeout_exc'): - raise Timeout() + raise eventlet.Timeout() return self def getexpect(self): - if isinstance(self.expect_status, (Exception, Timeout)): + expect_status = self.expect_status.pop(0) + if isinstance(self.expect_status, (Exception, eventlet.Timeout)): raise self.expect_status - headers = {} - if self.expect_status == 409: + headers = dict(self.expect_headers) + if expect_status == 409: headers['X-Backend-Timestamp'] = self.timestamp - return FakeConn(self.expect_status, headers=headers) + return FakeConn(expect_status, headers=headers) def getheaders(self): etag = self.etag @@ -717,34 +859,45 @@ def fake_http_connect(*code_iter, **kwargs): if am_slow: if self.sent < 4: self.sent += 1 - sleep(value) + eventlet.sleep(value) return ' ' rv = self.body[:amt] self.body = self.body[amt:] return rv def send(self, amt=None): + if self.give_send: + self.give_send(self.connection_id, amt) am_slow, value = self.get_slow() if am_slow: if self.received < 4: self.received += 1 - sleep(value) + eventlet.sleep(value) def getheader(self, name, default=None): return swob.HeaderKeyDict(self.getheaders()).get(name, default) + def close(self): + pass + timestamps_iter = iter(kwargs.get('timestamps') or ['1'] * len(code_iter)) etag_iter = iter(kwargs.get('etags') or [None] * len(code_iter)) - if isinstance(kwargs.get('headers'), list): + if isinstance(kwargs.get('headers'), (list, tuple)): headers_iter = iter(kwargs['headers']) else: headers_iter = iter([kwargs.get('headers', {})] * len(code_iter)) + if isinstance(kwargs.get('expect_headers'), (list, tuple)): + expect_headers_iter = iter(kwargs['expect_headers']) + else: + expect_headers_iter = iter([kwargs.get('expect_headers', {})] * + len(code_iter)) x = kwargs.get('missing_container', [False] * len(code_iter)) if not isinstance(x, (tuple, list)): x = [x] * len(code_iter) container_ts_iter = iter(x) code_iter = iter(code_iter) + conn_id_and_code_iter = enumerate(code_iter) static_body = kwargs.get('body', None) body_iter = kwargs.get('body_iter', None) if body_iter: @@ -752,17 +905,22 @@ def fake_http_connect(*code_iter, **kwargs): def connect(*args, **ckwargs): if kwargs.get('slow_connect', False): - sleep(0.1) + eventlet.sleep(0.1) if 'give_content_type' in kwargs: if len(args) >= 7 and 'Content-Type' in args[6]: kwargs['give_content_type'](args[6]['Content-Type']) else: kwargs['give_content_type']('') + i, status = conn_id_and_code_iter.next() if 'give_connect' in kwargs: - kwargs['give_connect'](*args, **ckwargs) - status = code_iter.next() + give_conn_fn = kwargs['give_connect'] + argspec = inspect.getargspec(give_conn_fn) + if argspec.keywords or 'connection_id' in argspec.args: + ckwargs['connection_id'] = i + give_conn_fn(*args, **ckwargs) etag = etag_iter.next() headers = headers_iter.next() + expect_headers = expect_headers_iter.next() timestamp = timestamps_iter.next() if status <= 0: @@ -772,7 +930,8 @@ def fake_http_connect(*code_iter, **kwargs): else: body = body_iter.next() return FakeConn(status, etag, body=body, timestamp=timestamp, - headers=headers) + headers=headers, expect_headers=expect_headers, + connection_id=i, give_send=kwargs.get('give_send')) connect.code_iter = code_iter @@ -803,3 +962,7 @@ def mocked_http_conn(*args, **kwargs): left_over_status = list(fake_conn.code_iter) if left_over_status: raise AssertionError('left over status %r' % left_over_status) + + +def make_timestamp_iter(): + return iter(Timestamp(t) for t in itertools.count(int(time.time()))) diff --git a/test/unit/account/test_backend.py b/test/unit/account/test_backend.py index 82978a8301..d231fea741 100644 --- a/test/unit/account/test_backend.py +++ b/test/unit/account/test_backend.py @@ -747,7 +747,7 @@ def prespi_AccountBroker_initialize(self, conn, put_timestamp, **kwargs): The AccountBroker initialze() function before we added the policy stat table. Used by test_policy_table_creation() to make sure that the AccountBroker will correctly add the table - for cases where the DB existed before the policy suport was added. + for cases where the DB existed before the policy support was added. :param conn: DB connection object :param put_timestamp: put timestamp diff --git a/test/unit/account/test_reaper.py b/test/unit/account/test_reaper.py index 6c1c102b8e..d81b565fc4 100644 --- a/test/unit/account/test_reaper.py +++ b/test/unit/account/test_reaper.py @@ -141,7 +141,7 @@ cont_nodes = [{'device': 'sda1', @unit.patch_policies([StoragePolicy(0, 'zero', False, object_ring=unit.FakeRing()), StoragePolicy(1, 'one', True, - object_ring=unit.FakeRing())]) + object_ring=unit.FakeRing(replicas=4))]) class TestReaper(unittest.TestCase): def setUp(self): @@ -215,7 +215,7 @@ class TestReaper(unittest.TestCase): r.stats_objects_possibly_remaining = 0 r.myips = myips if fakelogger: - r.logger = FakeLogger() + r.logger = unit.debug_logger('test-reaper') return r def fake_reap_account(self, *args, **kwargs): @@ -287,7 +287,7 @@ class TestReaper(unittest.TestCase): policy.idx) for i, call_args in enumerate( fake_direct_delete.call_args_list): - cnode = cont_nodes[i] + cnode = cont_nodes[i % len(cont_nodes)] host = '%(ip)s:%(port)s' % cnode device = cnode['device'] headers = { @@ -297,11 +297,13 @@ class TestReaper(unittest.TestCase): 'X-Backend-Storage-Policy-Index': policy.idx } ring = r.get_object_ring(policy.idx) - expected = call(ring.devs[i], 0, 'a', 'c', 'o', + expected = call(dict(ring.devs[i], index=i), 0, + 'a', 'c', 'o', headers=headers, conn_timeout=0.5, response_timeout=10) self.assertEqual(call_args, expected) - self.assertEqual(r.stats_objects_deleted, 3) + self.assertEqual(r.stats_objects_deleted, + policy.object_ring.replicas) def test_reap_object_fail(self): r = self.init_reaper({}, fakelogger=True) @@ -312,7 +314,26 @@ class TestReaper(unittest.TestCase): self.fake_direct_delete_object): r.reap_object('a', 'c', 'partition', cont_nodes, 'o', policy.idx) - self.assertEqual(r.stats_objects_deleted, 1) + # IMHO, the stat handling in the node loop of reap object is + # over indented, but no one has complained, so I'm not inclined + # to move it. However it's worth noting we're currently keeping + # stats on deletes per *replica* - which is rather obvious from + # these tests, but this results is surprising because of some + # funny logic to *skip* increments on successful deletes of + # replicas until we have more successful responses than + # failures. This means that while the first replica doesn't + # increment deleted because of the failure, the second one + # *does* get successfully deleted, but *also does not* increment + # the counter (!?). + # + # In the three replica case this leaves only the last deleted + # object incrementing the counter - in the four replica case + # this leaves the last two. + # + # Basically this test will always result in: + # deleted == num_replicas - 2 + self.assertEqual(r.stats_objects_deleted, + policy.object_ring.replicas - 2) self.assertEqual(r.stats_objects_remaining, 1) self.assertEqual(r.stats_objects_possibly_remaining, 1) @@ -347,7 +368,7 @@ class TestReaper(unittest.TestCase): mocks['direct_get_container'].side_effect = fake_get_container r.reap_container('a', 'partition', acc_nodes, 'c') mock_calls = mocks['direct_delete_object'].call_args_list - self.assertEqual(3, len(mock_calls)) + self.assertEqual(policy.object_ring.replicas, len(mock_calls)) for call_args in mock_calls: _args, kwargs = call_args self.assertEqual(kwargs['headers'] @@ -355,7 +376,7 @@ class TestReaper(unittest.TestCase): policy.idx) self.assertEquals(mocks['direct_delete_container'].call_count, 3) - self.assertEqual(r.stats_objects_deleted, 3) + self.assertEqual(r.stats_objects_deleted, policy.object_ring.replicas) def test_reap_container_get_object_fail(self): r = self.init_reaper({}, fakelogger=True) @@ -373,7 +394,7 @@ class TestReaper(unittest.TestCase): self.fake_reap_object)] with nested(*ctx): r.reap_container('a', 'partition', acc_nodes, 'c') - self.assertEqual(r.logger.inc['return_codes.4'], 1) + self.assertEqual(r.logger.get_increment_counts()['return_codes.4'], 1) self.assertEqual(r.stats_containers_deleted, 1) def test_reap_container_partial_fail(self): @@ -392,7 +413,7 @@ class TestReaper(unittest.TestCase): self.fake_reap_object)] with nested(*ctx): r.reap_container('a', 'partition', acc_nodes, 'c') - self.assertEqual(r.logger.inc['return_codes.4'], 2) + self.assertEqual(r.logger.get_increment_counts()['return_codes.4'], 2) self.assertEqual(r.stats_containers_possibly_remaining, 1) def test_reap_container_full_fail(self): @@ -411,7 +432,7 @@ class TestReaper(unittest.TestCase): self.fake_reap_object)] with nested(*ctx): r.reap_container('a', 'partition', acc_nodes, 'c') - self.assertEqual(r.logger.inc['return_codes.4'], 3) + self.assertEqual(r.logger.get_increment_counts()['return_codes.4'], 3) self.assertEqual(r.stats_containers_remaining, 1) @patch('swift.account.reaper.Ring', @@ -436,8 +457,8 @@ class TestReaper(unittest.TestCase): mocks['direct_get_container'].side_effect = fake_get_container r.reap_container('a', 'partition', acc_nodes, 'c') - self.assertEqual(r.logger.msg, - 'ERROR: invalid storage policy index: 2') + self.assertEqual(r.logger.get_lines_for_level('error'), [ + 'ERROR: invalid storage policy index: 2']) def fake_reap_container(self, *args, **kwargs): self.called_amount += 1 @@ -462,13 +483,16 @@ class TestReaper(unittest.TestCase): nodes = r.get_account_ring().get_part_nodes() self.assertTrue(r.reap_account(broker, 'partition', nodes)) self.assertEqual(self.called_amount, 4) - self.assertEqual(r.logger.msg.find('Completed pass'), 0) - self.assertTrue(r.logger.msg.find('1 containers deleted')) - self.assertTrue(r.logger.msg.find('1 objects deleted')) - self.assertTrue(r.logger.msg.find('1 containers remaining')) - self.assertTrue(r.logger.msg.find('1 objects remaining')) - self.assertTrue(r.logger.msg.find('1 containers possibly remaining')) - self.assertTrue(r.logger.msg.find('1 objects possibly remaining')) + info_lines = r.logger.get_lines_for_level('info') + self.assertEqual(len(info_lines), 2) + start_line, stat_line = info_lines + self.assertEqual(start_line, 'Beginning pass on account a') + self.assertTrue(stat_line.find('1 containers deleted')) + self.assertTrue(stat_line.find('1 objects deleted')) + self.assertTrue(stat_line.find('1 containers remaining')) + self.assertTrue(stat_line.find('1 objects remaining')) + self.assertTrue(stat_line.find('1 containers possibly remaining')) + self.assertTrue(stat_line.find('1 objects possibly remaining')) def test_reap_account_no_container(self): broker = FakeAccountBroker(tuple()) @@ -482,7 +506,8 @@ class TestReaper(unittest.TestCase): with nested(*ctx): nodes = r.get_account_ring().get_part_nodes() self.assertTrue(r.reap_account(broker, 'partition', nodes)) - self.assertEqual(r.logger.msg.find('Completed pass'), 0) + self.assertTrue(r.logger.get_lines_for_level( + 'info')[-1].startswith('Completed pass')) self.assertEqual(self.called_amount, 0) def test_reap_device(self): diff --git a/test/unit/cli/test_info.py b/test/unit/cli/test_info.py index 2766520fd0..4e702abd5f 100644 --- a/test/unit/cli/test_info.py +++ b/test/unit/cli/test_info.py @@ -386,6 +386,17 @@ class TestPrintObjFullMeta(TestCliInfoBase): print_obj(self.datafile, swift_dir=self.testdir) self.assertTrue('/objects-1/' in out.getvalue()) + def test_print_obj_meta_and_ts_files(self): + # verify that print_obj will also read from meta and ts files + base = os.path.splitext(self.datafile)[0] + for ext in ('.meta', '.ts'): + test_file = '%s%s' % (base, ext) + os.link(self.datafile, test_file) + out = StringIO() + with mock.patch('sys.stdout', out): + print_obj(test_file, swift_dir=self.testdir) + self.assertTrue('/objects-1/' in out.getvalue()) + def test_print_obj_no_ring(self): no_rings_dir = os.path.join(self.testdir, 'no_rings_here') os.mkdir(no_rings_dir) @@ -435,14 +446,14 @@ class TestPrintObjFullMeta(TestCliInfoBase): self.assertRaisesMessage(ValueError, 'Metadata is None', print_obj_metadata, []) - def reset_metadata(): + def get_metadata(items): md = dict(name='/AUTH_admin/c/dummy') md['Content-Type'] = 'application/octet-stream' md['X-Timestamp'] = 106.3 - md['X-Object-Meta-Mtime'] = '107.3' + md.update(items) return md - metadata = reset_metadata() + metadata = get_metadata({'X-Object-Meta-Mtime': '107.3'}) out = StringIO() with mock.patch('sys.stdout', out): print_obj_metadata(metadata) @@ -453,17 +464,93 @@ class TestPrintObjFullMeta(TestCliInfoBase): Object hash: 128fdf98bddd1b1e8695f4340e67a67a Content-Type: application/octet-stream Timestamp: 1970-01-01T00:01:46.300000 (%s) -User Metadata: {'X-Object-Meta-Mtime': '107.3'}''' % ( +System Metadata: + No metadata found +User Metadata: + X-Object-Meta-Mtime: 107.3 +Other Metadata: + No metadata found''' % ( utils.Timestamp(106.3).internal) self.assertEquals(out.getvalue().strip(), exp_out) - metadata = reset_metadata() + metadata = get_metadata({ + 'X-Object-Sysmeta-Mtime': '107.3', + 'X-Object-Sysmeta-Name': 'Obj name', + }) + out = StringIO() + with mock.patch('sys.stdout', out): + print_obj_metadata(metadata) + exp_out = '''Path: /AUTH_admin/c/dummy + Account: AUTH_admin + Container: c + Object: dummy + Object hash: 128fdf98bddd1b1e8695f4340e67a67a +Content-Type: application/octet-stream +Timestamp: 1970-01-01T00:01:46.300000 (%s) +System Metadata: + X-Object-Sysmeta-Mtime: 107.3 + X-Object-Sysmeta-Name: Obj name +User Metadata: + No metadata found +Other Metadata: + No metadata found''' % ( + utils.Timestamp(106.3).internal) + + self.assertEquals(out.getvalue().strip(), exp_out) + + metadata = get_metadata({ + 'X-Object-Meta-Mtime': '107.3', + 'X-Object-Sysmeta-Mtime': '107.3', + 'X-Object-Mtime': '107.3', + }) + out = StringIO() + with mock.patch('sys.stdout', out): + print_obj_metadata(metadata) + exp_out = '''Path: /AUTH_admin/c/dummy + Account: AUTH_admin + Container: c + Object: dummy + Object hash: 128fdf98bddd1b1e8695f4340e67a67a +Content-Type: application/octet-stream +Timestamp: 1970-01-01T00:01:46.300000 (%s) +System Metadata: + X-Object-Sysmeta-Mtime: 107.3 +User Metadata: + X-Object-Meta-Mtime: 107.3 +Other Metadata: + X-Object-Mtime: 107.3''' % ( + utils.Timestamp(106.3).internal) + + self.assertEquals(out.getvalue().strip(), exp_out) + + metadata = get_metadata({}) + out = StringIO() + with mock.patch('sys.stdout', out): + print_obj_metadata(metadata) + exp_out = '''Path: /AUTH_admin/c/dummy + Account: AUTH_admin + Container: c + Object: dummy + Object hash: 128fdf98bddd1b1e8695f4340e67a67a +Content-Type: application/octet-stream +Timestamp: 1970-01-01T00:01:46.300000 (%s) +System Metadata: + No metadata found +User Metadata: + No metadata found +Other Metadata: + No metadata found''' % ( + utils.Timestamp(106.3).internal) + + self.assertEquals(out.getvalue().strip(), exp_out) + + metadata = get_metadata({'X-Object-Meta-Mtime': '107.3'}) metadata['name'] = '/a-s' self.assertRaisesMessage(ValueError, 'Path is invalid', print_obj_metadata, metadata) - metadata = reset_metadata() + metadata = get_metadata({'X-Object-Meta-Mtime': '107.3'}) del metadata['name'] out = StringIO() with mock.patch('sys.stdout', out): @@ -471,12 +558,17 @@ User Metadata: {'X-Object-Meta-Mtime': '107.3'}''' % ( exp_out = '''Path: Not found in metadata Content-Type: application/octet-stream Timestamp: 1970-01-01T00:01:46.300000 (%s) -User Metadata: {'X-Object-Meta-Mtime': '107.3'}''' % ( +System Metadata: + No metadata found +User Metadata: + X-Object-Meta-Mtime: 107.3 +Other Metadata: + No metadata found''' % ( utils.Timestamp(106.3).internal) self.assertEquals(out.getvalue().strip(), exp_out) - metadata = reset_metadata() + metadata = get_metadata({'X-Object-Meta-Mtime': '107.3'}) del metadata['Content-Type'] out = StringIO() with mock.patch('sys.stdout', out): @@ -488,12 +580,17 @@ User Metadata: {'X-Object-Meta-Mtime': '107.3'}''' % ( Object hash: 128fdf98bddd1b1e8695f4340e67a67a Content-Type: Not found in metadata Timestamp: 1970-01-01T00:01:46.300000 (%s) -User Metadata: {'X-Object-Meta-Mtime': '107.3'}''' % ( +System Metadata: + No metadata found +User Metadata: + X-Object-Meta-Mtime: 107.3 +Other Metadata: + No metadata found''' % ( utils.Timestamp(106.3).internal) self.assertEquals(out.getvalue().strip(), exp_out) - metadata = reset_metadata() + metadata = get_metadata({'X-Object-Meta-Mtime': '107.3'}) del metadata['X-Timestamp'] out = StringIO() with mock.patch('sys.stdout', out): @@ -505,6 +602,11 @@ User Metadata: {'X-Object-Meta-Mtime': '107.3'}''' % ( Object hash: 128fdf98bddd1b1e8695f4340e67a67a Content-Type: application/octet-stream Timestamp: Not found in metadata -User Metadata: {'X-Object-Meta-Mtime': '107.3'}''' +System Metadata: + No metadata found +User Metadata: + X-Object-Meta-Mtime: 107.3 +Other Metadata: + No metadata found''' self.assertEquals(out.getvalue().strip(), exp_out) diff --git a/test/unit/cli/test_recon.py b/test/unit/cli/test_recon.py index e9ad45d2c8..0b6ffd7a33 100644 --- a/test/unit/cli/test_recon.py +++ b/test/unit/cli/test_recon.py @@ -293,6 +293,43 @@ class TestRecon(unittest.TestCase): % ex) self.assertFalse(expected) + def test_drive_audit_check(self): + hosts = [('127.0.0.1', 6010), ('127.0.0.1', 6020), + ('127.0.0.1', 6030), ('127.0.0.1', 6040)] + # sample json response from http://:/recon/driveaudit + responses = {6010: {'drive_audit_errors': 15}, + 6020: {'drive_audit_errors': 0}, + 6030: {'drive_audit_errors': 257}, + 6040: {'drive_audit_errors': 56}} + # + expected = (0, 257, 82.0, 328, 0.0, 0, 4) + + def mock_scout_driveaudit(app, host): + url = 'http://%s:%s/recon/driveaudit' % host + response = responses[host[1]] + status = 200 + return url, response, status + + stdout = StringIO() + patches = [ + mock.patch('swift.cli.recon.Scout.scout', mock_scout_driveaudit), + mock.patch('sys.stdout', new=stdout), + ] + with nested(*patches): + self.recon_instance.driveaudit_check(hosts) + + output = stdout.getvalue() + r = re.compile("\[drive_audit_errors(.*)\](.*)") + lines = output.splitlines() + self.assertTrue(lines) + for line in lines: + m = r.match(line) + if m: + self.assertEquals(m.group(2), + " low: %s, high: %s, avg: %s, total: %s," + " Failed: %s%%, no_result: %s, reported: %s" + % expected) + class TestReconCommands(unittest.TestCase): def setUp(self): @@ -485,3 +522,173 @@ class TestReconCommands(unittest.TestCase): self.assertTrue(computed) for key in keys: self.assertTrue(key in computed) + + def test_disk_usage(self): + def dummy_request(*args, **kwargs): + return [('http://127.0.0.1:6010/recon/diskusage', [ + {"device": "sdb1", "mounted": True, + "avail": 10, "used": 90, "size": 100}, + {"device": "sdc1", "mounted": True, + "avail": 15, "used": 85, "size": 100}, + {"device": "sdd1", "mounted": True, + "avail": 15, "used": 85, "size": 100}], + 200)] + + cli = recon.SwiftRecon() + cli.pool.imap = dummy_request + + default_calls = [ + mock.call('Distribution Graph:'), + mock.call(' 85% 2 **********************************' + + '***********************************'), + mock.call(' 90% 1 **********************************'), + mock.call('Disk usage: space used: 260 of 300'), + mock.call('Disk usage: space free: 40 of 300'), + mock.call('Disk usage: lowest: 85.0%, ' + + 'highest: 90.0%, avg: 86.6666666667%'), + mock.call('=' * 79), + ] + + with mock.patch('__builtin__.print') as mock_print: + cli.disk_usage([('127.0.0.1', 6010)]) + mock_print.assert_has_calls(default_calls) + + with mock.patch('__builtin__.print') as mock_print: + expected_calls = default_calls + [ + mock.call('LOWEST 5'), + mock.call('85.00% 127.0.0.1 sdc1'), + mock.call('85.00% 127.0.0.1 sdd1'), + mock.call('90.00% 127.0.0.1 sdb1') + ] + cli.disk_usage([('127.0.0.1', 6010)], 0, 5) + mock_print.assert_has_calls(expected_calls) + + with mock.patch('__builtin__.print') as mock_print: + expected_calls = default_calls + [ + mock.call('TOP 5'), + mock.call('90.00% 127.0.0.1 sdb1'), + mock.call('85.00% 127.0.0.1 sdc1'), + mock.call('85.00% 127.0.0.1 sdd1') + ] + cli.disk_usage([('127.0.0.1', 6010)], 5, 0) + mock_print.assert_has_calls(expected_calls) + + @mock.patch('__builtin__.print') + @mock.patch('time.time') + def test_object_replication_check(self, mock_now, mock_print): + now = 1430000000.0 + + def dummy_request(*args, **kwargs): + return [ + ('http://127.0.0.1:6010/recon/replication/object', + {"object_replication_time": 61, + "object_replication_last": now}, + 200), + ('http://127.0.0.1:6020/recon/replication/object', + {"object_replication_time": 23, + "object_replication_last": now}, + 200), + ] + + cli = recon.SwiftRecon() + cli.pool.imap = dummy_request + + default_calls = [ + mock.call('[replication_time] low: 23, high: 61, avg: 42.0, ' + + 'total: 84, Failed: 0.0%, no_result: 0, reported: 2'), + mock.call('Oldest completion was 2015-04-25 22:13:20 ' + + '(42 seconds ago) by 127.0.0.1:6010.'), + mock.call('Most recent completion was 2015-04-25 22:13:20 ' + + '(42 seconds ago) by 127.0.0.1:6010.'), + ] + + mock_now.return_value = now + 42 + cli.object_replication_check([('127.0.0.1', 6010), + ('127.0.0.1', 6020)]) + mock_print.assert_has_calls(default_calls) + + @mock.patch('__builtin__.print') + @mock.patch('time.time') + def test_replication_check(self, mock_now, mock_print): + now = 1430000000.0 + + def dummy_request(*args, **kwargs): + return [ + ('http://127.0.0.1:6011/recon/replication/container', + {"replication_last": now, + "replication_stats": { + "no_change": 2, "rsync": 0, "success": 3, "failure": 1, + "attempted": 0, "ts_repl": 0, "remove": 0, + "remote_merge": 0, "diff_capped": 0, "start": now, + "hashmatch": 0, "diff": 0, "empty": 0}, + "replication_time": 42}, + 200), + ('http://127.0.0.1:6021/recon/replication/container', + {"replication_last": now, + "replication_stats": { + "no_change": 0, "rsync": 0, "success": 1, "failure": 0, + "attempted": 0, "ts_repl": 0, "remove": 0, + "remote_merge": 0, "diff_capped": 0, "start": now, + "hashmatch": 0, "diff": 0, "empty": 0}, + "replication_time": 23}, + 200), + ] + + cli = recon.SwiftRecon() + cli.pool.imap = dummy_request + + default_calls = [ + mock.call('[replication_failure] low: 0, high: 1, avg: 0.5, ' + + 'total: 1, Failed: 0.0%, no_result: 0, reported: 2'), + mock.call('[replication_success] low: 1, high: 3, avg: 2.0, ' + + 'total: 4, Failed: 0.0%, no_result: 0, reported: 2'), + mock.call('[replication_time] low: 23, high: 42, avg: 32.5, ' + + 'total: 65, Failed: 0.0%, no_result: 0, reported: 2'), + mock.call('[replication_attempted] low: 0, high: 0, avg: 0.0, ' + + 'total: 0, Failed: 0.0%, no_result: 0, reported: 2'), + mock.call('Oldest completion was 2015-04-25 22:13:20 ' + + '(42 seconds ago) by 127.0.0.1:6011.'), + mock.call('Most recent completion was 2015-04-25 22:13:20 ' + + '(42 seconds ago) by 127.0.0.1:6011.'), + ] + + mock_now.return_value = now + 42 + cli.replication_check([('127.0.0.1', 6011), ('127.0.0.1', 6021)]) + # We need any_order=True because the order of calls depends on the dict + # that is returned from the recon middleware, thus can't rely on it + mock_print.assert_has_calls(default_calls, any_order=True) + + @mock.patch('__builtin__.print') + @mock.patch('time.time') + def test_load_check(self, mock_now, mock_print): + now = 1430000000.0 + + def dummy_request(*args, **kwargs): + return [ + ('http://127.0.0.1:6010/recon/load', + {"1m": 0.2, "5m": 0.4, "15m": 0.25, + "processes": 10000, "tasks": "1/128"}, + 200), + ('http://127.0.0.1:6020/recon/load', + {"1m": 0.4, "5m": 0.8, "15m": 0.75, + "processes": 9000, "tasks": "1/200"}, + 200), + ] + + cli = recon.SwiftRecon() + cli.pool.imap = dummy_request + + default_calls = [ + mock.call('[5m_load_avg] low: 0, high: 0, avg: 0.6, total: 1, ' + + 'Failed: 0.0%, no_result: 0, reported: 2'), + mock.call('[15m_load_avg] low: 0, high: 0, avg: 0.5, total: 1, ' + + 'Failed: 0.0%, no_result: 0, reported: 2'), + mock.call('[1m_load_avg] low: 0, high: 0, avg: 0.3, total: 0, ' + + 'Failed: 0.0%, no_result: 0, reported: 2'), + ] + + mock_now.return_value = now + 42 + cli.load_check([('127.0.0.1', 6010), ('127.0.0.1', 6020)]) + # We need any_order=True because the order of calls depends on the dict + # that is returned from the recon middleware, thus can't rely on it + mock_print.assert_has_calls(default_calls, any_order=True) diff --git a/test/unit/cli/test_ringbuilder.py b/test/unit/cli/test_ringbuilder.py index 38991bfe4a..246f282f38 100644 --- a/test/unit/cli/test_ringbuilder.py +++ b/test/unit/cli/test_ringbuilder.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import logging import mock import os import StringIO @@ -1710,10 +1711,43 @@ class TestCommands(unittest.TestCase, RunSwiftRingBuilderMixin): ring.devs[0]['weight'] = 10 ring.save(self.tmpfile) argv = ["", self.tmpfile, "rebalance"] + err = None try: ringbuilder.main(argv) except SystemExit as e: - self.assertEquals(e.code, 1) + err = e + self.assertEquals(err.code, 1) + + def test_invalid_device_name(self): + self.create_sample_ring() + for device_name in ["", " ", " sda1", "sda1 ", " meta "]: + err = 0 + + argv = ["", + self.tmpfile, + "add", + "r1z1-127.0.0.1:6000/%s" % device_name, + "1"] + try: + ringbuilder.main(argv) + except SystemExit as exc: + err = exc + self.assertEquals(err.code, 2) + + argv = ["", + self.tmpfile, + "add", + "--region", "1", + "--zone", "1", + "--ip", "127.0.0.1", + "--port", "6000", + "--device", device_name, + "--weight", "100"] + try: + ringbuilder.main(argv) + except SystemExit as exc: + err = exc + self.assertEquals(err.code, 2) class TestRebalanceCommand(unittest.TestCase, RunSwiftRingBuilderMixin): @@ -1744,6 +1778,32 @@ class TestRebalanceCommand(unittest.TestCase, RunSwiftRingBuilderMixin): raise return (mock_stdout.getvalue(), mock_stderr.getvalue()) + def test_debug(self): + # NB: getLogger(name) always returns the same object + rb_logger = logging.getLogger("swift.ring.builder") + try: + self.assertNotEqual(rb_logger.getEffectiveLevel(), logging.DEBUG) + + self.run_srb("create", 8, 3, 1) + self.run_srb("add", + "r1z1-10.1.1.1:2345/sda", 100.0, + "r1z1-10.1.1.1:2345/sdb", 100.0, + "r1z1-10.1.1.1:2345/sdc", 100.0, + "r1z1-10.1.1.1:2345/sdd", 100.0) + self.run_srb("rebalance", "--debug") + self.assertEqual(rb_logger.getEffectiveLevel(), logging.DEBUG) + + rb_logger.setLevel(logging.INFO) + self.run_srb("rebalance", "--debug", "123") + self.assertEqual(rb_logger.getEffectiveLevel(), logging.DEBUG) + + rb_logger.setLevel(logging.INFO) + self.run_srb("rebalance", "123", "--debug") + self.assertEqual(rb_logger.getEffectiveLevel(), logging.DEBUG) + + finally: + rb_logger.setLevel(logging.INFO) # silence other test cases + def test_rebalance_warning_appears(self): self.run_srb("create", 8, 3, 24) # all in one machine: totally balanceable diff --git a/test/unit/common/middleware/test_dlo.py b/test/unit/common/middleware/test_dlo.py index a292bc92b8..16237eb1d1 100644 --- a/test/unit/common/middleware/test_dlo.py +++ b/test/unit/common/middleware/test_dlo.py @@ -564,9 +564,10 @@ class TestDloGetManifest(DloTestCase): environ={'REQUEST_METHOD': 'GET'}) status, headers, body = self.call_dlo(req) self.assertEqual(status, "409 Conflict") - err_log = self.dlo.logger.log_dict['exception'][0][0][0] - self.assertTrue(err_log.startswith('ERROR: An error occurred ' - 'while retrieving segments')) + err_lines = self.dlo.logger.get_lines_for_level('error') + self.assertEqual(len(err_lines), 1) + self.assertTrue(err_lines[0].startswith( + 'ERROR: An error occurred while retrieving segments')) def test_error_fetching_second_segment(self): self.app.register( @@ -581,9 +582,10 @@ class TestDloGetManifest(DloTestCase): self.assertTrue(isinstance(exc, exceptions.SegmentError)) self.assertEqual(status, "200 OK") self.assertEqual(''.join(body), "aaaaa") # first segment made it out - err_log = self.dlo.logger.log_dict['exception'][0][0][0] - self.assertTrue(err_log.startswith('ERROR: An error occurred ' - 'while retrieving segments')) + err_lines = self.dlo.logger.get_lines_for_level('error') + self.assertEqual(len(err_lines), 1) + self.assertTrue(err_lines[0].startswith( + 'ERROR: An error occurred while retrieving segments')) def test_error_listing_container_first_listing_request(self): self.app.register( diff --git a/test/unit/common/middleware/test_keystoneauth.py b/test/unit/common/middleware/test_keystoneauth.py index b1e7bbda13..078c275a7e 100644 --- a/test/unit/common/middleware/test_keystoneauth.py +++ b/test/unit/common/middleware/test_keystoneauth.py @@ -158,6 +158,31 @@ class SwiftAuth(unittest.TestCase): resp = req.get_response(self.test_auth) self.assertEqual(resp.status_int, 401) + def test_denied_responses(self): + + def get_resp_status(headers): + req = self._make_request(headers=headers) + resp = req.get_response(self.test_auth) + return resp.status_int + + self.assertEqual(get_resp_status({'X_IDENTITY_STATUS': 'Confirmed'}), + 403) + self.assertEqual(get_resp_status( + {'X_IDENTITY_STATUS': 'Confirmed', + 'X_SERVICE_IDENTITY_STATUS': 'Confirmed'}), 403) + self.assertEqual(get_resp_status({}), 401) + self.assertEqual(get_resp_status( + {'X_IDENTITY_STATUS': 'Invalid'}), 401) + self.assertEqual(get_resp_status( + {'X_IDENTITY_STATUS': 'Invalid', + 'X_SERVICE_IDENTITY_STATUS': 'Confirmed'}), 401) + self.assertEqual(get_resp_status( + {'X_IDENTITY_STATUS': 'Confirmed', + 'X_SERVICE_IDENTITY_STATUS': 'Invalid'}), 401) + self.assertEqual(get_resp_status( + {'X_IDENTITY_STATUS': 'Invalid', + 'X_SERVICE_IDENTITY_STATUS': 'Invalid'}), 401) + def test_blank_reseller_prefix(self): conf = {'reseller_prefix': ''} test_auth = keystoneauth.filter_factory(conf)(FakeApp()) @@ -854,6 +879,25 @@ class TestAuthorize(BaseTestAuthorize): acl = '%s:%s' % (id['HTTP_X_TENANT_ID'], id['HTTP_X_USER_ID']) self._check_authenticate(acl=acl, identity=id, env=env) + def test_keystone_identity(self): + user_name = 'U_NAME' + project = ('P_ID', 'P_NAME') + roles = ('ROLE1', 'ROLE2') + + req = Request.blank('/v/a/c/o') + req.headers.update({'X-Identity-Status': 'Confirmed', + 'X-Roles': ' %s , %s ' % roles, + 'X-User-Name': user_name, + 'X-Tenant-Id': project[0], + 'X-Tenant-Name': project[1]}) + + expected = {'user': user_name, + 'tenant': project, + 'roles': list(roles)} + data = self.test_auth._keystone_identity(req.environ) + + self.assertEquals(expected, data) + def test_integral_keystone_identity(self): user = ('U_ID', 'U_NAME') roles = ('ROLE1', 'ROLE2') diff --git a/test/unit/common/middleware/test_recon.py b/test/unit/common/middleware/test_recon.py index 66e97c3088..a46c4ae6c4 100644 --- a/test/unit/common/middleware/test_recon.py +++ b/test/unit/common/middleware/test_recon.py @@ -172,6 +172,9 @@ class FakeRecon(object): def fake_sockstat(self): return {'sockstattest': "1"} + def fake_driveaudit(self): + return {'driveaudittest': "1"} + def nocontent(self): return None @@ -489,6 +492,9 @@ class TestReconSuccess(TestCase): from_cache_response = {'async_pending': 5} self.fakecache.fakeout = from_cache_response rv = self.app.get_async_info() + self.assertEquals(self.fakecache.fakeout_calls, + [((['async_pending'], + '/var/cache/swift/object.recon'), {})]) self.assertEquals(rv, {'async_pending': 5}) def test_get_replication_info_account(self): @@ -585,6 +591,17 @@ class TestReconSuccess(TestCase): '/var/cache/swift/object.recon'), {})]) self.assertEquals(rv, {"object_updater_sweep": 0.79848217964172363}) + def test_get_expirer_info_object(self): + from_cache_response = {'object_expiration_pass': 0.79848217964172363, + 'expired_last_pass': 99} + self.fakecache.fakeout_calls = [] + self.fakecache.fakeout = from_cache_response + rv = self.app.get_expirer_info('object') + self.assertEquals(self.fakecache.fakeout_calls, + [((['object_expiration_pass', 'expired_last_pass'], + '/var/cache/swift/object.recon'), {})]) + self.assertEquals(rv, from_cache_response) + def test_get_auditor_info_account(self): from_cache_response = {"account_auditor_pass_completed": 0.24, "account_audits_failed": 0, @@ -829,6 +846,15 @@ class TestReconSuccess(TestCase): (('/proc/net/sockstat', 'r'), {}), (('/proc/net/sockstat6', 'r'), {})]) + def test_get_driveaudit_info(self): + from_cache_response = {'drive_audit_errors': 7} + self.fakecache.fakeout = from_cache_response + rv = self.app.get_driveaudit_error() + self.assertEquals(self.fakecache.fakeout_calls, + [((['drive_audit_errors'], + '/var/cache/swift/drive.recon'), {})]) + self.assertEquals(rv, {'drive_audit_errors': 7}) + class TestReconMiddleware(unittest.TestCase): @@ -857,6 +883,7 @@ class TestReconMiddleware(unittest.TestCase): self.app.get_swift_conf_md5 = self.frecon.fake_swiftconfmd5 self.app.get_quarantine_count = self.frecon.fake_quarantined self.app.get_socket_info = self.frecon.fake_sockstat + self.app.get_driveaudit_error = self.frecon.fake_driveaudit def test_recon_get_mem(self): get_mem_resp = ['{"memtest": "1"}'] @@ -1084,5 +1111,12 @@ class TestReconMiddleware(unittest.TestCase): resp = self.app(req.environ, start_response) self.assertEquals(resp, 'FAKE APP') + def test_recon_get_driveaudit(self): + get_driveaudit_resp = ['{"driveaudittest": "1"}'] + req = Request.blank('/recon/driveaudit', + environ={'REQUEST_METHOD': 'GET'}) + resp = self.app(req.environ, start_response) + self.assertEquals(resp, get_driveaudit_resp) + if __name__ == '__main__': unittest.main() diff --git a/test/unit/common/middleware/test_slo.py b/test/unit/common/middleware/test_slo.py index f4bac155ce..4160d91d46 100644 --- a/test/unit/common/middleware/test_slo.py +++ b/test/unit/common/middleware/test_slo.py @@ -1431,9 +1431,10 @@ class TestSloGetManifest(SloTestCase): self.assertEqual(status, '409 Conflict') self.assertEqual(self.app.call_count, 10) - err_log = self.slo.logger.log_dict['exception'][0][0][0] - self.assertTrue(err_log.startswith('ERROR: An error occurred ' - 'while retrieving segments')) + error_lines = self.slo.logger.get_lines_for_level('error') + self.assertEqual(len(error_lines), 1) + self.assertTrue(error_lines[0].startswith( + 'ERROR: An error occurred while retrieving segments')) def test_get_with_if_modified_since(self): # It's important not to pass the If-[Un]Modified-Since header to the @@ -1508,9 +1509,10 @@ class TestSloGetManifest(SloTestCase): status, headers, body = self.call_slo(req) self.assertEqual('409 Conflict', status) - err_log = self.slo.logger.log_dict['exception'][0][0][0] - self.assertTrue(err_log.startswith('ERROR: An error occurred ' - 'while retrieving segments')) + error_lines = self.slo.logger.get_lines_for_level('error') + self.assertEqual(len(error_lines), 1) + self.assertTrue(error_lines[0].startswith( + 'ERROR: An error occurred while retrieving segments')) def test_invalid_json_submanifest(self): self.app.register( @@ -1585,9 +1587,10 @@ class TestSloGetManifest(SloTestCase): status, headers, body = self.call_slo(req) self.assertEqual('409 Conflict', status) - err_log = self.slo.logger.log_dict['exception'][0][0][0] - self.assertTrue(err_log.startswith('ERROR: An error occurred ' - 'while retrieving segments')) + error_lines = self.slo.logger.get_lines_for_level('error') + self.assertEqual(len(error_lines), 1) + self.assertTrue(error_lines[0].startswith( + 'ERROR: An error occurred while retrieving segments')) def test_first_segment_mismatched_size(self): self.app.register('GET', '/v1/AUTH_test/gettest/manifest-badsize', @@ -1603,9 +1606,10 @@ class TestSloGetManifest(SloTestCase): status, headers, body = self.call_slo(req) self.assertEqual('409 Conflict', status) - err_log = self.slo.logger.log_dict['exception'][0][0][0] - self.assertTrue(err_log.startswith('ERROR: An error occurred ' - 'while retrieving segments')) + error_lines = self.slo.logger.get_lines_for_level('error') + self.assertEqual(len(error_lines), 1) + self.assertTrue(error_lines[0].startswith( + 'ERROR: An error occurred while retrieving segments')) def test_download_takes_too_long(self): the_time = [time.time()] @@ -1657,9 +1661,10 @@ class TestSloGetManifest(SloTestCase): status, headers, body = self.call_slo(req) self.assertEqual('409 Conflict', status) - err_log = self.slo.logger.log_dict['exception'][0][0][0] - self.assertTrue(err_log.startswith('ERROR: An error occurred ' - 'while retrieving segments')) + error_lines = self.slo.logger.get_lines_for_level('error') + self.assertEqual(len(error_lines), 1) + self.assertTrue(error_lines[0].startswith( + 'ERROR: An error occurred while retrieving segments')) class TestSloBulkLogger(unittest.TestCase): diff --git a/test/unit/common/middleware/test_tempauth.py b/test/unit/common/middleware/test_tempauth.py index 394668b47e..b9be84bb92 100644 --- a/test/unit/common/middleware/test_tempauth.py +++ b/test/unit/common/middleware/test_tempauth.py @@ -14,9 +14,10 @@ # limitations under the License. import unittest -from contextlib import contextmanager +from contextlib import contextmanager, nested from base64 import b64encode from time import time +import mock from swift.common.middleware import tempauth as auth from swift.common.middleware.acl import format_acl @@ -266,6 +267,25 @@ class TestAuth(unittest.TestCase): self.assertEquals(req.environ['swift.authorize'], local_auth.denied_response) + def test_auth_with_s3_authorization(self): + local_app = FakeApp() + local_auth = auth.filter_factory( + {'user_s3_s3': 's3 .admin'})(local_app) + req = self._make_request('/v1/AUTH_s3', + headers={'X-Auth-Token': 't', + 'AUTHORIZATION': 'AWS s3:s3:pass'}) + + with nested(mock.patch('base64.urlsafe_b64decode'), + mock.patch('base64.encodestring')) as (msg, sign): + msg.return_value = '' + sign.return_value = 'pass' + resp = req.get_response(local_auth) + + self.assertEquals(resp.status_int, 404) + self.assertEquals(local_app.calls, 1) + self.assertEquals(req.environ['swift.authorize'], + local_auth.authorize) + def test_auth_no_reseller_prefix_no_token(self): # Check that normally we set up a call back to our authorize. local_auth = auth.filter_factory({'reseller_prefix': ''})(FakeApp()) diff --git a/test/unit/common/ring/test_ring.py b/test/unit/common/ring/test_ring.py index fff715785b..b97b60eeee 100644 --- a/test/unit/common/ring/test_ring.py +++ b/test/unit/common/ring/test_ring.py @@ -363,63 +363,74 @@ class TestRing(TestRingBase): self.assertRaises(TypeError, self.ring.get_nodes) part, nodes = self.ring.get_nodes('a') self.assertEquals(part, 0) - self.assertEquals(nodes, [self.intended_devs[0], - self.intended_devs[3]]) + self.assertEquals(nodes, [dict(node, index=i) for i, node in + enumerate([self.intended_devs[0], + self.intended_devs[3]])]) part, nodes = self.ring.get_nodes('a1') self.assertEquals(part, 0) - self.assertEquals(nodes, [self.intended_devs[0], - self.intended_devs[3]]) + self.assertEquals(nodes, [dict(node, index=i) for i, node in + enumerate([self.intended_devs[0], + self.intended_devs[3]])]) part, nodes = self.ring.get_nodes('a4') self.assertEquals(part, 1) - self.assertEquals(nodes, [self.intended_devs[1], - self.intended_devs[4]]) + self.assertEquals(nodes, [dict(node, index=i) for i, node in + enumerate([self.intended_devs[1], + self.intended_devs[4]])]) part, nodes = self.ring.get_nodes('aa') self.assertEquals(part, 1) - self.assertEquals(nodes, [self.intended_devs[1], - self.intended_devs[4]]) + self.assertEquals(nodes, [dict(node, index=i) for i, node in + enumerate([self.intended_devs[1], + self.intended_devs[4]])]) part, nodes = self.ring.get_nodes('a', 'c1') self.assertEquals(part, 0) - self.assertEquals(nodes, [self.intended_devs[0], - self.intended_devs[3]]) + self.assertEquals(nodes, [dict(node, index=i) for i, node in + enumerate([self.intended_devs[0], + self.intended_devs[3]])]) part, nodes = self.ring.get_nodes('a', 'c0') self.assertEquals(part, 3) - self.assertEquals(nodes, [self.intended_devs[1], - self.intended_devs[4]]) + self.assertEquals(nodes, [dict(node, index=i) for i, node in + enumerate([self.intended_devs[1], + self.intended_devs[4]])]) part, nodes = self.ring.get_nodes('a', 'c3') self.assertEquals(part, 2) - self.assertEquals(nodes, [self.intended_devs[0], - self.intended_devs[3]]) + self.assertEquals(nodes, [dict(node, index=i) for i, node in + enumerate([self.intended_devs[0], + self.intended_devs[3]])]) part, nodes = self.ring.get_nodes('a', 'c2') - self.assertEquals(part, 2) - self.assertEquals(nodes, [self.intended_devs[0], - self.intended_devs[3]]) + self.assertEquals(nodes, [dict(node, index=i) for i, node in + enumerate([self.intended_devs[0], + self.intended_devs[3]])]) part, nodes = self.ring.get_nodes('a', 'c', 'o1') self.assertEquals(part, 1) - self.assertEquals(nodes, [self.intended_devs[1], - self.intended_devs[4]]) + self.assertEquals(nodes, [dict(node, index=i) for i, node in + enumerate([self.intended_devs[1], + self.intended_devs[4]])]) part, nodes = self.ring.get_nodes('a', 'c', 'o5') self.assertEquals(part, 0) - self.assertEquals(nodes, [self.intended_devs[0], - self.intended_devs[3]]) + self.assertEquals(nodes, [dict(node, index=i) for i, node in + enumerate([self.intended_devs[0], + self.intended_devs[3]])]) part, nodes = self.ring.get_nodes('a', 'c', 'o0') self.assertEquals(part, 0) - self.assertEquals(nodes, [self.intended_devs[0], - self.intended_devs[3]]) + self.assertEquals(nodes, [dict(node, index=i) for i, node in + enumerate([self.intended_devs[0], + self.intended_devs[3]])]) part, nodes = self.ring.get_nodes('a', 'c', 'o2') self.assertEquals(part, 2) - self.assertEquals(nodes, [self.intended_devs[0], - self.intended_devs[3]]) + self.assertEquals(nodes, [dict(node, index=i) for i, node in + enumerate([self.intended_devs[0], + self.intended_devs[3]])]) def add_dev_to_ring(self, new_dev): self.ring.devs.append(new_dev) diff --git a/test/unit/common/test_constraints.py b/test/unit/common/test_constraints.py index 7ae9fb44a4..61231d3f02 100644 --- a/test/unit/common/test_constraints.py +++ b/test/unit/common/test_constraints.py @@ -368,6 +368,11 @@ class TestConstraints(unittest.TestCase): self.assertTrue('X-Delete-At' in req.headers) self.assertEqual(req.headers['X-Delete-At'], expected) + def test_check_dir(self): + self.assertFalse(constraints.check_dir('', '')) + with mock.patch("os.path.isdir", MockTrue()): + self.assertTrue(constraints.check_dir('/srv', 'foo/bar')) + def test_check_mount(self): self.assertFalse(constraints.check_mount('', '')) with mock.patch("swift.common.utils.ismount", MockTrue()): diff --git a/test/unit/common/test_internal_client.py b/test/unit/common/test_internal_client.py index df140ebdde..b7d6806880 100644 --- a/test/unit/common/test_internal_client.py +++ b/test/unit/common/test_internal_client.py @@ -235,19 +235,20 @@ class TestInternalClient(unittest.TestCase): write_fake_ring(object_ring_path) with patch_policies([StoragePolicy(0, 'legacy', True)]): client = internal_client.InternalClient(conf_path, 'test', 1) - self.assertEqual(client.account_ring, client.app.app.app.account_ring) - self.assertEqual(client.account_ring.serialized_path, - account_ring_path) - self.assertEqual(client.container_ring, - client.app.app.app.container_ring) - self.assertEqual(client.container_ring.serialized_path, - container_ring_path) - object_ring = client.app.app.app.get_object_ring(0) - self.assertEqual(client.get_object_ring(0), - object_ring) - self.assertEqual(object_ring.serialized_path, - object_ring_path) - self.assertEquals(client.auto_create_account_prefix, '-') + self.assertEqual(client.account_ring, + client.app.app.app.account_ring) + self.assertEqual(client.account_ring.serialized_path, + account_ring_path) + self.assertEqual(client.container_ring, + client.app.app.app.container_ring) + self.assertEqual(client.container_ring.serialized_path, + container_ring_path) + object_ring = client.app.app.app.get_object_ring(0) + self.assertEqual(client.get_object_ring(0), + object_ring) + self.assertEqual(object_ring.serialized_path, + object_ring_path) + self.assertEquals(client.auto_create_account_prefix, '-') def test_init(self): class App(object): @@ -333,6 +334,24 @@ class TestInternalClient(unittest.TestCase): self.assertEquals(3, client.sleep_called) self.assertEquals(4, client.tries) + def test_base_request_timeout(self): + # verify that base_request passes timeout arg on to urlopen + body = {"some": "content"} + + class FakeConn(object): + def read(self): + return json.dumps(body) + + for timeout in (0.0, 42.0, None): + mocked_func = 'swift.common.internal_client.urllib2.urlopen' + with mock.patch(mocked_func) as mock_urlopen: + mock_urlopen.side_effect = [FakeConn()] + sc = internal_client.SimpleClient('http://0.0.0.0/') + _, resp_body = sc.base_request('GET', timeout=timeout) + mock_urlopen.assert_called_once_with(mock.ANY, timeout=timeout) + # sanity check + self.assertEquals(body, resp_body) + def test_make_request_method_path_headers(self): class InternalClient(internal_client.InternalClient): def __init__(self): diff --git a/test/unit/common/test_manager.py b/test/unit/common/test_manager.py index be330b58de..8896fc138a 100644 --- a/test/unit/common/test_manager.py +++ b/test/unit/common/test_manager.py @@ -445,7 +445,8 @@ class TestServer(unittest.TestCase): # check warn "unable to locate" conf_files = server.conf_files() self.assertFalse(conf_files) - self.assert_('unable to locate' in pop_stream(f).lower()) + self.assert_('unable to locate config for auth' + in pop_stream(f).lower()) # check quiet will silence warning conf_files = server.conf_files(verbose=True, quiet=True) self.assertEquals(pop_stream(f), '') @@ -455,7 +456,9 @@ class TestServer(unittest.TestCase): self.assertEquals(pop_stream(f), '') # check missing config number warn "unable to locate" conf_files = server.conf_files(number=2) - self.assert_('unable to locate' in pop_stream(f).lower()) + self.assert_( + 'unable to locate config number 2 for ' + + 'container-auditor' in pop_stream(f).lower()) # check verbose lists configs conf_files = server.conf_files(number=2, verbose=True) c1 = self.join_swift_dir('container-server/1.conf') diff --git a/test/unit/common/test_request_helpers.py b/test/unit/common/test_request_helpers.py index c87a39979b..d2dc02c48b 100644 --- a/test/unit/common/test_request_helpers.py +++ b/test/unit/common/test_request_helpers.py @@ -16,10 +16,13 @@ """Tests for swift.common.request_helpers""" import unittest -from swift.common.swob import Request +from swift.common.swob import Request, HTTPException +from swift.common.storage_policy import POLICIES, EC_POLICY, REPL_POLICY from swift.common.request_helpers import is_sys_meta, is_user_meta, \ is_sys_or_user_meta, strip_sys_meta_prefix, strip_user_meta_prefix, \ - remove_items, copy_header_subset + remove_items, copy_header_subset, get_name_and_placement + +from test.unit import patch_policies server_types = ['account', 'container', 'object'] @@ -81,3 +84,77 @@ class TestRequestHelpers(unittest.TestCase): self.assertEqual(to_req.headers['A'], 'b') self.assertFalse('c' in to_req.headers) self.assertFalse('C' in to_req.headers) + + @patch_policies(with_ec_default=True) + def test_get_name_and_placement_object_req(self): + path = '/device/part/account/container/object' + req = Request.blank(path, headers={ + 'X-Backend-Storage-Policy-Index': '0'}) + device, part, account, container, obj, policy = \ + get_name_and_placement(req, 5, 5, True) + self.assertEqual(device, 'device') + self.assertEqual(part, 'part') + self.assertEqual(account, 'account') + self.assertEqual(container, 'container') + self.assertEqual(obj, 'object') + self.assertEqual(policy, POLICIES[0]) + self.assertEqual(policy.policy_type, EC_POLICY) + + req.headers['X-Backend-Storage-Policy-Index'] = 1 + device, part, account, container, obj, policy = \ + get_name_and_placement(req, 5, 5, True) + self.assertEqual(device, 'device') + self.assertEqual(part, 'part') + self.assertEqual(account, 'account') + self.assertEqual(container, 'container') + self.assertEqual(obj, 'object') + self.assertEqual(policy, POLICIES[1]) + self.assertEqual(policy.policy_type, REPL_POLICY) + + req.headers['X-Backend-Storage-Policy-Index'] = 'foo' + try: + device, part, account, container, obj, policy = \ + get_name_and_placement(req, 5, 5, True) + except HTTPException as e: + self.assertEqual(e.status_int, 503) + self.assertEqual(str(e), '503 Service Unavailable') + self.assertEqual(e.body, "No policy with index foo") + else: + self.fail('get_name_and_placement did not raise error ' + 'for invalid storage policy index') + + @patch_policies(with_ec_default=True) + def test_get_name_and_placement_object_replication(self): + # yup, suffixes are sent '-'.joined in the path + path = '/device/part/012-345-678-9ab-cde' + req = Request.blank(path, headers={ + 'X-Backend-Storage-Policy-Index': '0'}) + device, partition, suffix_parts, policy = \ + get_name_and_placement(req, 2, 3, True) + self.assertEqual(device, 'device') + self.assertEqual(partition, 'part') + self.assertEqual(suffix_parts, '012-345-678-9ab-cde') + self.assertEqual(policy, POLICIES[0]) + self.assertEqual(policy.policy_type, EC_POLICY) + + path = '/device/part' + req = Request.blank(path, headers={ + 'X-Backend-Storage-Policy-Index': '1'}) + device, partition, suffix_parts, policy = \ + get_name_and_placement(req, 2, 3, True) + self.assertEqual(device, 'device') + self.assertEqual(partition, 'part') + self.assertEqual(suffix_parts, None) # false-y + self.assertEqual(policy, POLICIES[1]) + self.assertEqual(policy.policy_type, REPL_POLICY) + + path = '/device/part/' # with a trailing slash + req = Request.blank(path, headers={ + 'X-Backend-Storage-Policy-Index': '1'}) + device, partition, suffix_parts, policy = \ + get_name_and_placement(req, 2, 3, True) + self.assertEqual(device, 'device') + self.assertEqual(partition, 'part') + self.assertEqual(suffix_parts, '') # still false-y + self.assertEqual(policy, POLICIES[1]) + self.assertEqual(policy.policy_type, REPL_POLICY) diff --git a/test/unit/common/test_storage_policy.py b/test/unit/common/test_storage_policy.py index e154a11b63..6406dc1923 100644 --- a/test/unit/common/test_storage_policy.py +++ b/test/unit/common/test_storage_policy.py @@ -19,8 +19,23 @@ import mock from tempfile import NamedTemporaryFile from test.unit import patch_policies, FakeRing from swift.common.storage_policy import ( - StoragePolicy, StoragePolicyCollection, POLICIES, PolicyError, - parse_storage_policies, reload_storage_policies, get_policy_string) + StoragePolicyCollection, POLICIES, PolicyError, parse_storage_policies, + reload_storage_policies, get_policy_string, split_policy_string, + BaseStoragePolicy, StoragePolicy, ECStoragePolicy, REPL_POLICY, EC_POLICY, + VALID_EC_TYPES, DEFAULT_EC_OBJECT_SEGMENT_SIZE) +from swift.common.exceptions import RingValidationError + + +@BaseStoragePolicy.register('fake') +class FakeStoragePolicy(BaseStoragePolicy): + """ + Test StoragePolicy class - the only user at the moment is + test_validate_policies_type_invalid() + """ + def __init__(self, idx, name='', is_default=False, is_deprecated=False, + object_ring=None): + super(FakeStoragePolicy, self).__init__( + idx, name, is_default, is_deprecated, object_ring) class TestStoragePolicies(unittest.TestCase): @@ -31,15 +46,35 @@ class TestStoragePolicies(unittest.TestCase): conf.readfp(StringIO.StringIO(conf_str)) return conf - @patch_policies([StoragePolicy(0, 'zero', True), - StoragePolicy(1, 'one', False), - StoragePolicy(2, 'two', False), - StoragePolicy(3, 'three', False, is_deprecated=True)]) + def assertRaisesWithMessage(self, exc_class, message, f, *args, **kwargs): + try: + f(*args, **kwargs) + except exc_class as err: + err_msg = str(err) + self.assert_(message in err_msg, 'Error message %r did not ' + 'have expected substring %r' % (err_msg, message)) + else: + self.fail('%r did not raise %s' % (message, exc_class.__name__)) + + def test_policy_baseclass_instantiate(self): + self.assertRaisesWithMessage(TypeError, + "Can't instantiate BaseStoragePolicy", + BaseStoragePolicy, 1, 'one') + + @patch_policies([ + StoragePolicy(0, 'zero', is_default=True), + StoragePolicy(1, 'one'), + StoragePolicy(2, 'two'), + StoragePolicy(3, 'three', is_deprecated=True), + ECStoragePolicy(10, 'ten', ec_type='jerasure_rs_vand', + ec_ndata=10, ec_nparity=4), + ]) def test_swift_info(self): # the deprecated 'three' should not exist in expect expect = [{'default': True, 'name': 'zero'}, {'name': 'two'}, - {'name': 'one'}] + {'name': 'one'}, + {'name': 'ten'}] swift_info = POLICIES.get_policy_info() self.assertEquals(sorted(expect, key=lambda k: k['name']), sorted(swift_info, key=lambda k: k['name'])) @@ -48,10 +83,48 @@ class TestStoragePolicies(unittest.TestCase): def test_get_policy_string(self): self.assertEquals(get_policy_string('something', 0), 'something') self.assertEquals(get_policy_string('something', None), 'something') + self.assertEquals(get_policy_string('something', ''), 'something') self.assertEquals(get_policy_string('something', 1), 'something' + '-1') self.assertRaises(PolicyError, get_policy_string, 'something', 99) + @patch_policies + def test_split_policy_string(self): + expectations = { + 'something': ('something', POLICIES[0]), + 'something-1': ('something', POLICIES[1]), + 'tmp': ('tmp', POLICIES[0]), + 'objects': ('objects', POLICIES[0]), + 'tmp-1': ('tmp', POLICIES[1]), + 'objects-1': ('objects', POLICIES[1]), + 'objects-': PolicyError, + 'objects-0': PolicyError, + 'objects--1': ('objects-', POLICIES[1]), + 'objects-+1': PolicyError, + 'objects--': PolicyError, + 'objects-foo': PolicyError, + 'objects--bar': PolicyError, + 'objects-+bar': PolicyError, + # questionable, demonstrated as inverse of get_policy_string + 'objects+0': ('objects+0', POLICIES[0]), + '': ('', POLICIES[0]), + '0': ('0', POLICIES[0]), + '-1': ('', POLICIES[1]), + } + for policy_string, expected in expectations.items(): + if expected == PolicyError: + try: + invalid = split_policy_string(policy_string) + except PolicyError: + continue # good + else: + self.fail('The string %r returned %r ' + 'instead of raising a PolicyError' % + (policy_string, invalid)) + self.assertEqual(expected, split_policy_string(policy_string)) + # should be inverse of get_policy_string + self.assertEqual(policy_string, get_policy_string(*expected)) + def test_defaults(self): self.assertTrue(len(POLICIES) > 0) @@ -66,7 +139,9 @@ class TestStoragePolicies(unittest.TestCase): def test_storage_policy_repr(self): test_policies = [StoragePolicy(0, 'aay', True), StoragePolicy(1, 'bee', False), - StoragePolicy(2, 'cee', False)] + StoragePolicy(2, 'cee', False), + ECStoragePolicy(10, 'ten', ec_type='jerasure_rs_vand', + ec_ndata=10, ec_nparity=3)] policies = StoragePolicyCollection(test_policies) for policy in policies: policy_repr = repr(policy) @@ -75,6 +150,13 @@ class TestStoragePolicies(unittest.TestCase): self.assert_('is_deprecated=%s' % policy.is_deprecated in policy_repr) self.assert_(policy.name in policy_repr) + if policy.policy_type == EC_POLICY: + self.assert_('ec_type=%s' % policy.ec_type in policy_repr) + self.assert_('ec_ndata=%s' % policy.ec_ndata in policy_repr) + self.assert_('ec_nparity=%s' % + policy.ec_nparity in policy_repr) + self.assert_('ec_segment_size=%s' % + policy.ec_segment_size in policy_repr) collection_repr = repr(policies) collection_repr_lines = collection_repr.splitlines() self.assert_(policies.__class__.__name__ in collection_repr_lines[0]) @@ -157,15 +239,16 @@ class TestStoragePolicies(unittest.TestCase): def test_validate_policy_params(self): StoragePolicy(0, 'name') # sanity # bogus indexes - self.assertRaises(PolicyError, StoragePolicy, 'x', 'name') - self.assertRaises(PolicyError, StoragePolicy, -1, 'name') + self.assertRaises(PolicyError, FakeStoragePolicy, 'x', 'name') + self.assertRaises(PolicyError, FakeStoragePolicy, -1, 'name') + # non-zero Policy-0 - self.assertRaisesWithMessage(PolicyError, 'reserved', StoragePolicy, - 1, 'policy-0') + self.assertRaisesWithMessage(PolicyError, 'reserved', + FakeStoragePolicy, 1, 'policy-0') # deprecate default self.assertRaisesWithMessage( PolicyError, 'Deprecated policy can not be default', - StoragePolicy, 1, 'Policy-1', is_default=True, + FakeStoragePolicy, 1, 'Policy-1', is_default=True, is_deprecated=True) # weird names names = ( @@ -178,7 +261,7 @@ class TestStoragePolicies(unittest.TestCase): ) for name in names: self.assertRaisesWithMessage(PolicyError, 'Invalid name', - StoragePolicy, 1, name) + FakeStoragePolicy, 1, name) def test_validate_policies_names(self): # duplicate names @@ -188,6 +271,40 @@ class TestStoragePolicies(unittest.TestCase): self.assertRaises(PolicyError, StoragePolicyCollection, test_policies) + def test_validate_policies_type_default(self): + # no type specified - make sure the policy is initialized to + # DEFAULT_POLICY_TYPE + test_policy = FakeStoragePolicy(0, 'zero', True) + self.assertEquals(test_policy.policy_type, 'fake') + + def test_validate_policies_type_invalid(self): + class BogusStoragePolicy(FakeStoragePolicy): + policy_type = 'bogus' + # unsupported policy type - initialization with FakeStoragePolicy + self.assertRaisesWithMessage(PolicyError, 'Invalid type', + BogusStoragePolicy, 1, 'one') + + def test_policies_type_attribute(self): + test_policies = [ + StoragePolicy(0, 'zero', is_default=True), + StoragePolicy(1, 'one'), + StoragePolicy(2, 'two'), + StoragePolicy(3, 'three', is_deprecated=True), + ECStoragePolicy(10, 'ten', ec_type='jerasure_rs_vand', + ec_ndata=10, ec_nparity=3), + ] + policies = StoragePolicyCollection(test_policies) + self.assertEquals(policies.get_by_index(0).policy_type, + REPL_POLICY) + self.assertEquals(policies.get_by_index(1).policy_type, + REPL_POLICY) + self.assertEquals(policies.get_by_index(2).policy_type, + REPL_POLICY) + self.assertEquals(policies.get_by_index(3).policy_type, + REPL_POLICY) + self.assertEquals(policies.get_by_index(10).policy_type, + EC_POLICY) + def test_names_are_normalized(self): test_policies = [StoragePolicy(0, 'zero', True), StoragePolicy(1, 'ZERO', False)] @@ -207,16 +324,6 @@ class TestStoragePolicies(unittest.TestCase): self.assertEqual(pol1, policies.get_by_name(name)) self.assertEqual(policies.get_by_name(name).name, 'One') - def assertRaisesWithMessage(self, exc_class, message, f, *args, **kwargs): - try: - f(*args, **kwargs) - except exc_class as err: - err_msg = str(err) - self.assert_(message in err_msg, 'Error message %r did not ' - 'have expected substring %r' % (err_msg, message)) - else: - self.fail('%r did not raise %s' % (message, exc_class.__name__)) - def test_deprecated_default(self): bad_conf = self._conf(""" [storage-policy:1] @@ -395,6 +502,133 @@ class TestStoragePolicies(unittest.TestCase): self.assertRaisesWithMessage(PolicyError, 'Invalid name', parse_storage_policies, bad_conf) + # policy_type = erasure_coding + + # missing ec_type, ec_num_data_fragments and ec_num_parity_fragments + bad_conf = self._conf(""" + [storage-policy:0] + name = zero + [storage-policy:1] + name = ec10-4 + policy_type = erasure_coding + """) + + self.assertRaisesWithMessage(PolicyError, 'Missing ec_type', + parse_storage_policies, bad_conf) + + # missing ec_type, but other options valid... + bad_conf = self._conf(""" + [storage-policy:0] + name = zero + [storage-policy:1] + name = ec10-4 + policy_type = erasure_coding + ec_num_data_fragments = 10 + ec_num_parity_fragments = 4 + """) + + self.assertRaisesWithMessage(PolicyError, 'Missing ec_type', + parse_storage_policies, bad_conf) + + # ec_type specified, but invalid... + bad_conf = self._conf(""" + [storage-policy:0] + name = zero + default = yes + [storage-policy:1] + name = ec10-4 + policy_type = erasure_coding + ec_type = garbage_alg + ec_num_data_fragments = 10 + ec_num_parity_fragments = 4 + """) + + self.assertRaisesWithMessage(PolicyError, + 'Wrong ec_type garbage_alg for policy ' + 'ec10-4, should be one of "%s"' % + (', '.join(VALID_EC_TYPES)), + parse_storage_policies, bad_conf) + + # missing and invalid ec_num_parity_fragments + bad_conf = self._conf(""" + [storage-policy:0] + name = zero + [storage-policy:1] + name = ec10-4 + policy_type = erasure_coding + ec_type = jerasure_rs_vand + ec_num_data_fragments = 10 + """) + + self.assertRaisesWithMessage(PolicyError, + 'Invalid ec_num_parity_fragments', + parse_storage_policies, bad_conf) + + for num_parity in ('-4', '0', 'x'): + bad_conf = self._conf(""" + [storage-policy:0] + name = zero + [storage-policy:1] + name = ec10-4 + policy_type = erasure_coding + ec_type = jerasure_rs_vand + ec_num_data_fragments = 10 + ec_num_parity_fragments = %s + """ % num_parity) + + self.assertRaisesWithMessage(PolicyError, + 'Invalid ec_num_parity_fragments', + parse_storage_policies, bad_conf) + + # missing and invalid ec_num_data_fragments + bad_conf = self._conf(""" + [storage-policy:0] + name = zero + [storage-policy:1] + name = ec10-4 + policy_type = erasure_coding + ec_type = jerasure_rs_vand + ec_num_parity_fragments = 4 + """) + + self.assertRaisesWithMessage(PolicyError, + 'Invalid ec_num_data_fragments', + parse_storage_policies, bad_conf) + + for num_data in ('-10', '0', 'x'): + bad_conf = self._conf(""" + [storage-policy:0] + name = zero + [storage-policy:1] + name = ec10-4 + policy_type = erasure_coding + ec_type = jerasure_rs_vand + ec_num_data_fragments = %s + ec_num_parity_fragments = 4 + """ % num_data) + + self.assertRaisesWithMessage(PolicyError, + 'Invalid ec_num_data_fragments', + parse_storage_policies, bad_conf) + + # invalid ec_object_segment_size + for segment_size in ('-4', '0', 'x'): + bad_conf = self._conf(""" + [storage-policy:0] + name = zero + [storage-policy:1] + name = ec10-4 + policy_type = erasure_coding + ec_object_segment_size = %s + ec_type = jerasure_rs_vand + ec_num_data_fragments = 10 + ec_num_parity_fragments = 4 + """ % segment_size) + + self.assertRaisesWithMessage(PolicyError, + 'Invalid ec_object_segment_size', + parse_storage_policies, bad_conf) + # Additional section added to ensure parser ignores other sections conf = self._conf(""" [some-other-section] @@ -430,6 +664,8 @@ class TestStoragePolicies(unittest.TestCase): self.assertEquals("zero", policies.get_by_index(None).name) self.assertEquals("zero", policies.get_by_index('').name) + self.assertEqual(policies.get_by_index(0), policies.legacy) + def test_reload_invalid_storage_policies(self): conf = self._conf(""" [storage-policy:0] @@ -512,6 +748,125 @@ class TestStoragePolicies(unittest.TestCase): for policy in POLICIES: self.assertEqual(POLICIES[int(policy)], policy) + def test_quorum_size_replication(self): + expected_sizes = {1: 1, + 2: 2, + 3: 2, + 4: 3, + 5: 3} + for n, expected in expected_sizes.items(): + policy = StoragePolicy(0, 'zero', + object_ring=FakeRing(replicas=n)) + self.assertEqual(policy.quorum, expected) + + def test_quorum_size_erasure_coding(self): + test_ec_policies = [ + ECStoragePolicy(10, 'ec8-2', ec_type='jerasure_rs_vand', + ec_ndata=8, ec_nparity=2), + ECStoragePolicy(11, 'df10-6', ec_type='flat_xor_hd_4', + ec_ndata=10, ec_nparity=6), + ] + for ec_policy in test_ec_policies: + k = ec_policy.ec_ndata + expected_size = \ + k + ec_policy.pyeclib_driver.min_parity_fragments_needed() + self.assertEqual(expected_size, ec_policy.quorum) + + def test_validate_ring(self): + test_policies = [ + ECStoragePolicy(0, 'ec8-2', ec_type='jerasure_rs_vand', + ec_ndata=8, ec_nparity=2, + object_ring=FakeRing(replicas=8), + is_default=True), + ECStoragePolicy(1, 'ec10-4', ec_type='jerasure_rs_vand', + ec_ndata=10, ec_nparity=4, + object_ring=FakeRing(replicas=10)), + ECStoragePolicy(2, 'ec4-2', ec_type='jerasure_rs_vand', + ec_ndata=4, ec_nparity=2, + object_ring=FakeRing(replicas=7)), + ] + policies = StoragePolicyCollection(test_policies) + + for policy in policies: + msg = 'EC ring for policy %s needs to be configured with ' \ + 'exactly %d nodes.' % \ + (policy.name, policy.ec_ndata + policy.ec_nparity) + self.assertRaisesWithMessage( + RingValidationError, msg, + policy._validate_ring) + + def test_storage_policy_get_info(self): + test_policies = [ + StoragePolicy(0, 'zero', is_default=True), + StoragePolicy(1, 'one', is_deprecated=True), + ECStoragePolicy(10, 'ten', + ec_type='jerasure_rs_vand', + ec_ndata=10, ec_nparity=3), + ECStoragePolicy(11, 'done', is_deprecated=True, + ec_type='jerasure_rs_vand', + ec_ndata=10, ec_nparity=3), + ] + policies = StoragePolicyCollection(test_policies) + expected = { + # default replication + (0, True): { + 'name': 'zero', + 'default': True, + 'deprecated': False, + 'policy_type': REPL_POLICY + }, + (0, False): { + 'name': 'zero', + 'default': True, + }, + # deprecated replication + (1, True): { + 'name': 'one', + 'default': False, + 'deprecated': True, + 'policy_type': REPL_POLICY + }, + (1, False): { + 'name': 'one', + 'deprecated': True, + }, + # enabled ec + (10, True): { + 'name': 'ten', + 'default': False, + 'deprecated': False, + 'policy_type': EC_POLICY, + 'ec_type': 'jerasure_rs_vand', + 'ec_num_data_fragments': 10, + 'ec_num_parity_fragments': 3, + 'ec_object_segment_size': DEFAULT_EC_OBJECT_SEGMENT_SIZE, + }, + (10, False): { + 'name': 'ten', + }, + # deprecated ec + (11, True): { + 'name': 'done', + 'default': False, + 'deprecated': True, + 'policy_type': EC_POLICY, + 'ec_type': 'jerasure_rs_vand', + 'ec_num_data_fragments': 10, + 'ec_num_parity_fragments': 3, + 'ec_object_segment_size': DEFAULT_EC_OBJECT_SEGMENT_SIZE, + }, + (11, False): { + 'name': 'done', + 'deprecated': True, + }, + } + self.maxDiff = None + for policy in policies: + expected_info = expected[(int(policy), True)] + self.assertEqual(policy.get_info(config=True), expected_info) + expected_info = expected[(int(policy), False)] + self.assertEqual(policy.get_info(config=False), expected_info) + if __name__ == '__main__': unittest.main() diff --git a/test/unit/common/test_swob.py b/test/unit/common/test_swob.py index fffb33ecf1..7015abb8eb 100644 --- a/test/unit/common/test_swob.py +++ b/test/unit/common/test_swob.py @@ -1553,6 +1553,17 @@ class TestConditionalIfMatch(unittest.TestCase): self.assertEquals(resp.status_int, 200) self.assertEquals(body, 'hi') + def test_simple_conditional_etag_match(self): + # if etag matches, proceed as normal + req = swift.common.swob.Request.blank( + '/', headers={'If-Match': 'not-the-etag'}) + resp = req.get_response(self.fake_app) + resp.conditional_response = True + resp._conditional_etag = 'not-the-etag' + body = ''.join(resp(req.environ, self.fake_start_response)) + self.assertEquals(resp.status_int, 200) + self.assertEquals(body, 'hi') + def test_quoted_simple_match(self): # double quotes or not, doesn't matter req = swift.common.swob.Request.blank( @@ -1573,6 +1584,16 @@ class TestConditionalIfMatch(unittest.TestCase): self.assertEquals(resp.status_int, 412) self.assertEquals(body, '') + def test_simple_conditional_etag_no_match(self): + req = swift.common.swob.Request.blank( + '/', headers={'If-Match': 'the-etag'}) + resp = req.get_response(self.fake_app) + resp.conditional_response = True + resp._conditional_etag = 'not-the-etag' + body = ''.join(resp(req.environ, self.fake_start_response)) + self.assertEquals(resp.status_int, 412) + self.assertEquals(body, '') + def test_match_star(self): # "*" means match anything; see RFC 2616 section 14.24 req = swift.common.swob.Request.blank( diff --git a/test/unit/common/test_utils.py b/test/unit/common/test_utils.py index aad08d5d32..22aa3db5e1 100644 --- a/test/unit/common/test_utils.py +++ b/test/unit/common/test_utils.py @@ -1043,54 +1043,58 @@ class TestUtils(unittest.TestCase): handler = logging.StreamHandler(sio) logger = logging.getLogger() logger.addHandler(handler) - lfo = utils.LoggerFileObject(logger) + lfo_stdout = utils.LoggerFileObject(logger) + lfo_stderr = utils.LoggerFileObject(logger) + lfo_stderr = utils.LoggerFileObject(logger, 'STDERR') print 'test1' self.assertEquals(sio.getvalue(), '') - sys.stdout = lfo + sys.stdout = lfo_stdout print 'test2' self.assertEquals(sio.getvalue(), 'STDOUT: test2\n') - sys.stderr = lfo + sys.stderr = lfo_stderr print >> sys.stderr, 'test4' - self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDOUT: test4\n') + self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDERR: test4\n') sys.stdout = orig_stdout print 'test5' - self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDOUT: test4\n') + self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDERR: test4\n') print >> sys.stderr, 'test6' - self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDOUT: test4\n' - 'STDOUT: test6\n') + self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDERR: test4\n' + 'STDERR: test6\n') sys.stderr = orig_stderr print 'test8' - self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDOUT: test4\n' - 'STDOUT: test6\n') - lfo.writelines(['a', 'b', 'c']) - self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDOUT: test4\n' - 'STDOUT: test6\nSTDOUT: a#012b#012c\n') - lfo.close() - lfo.write('d') - self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDOUT: test4\n' - 'STDOUT: test6\nSTDOUT: a#012b#012c\nSTDOUT: d\n') - lfo.flush() - self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDOUT: test4\n' - 'STDOUT: test6\nSTDOUT: a#012b#012c\nSTDOUT: d\n') - got_exc = False - try: - for line in lfo: - pass - except Exception: - got_exc = True - self.assert_(got_exc) - got_exc = False - try: - for line in lfo.xreadlines(): - pass - except Exception: - got_exc = True - self.assert_(got_exc) - self.assertRaises(IOError, lfo.read) - self.assertRaises(IOError, lfo.read, 1024) - self.assertRaises(IOError, lfo.readline) - self.assertRaises(IOError, lfo.readline, 1024) - lfo.tell() + self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDERR: test4\n' + 'STDERR: test6\n') + lfo_stdout.writelines(['a', 'b', 'c']) + self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDERR: test4\n' + 'STDERR: test6\nSTDOUT: a#012b#012c\n') + lfo_stdout.close() + lfo_stderr.close() + lfo_stdout.write('d') + self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDERR: test4\n' + 'STDERR: test6\nSTDOUT: a#012b#012c\nSTDOUT: d\n') + lfo_stdout.flush() + self.assertEquals(sio.getvalue(), 'STDOUT: test2\nSTDERR: test4\n' + 'STDERR: test6\nSTDOUT: a#012b#012c\nSTDOUT: d\n') + for lfo in (lfo_stdout, lfo_stderr): + got_exc = False + try: + for line in lfo: + pass + except Exception: + got_exc = True + self.assert_(got_exc) + got_exc = False + try: + for line in lfo.xreadlines(): + pass + except Exception: + got_exc = True + self.assert_(got_exc) + self.assertRaises(IOError, lfo.read) + self.assertRaises(IOError, lfo.read, 1024) + self.assertRaises(IOError, lfo.readline) + self.assertRaises(IOError, lfo.readline, 1024) + lfo.tell() def test_parse_options(self): # Get a file that is definitely on disk @@ -2186,13 +2190,14 @@ cluster_dfw1 = http://dfw1.host/v1/ self.assertFalse(utils.streq_const_time('a', 'aaaaa')) self.assertFalse(utils.streq_const_time('ABC123', 'abc123')) - def test_quorum_size(self): + def test_replication_quorum_size(self): expected_sizes = {1: 1, 2: 2, 3: 2, 4: 3, 5: 3} - got_sizes = dict([(n, utils.quorum_size(n)) for n in expected_sizes]) + got_sizes = dict([(n, utils.quorum_size(n)) + for n in expected_sizes]) self.assertEqual(expected_sizes, got_sizes) def test_rsync_ip_ipv4_localhost(self): @@ -4589,6 +4594,22 @@ class TestLRUCache(unittest.TestCase): self.assertEqual(f.size(), 4) +class TestParseContentRange(unittest.TestCase): + def test_good(self): + start, end, total = utils.parse_content_range("bytes 100-200/300") + self.assertEqual(start, 100) + self.assertEqual(end, 200) + self.assertEqual(total, 300) + + def test_bad(self): + self.assertRaises(ValueError, utils.parse_content_range, + "100-300/500") + self.assertRaises(ValueError, utils.parse_content_range, + "bytes 100-200/aardvark") + self.assertRaises(ValueError, utils.parse_content_range, + "bytes bulbous-bouffant/4994801") + + class TestParseContentDisposition(unittest.TestCase): def test_basic_content_type(self): @@ -4618,7 +4639,8 @@ class TestIterMultipartMimeDocuments(unittest.TestCase): it.next() except MimeInvalid as err: exc = err - self.assertEquals(str(exc), 'invalid starting boundary') + self.assertTrue('invalid starting boundary' in str(exc)) + self.assertTrue('--unique' in str(exc)) def test_empty(self): it = utils.iter_multipart_mime_documents(StringIO('--unique'), diff --git a/test/unit/common/test_wsgi.py b/test/unit/common/test_wsgi.py index 67142decdd..279eb8624b 100644 --- a/test/unit/common/test_wsgi.py +++ b/test/unit/common/test_wsgi.py @@ -156,6 +156,27 @@ class TestWSGI(unittest.TestCase): logger.info('testing') self.assertEquals('proxy-server', log_name) + @with_tempdir + def test_loadapp_from_file(self, tempdir): + conf_path = os.path.join(tempdir, 'object-server.conf') + conf_body = """ + [app:main] + use = egg:swift#object + """ + contents = dedent(conf_body) + with open(conf_path, 'w') as f: + f.write(contents) + app = wsgi.loadapp(conf_path) + self.assertTrue(isinstance(app, obj_server.ObjectController)) + + def test_loadapp_from_string(self): + conf_body = """ + [app:main] + use = egg:swift#object + """ + app = wsgi.loadapp(wsgi.ConfigString(conf_body)) + self.assertTrue(isinstance(app, obj_server.ObjectController)) + def test_init_request_processor_from_conf_dir(self): config_dir = { 'proxy-server.conf.d/pipeline.conf': """ diff --git a/test/unit/container/test_backend.py b/test/unit/container/test_backend.py index 6a21a3afab..76a42d6e57 100644 --- a/test/unit/container/test_backend.py +++ b/test/unit/container/test_backend.py @@ -555,6 +555,44 @@ class TestContainerBroker(unittest.TestCase): self.assertEqual(stat['bytes_used'], sum(stats[policy_index].values())) + def test_initialize_container_broker_in_default(self): + broker = ContainerBroker(':memory:', account='test1', + container='test2') + + # initialize with no storage_policy_index argument + broker.initialize(Timestamp(1).internal) + + info = broker.get_info() + self.assertEquals(info['account'], 'test1') + self.assertEquals(info['container'], 'test2') + self.assertEquals(info['hash'], '00000000000000000000000000000000') + self.assertEqual(info['put_timestamp'], Timestamp(1).internal) + self.assertEqual(info['delete_timestamp'], '0') + + info = broker.get_info() + self.assertEquals(info['object_count'], 0) + self.assertEquals(info['bytes_used'], 0) + + policy_stats = broker.get_policy_stats() + + # Act as policy-0 + self.assertTrue(0 in policy_stats) + self.assertEquals(policy_stats[0]['bytes_used'], 0) + self.assertEquals(policy_stats[0]['object_count'], 0) + + broker.put_object('o1', Timestamp(time()).internal, 123, 'text/plain', + '5af83e3196bf99f440f31f2e1a6c9afe') + + info = broker.get_info() + self.assertEquals(info['object_count'], 1) + self.assertEquals(info['bytes_used'], 123) + + policy_stats = broker.get_policy_stats() + + self.assertTrue(0 in policy_stats) + self.assertEquals(policy_stats[0]['object_count'], 1) + self.assertEquals(policy_stats[0]['bytes_used'], 123) + def test_get_info(self): # Test ContainerBroker.get_info broker = ContainerBroker(':memory:', account='test1', diff --git a/test/unit/container/test_replicator.py b/test/unit/container/test_replicator.py index c638dc5e35..399bb8bb19 100644 --- a/test/unit/container/test_replicator.py +++ b/test/unit/container/test_replicator.py @@ -168,7 +168,8 @@ class TestReplicatorSync(test_db_replicator.TestReplicatorSync): # replicate part, local_node = self._get_broker_part_node(broker) - node = random.choice([n for n in self._ring.devs if n != local_node]) + node = random.choice([n for n in self._ring.devs + if n['id'] != local_node['id']]) info = broker.get_replication_info() success = daemon._repl_to_node(node, broker, part, info) self.assertFalse(success) diff --git a/test/unit/container/test_sync.py b/test/unit/container/test_sync.py index 645c7935c3..8c6d895323 100644 --- a/test/unit/container/test_sync.py +++ b/test/unit/container/test_sync.py @@ -14,17 +14,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -import re +import os import unittest from contextlib import nested +from textwrap import dedent import mock -from test.unit import FakeLogger +from test.unit import debug_logger from swift.container import sync from swift.common import utils +from swift.common.wsgi import ConfigString from swift.common.exceptions import ClientException from swift.common.storage_policy import StoragePolicy -from test.unit import patch_policies +import test +from test.unit import patch_policies, with_tempdir utils.HASH_PATH_SUFFIX = 'endcap' utils.HASH_PATH_PREFIX = 'endcap' @@ -71,6 +74,9 @@ class FakeContainerBroker(object): @patch_policies([StoragePolicy(0, 'zero', True, object_ring=FakeRing())]) class TestContainerSync(unittest.TestCase): + def setUp(self): + self.logger = debug_logger('test-container-sync') + def test_FileLikeIter(self): # Retained test to show new FileLikeIter acts just like the removed # _Iter2FileLikeObject did. @@ -96,11 +102,56 @@ class TestContainerSync(unittest.TestCase): self.assertEquals(flo.read(), '') self.assertEquals(flo.read(2), '') - def test_init(self): + def assertLogMessage(self, msg_level, expected, skip=0): + for line in self.logger.get_lines_for_level(msg_level)[skip:]: + msg = 'expected %r not in %r' % (expected, line) + self.assertTrue(expected in line, msg) + + @with_tempdir + def test_init(self, tempdir): + ic_conf_path = os.path.join(tempdir, 'internal-client.conf') cring = FakeRing() - cs = sync.ContainerSync({}, container_ring=cring) + + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync({}, container_ring=cring) self.assertTrue(cs.container_ring is cring) + # specified but not exists will not start + conf = {'internal_client_conf_path': ic_conf_path} + self.assertRaises(SystemExit, sync.ContainerSync, conf, + container_ring=cring, logger=self.logger) + + # not specified will use default conf + with mock.patch('swift.container.sync.InternalClient') as mock_ic: + cs = sync.ContainerSync({}, container_ring=cring, + logger=self.logger) + self.assertTrue(cs.container_ring is cring) + self.assertTrue(mock_ic.called) + conf_path, name, retry = mock_ic.call_args[0] + self.assertTrue(isinstance(conf_path, ConfigString)) + self.assertEquals(conf_path.contents.getvalue(), + dedent(sync.ic_conf_body)) + self.assertLogMessage('warning', 'internal_client_conf_path') + self.assertLogMessage('warning', 'internal-client.conf-sample') + + # correct + contents = dedent(sync.ic_conf_body) + with open(ic_conf_path, 'w') as f: + f.write(contents) + with mock.patch('swift.container.sync.InternalClient') as mock_ic: + cs = sync.ContainerSync(conf, container_ring=cring) + self.assertTrue(cs.container_ring is cring) + self.assertTrue(mock_ic.called) + conf_path, name, retry = mock_ic.call_args[0] + self.assertEquals(conf_path, ic_conf_path) + + sample_conf_filename = os.path.join( + os.path.dirname(test.__file__), + '../etc/internal-client.conf-sample') + with open(sample_conf_filename) as sample_conf_file: + sample_conf = sample_conf_file.read() + self.assertEqual(contents, sample_conf) + def test_run_forever(self): # This runs runs_forever with fakes to succeed for two loops, the first # causing a report but no interval sleep, the second no report but an @@ -142,7 +193,9 @@ class TestContainerSync(unittest.TestCase): 'storage_policy_index': 0}) sync.time = fake_time sync.sleep = fake_sleep - cs = sync.ContainerSync({}, container_ring=FakeRing()) + + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync({}, container_ring=FakeRing()) sync.audit_location_generator = fake_audit_location_generator cs.run_forever(1, 2, a=3, b=4, verbose=True) except Exception as err: @@ -197,7 +250,9 @@ class TestContainerSync(unittest.TestCase): p, info={'account': 'a', 'container': 'c', 'storage_policy_index': 0}) sync.time = fake_time - cs = sync.ContainerSync({}, container_ring=FakeRing()) + + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync({}, container_ring=FakeRing()) sync.audit_location_generator = fake_audit_location_generator cs.run_once(1, 2, a=3, b=4, verbose=True) self.assertEquals(time_calls, [6]) @@ -218,12 +273,14 @@ class TestContainerSync(unittest.TestCase): def test_container_sync_not_db(self): cring = FakeRing() - cs = sync.ContainerSync({}, container_ring=cring) + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync({}, container_ring=cring) self.assertEquals(cs.container_failures, 0) def test_container_sync_missing_db(self): cring = FakeRing() - cs = sync.ContainerSync({}, container_ring=cring) + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync({}, container_ring=cring) cs.container_sync('isa.db') self.assertEquals(cs.container_failures, 1) @@ -231,7 +288,8 @@ class TestContainerSync(unittest.TestCase): # Db could be there due to handoff replication so test that we ignore # those. cring = FakeRing() - cs = sync.ContainerSync({}, container_ring=cring) + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync({}, container_ring=cring) orig_ContainerBroker = sync.ContainerBroker try: sync.ContainerBroker = lambda p: FakeContainerBroker( @@ -263,7 +321,8 @@ class TestContainerSync(unittest.TestCase): def test_container_sync_deleted(self): cring = FakeRing() - cs = sync.ContainerSync({}, container_ring=cring) + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync({}, container_ring=cring) orig_ContainerBroker = sync.ContainerBroker try: sync.ContainerBroker = lambda p: FakeContainerBroker( @@ -288,7 +347,8 @@ class TestContainerSync(unittest.TestCase): def test_container_sync_no_to_or_key(self): cring = FakeRing() - cs = sync.ContainerSync({}, container_ring=cring) + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync({}, container_ring=cring) orig_ContainerBroker = sync.ContainerBroker try: sync.ContainerBroker = lambda p: FakeContainerBroker( @@ -368,7 +428,8 @@ class TestContainerSync(unittest.TestCase): def test_container_stop_at(self): cring = FakeRing() - cs = sync.ContainerSync({}, container_ring=cring) + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync({}, container_ring=cring) orig_ContainerBroker = sync.ContainerBroker orig_time = sync.time try: @@ -411,7 +472,8 @@ class TestContainerSync(unittest.TestCase): def test_container_first_loop(self): cring = FakeRing() - cs = sync.ContainerSync({}, container_ring=cring) + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync({}, container_ring=cring) def fake_hash_path(account, container, obj, raw_digest=False): # Ensures that no rows match for full syncing, ordinal is 0 and @@ -543,7 +605,9 @@ class TestContainerSync(unittest.TestCase): def test_container_second_loop(self): cring = FakeRing() - cs = sync.ContainerSync({}, container_ring=cring) + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync({}, container_ring=cring, + logger=self.logger) orig_ContainerBroker = sync.ContainerBroker orig_hash_path = sync.hash_path orig_delete_object = sync.delete_object @@ -649,10 +713,9 @@ class TestContainerSync(unittest.TestCase): hex = 'abcdef' sync.uuid = FakeUUID - fake_logger = FakeLogger() def fake_delete_object(path, name=None, headers=None, proxy=None, - logger=None): + logger=None, timeout=None): self.assertEquals(path, 'http://sync/to/path') self.assertEquals(name, 'object') if realm: @@ -665,11 +728,14 @@ class TestContainerSync(unittest.TestCase): headers, {'x-container-sync-key': 'key', 'x-timestamp': '1.2'}) self.assertEquals(proxy, 'http://proxy') - self.assertEqual(logger, fake_logger) + self.assertEqual(timeout, 5.0) + self.assertEqual(logger, self.logger) sync.delete_object = fake_delete_object - cs = sync.ContainerSync({}, container_ring=FakeRing()) - cs.logger = fake_logger + + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync({}, container_ring=FakeRing(), + logger=self.logger) cs.http_proxies = ['http://proxy'] # Success self.assertTrue(cs.container_sync_row( @@ -748,7 +814,6 @@ class TestContainerSync(unittest.TestCase): orig_uuid = sync.uuid orig_shuffle = sync.shuffle orig_put_object = sync.put_object - orig_direct_get_object = sync.direct_get_object try: class FakeUUID(object): class uuid4(object): @@ -756,10 +821,10 @@ class TestContainerSync(unittest.TestCase): sync.uuid = FakeUUID sync.shuffle = lambda x: x - fake_logger = FakeLogger() def fake_put_object(sync_to, name=None, headers=None, - contents=None, proxy=None, logger=None): + contents=None, proxy=None, logger=None, + timeout=None): self.assertEquals(sync_to, 'http://sync/to/path') self.assertEquals(name, 'object') if realm: @@ -779,23 +844,25 @@ class TestContainerSync(unittest.TestCase): 'content-type': 'text/plain'}) self.assertEquals(contents.read(), 'contents') self.assertEquals(proxy, 'http://proxy') - self.assertEqual(logger, fake_logger) + self.assertEqual(timeout, 5.0) + self.assertEqual(logger, self.logger) sync.put_object = fake_put_object - cs = sync.ContainerSync({}, container_ring=FakeRing()) - cs.logger = fake_logger + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync({}, container_ring=FakeRing(), + logger=self.logger) cs.http_proxies = ['http://proxy'] - def fake_direct_get_object(node, part, account, container, obj, - headers, resp_chunk_size=1): - self.assertEquals(headers['X-Backend-Storage-Policy-Index'], - '0') - return ({'other-header': 'other header value', - 'etag': '"etagvalue"', 'x-timestamp': '1.2', - 'content-type': 'text/plain; swift_bytes=123'}, + def fake_get_object(acct, con, obj, headers, acceptable_statuses): + self.assertEqual(headers['X-Backend-Storage-Policy-Index'], + '0') + return (200, {'other-header': 'other header value', + 'etag': '"etagvalue"', 'x-timestamp': '1.2', + 'content-type': 'text/plain; swift_bytes=123'}, iter('contents')) - sync.direct_get_object = fake_direct_get_object + + cs.swift.get_object = fake_get_object # Success as everything says it worked self.assertTrue(cs.container_sync_row( {'deleted': False, @@ -806,19 +873,19 @@ class TestContainerSync(unittest.TestCase): realm, realm_key)) self.assertEquals(cs.container_puts, 1) - def fake_direct_get_object(node, part, account, container, obj, - headers, resp_chunk_size=1): + def fake_get_object(acct, con, obj, headers, acceptable_statuses): + self.assertEquals(headers['X-Newest'], True) self.assertEquals(headers['X-Backend-Storage-Policy-Index'], '0') - return ({'date': 'date value', - 'last-modified': 'last modified value', - 'x-timestamp': '1.2', - 'other-header': 'other header value', - 'etag': '"etagvalue"', - 'content-type': 'text/plain; swift_bytes=123'}, + return (200, {'date': 'date value', + 'last-modified': 'last modified value', + 'x-timestamp': '1.2', + 'other-header': 'other header value', + 'etag': '"etagvalue"', + 'content-type': 'text/plain; swift_bytes=123'}, iter('contents')) - sync.direct_get_object = fake_direct_get_object + cs.swift.get_object = fake_get_object # Success as everything says it worked, also checks 'date' and # 'last-modified' headers are removed and that 'etag' header is # stripped of double quotes. @@ -833,14 +900,14 @@ class TestContainerSync(unittest.TestCase): exc = [] - def fake_direct_get_object(node, part, account, container, obj, - headers, resp_chunk_size=1): + def fake_get_object(acct, con, obj, headers, acceptable_statuses): + self.assertEquals(headers['X-Newest'], True) self.assertEquals(headers['X-Backend-Storage-Policy-Index'], '0') exc.append(Exception('test exception')) raise exc[-1] - sync.direct_get_object = fake_direct_get_object + cs.swift.get_object = fake_get_object # Fail due to completely unexpected exception self.assertFalse(cs.container_sync_row( {'deleted': False, @@ -850,22 +917,20 @@ class TestContainerSync(unittest.TestCase): {'account': 'a', 'container': 'c', 'storage_policy_index': 0}, realm, realm_key)) self.assertEquals(cs.container_puts, 2) - self.assertEquals(len(exc), 3) + self.assertEquals(len(exc), 1) self.assertEquals(str(exc[-1]), 'test exception') exc = [] - def fake_direct_get_object(node, part, account, container, obj, - headers, resp_chunk_size=1): + def fake_get_object(acct, con, obj, headers, acceptable_statuses): + self.assertEquals(headers['X-Newest'], True) self.assertEquals(headers['X-Backend-Storage-Policy-Index'], '0') - if len(exc) == 0: - exc.append(Exception('test other exception')) - else: - exc.append(ClientException('test client exception')) + + exc.append(ClientException('test client exception')) raise exc[-1] - sync.direct_get_object = fake_direct_get_object + cs.swift.get_object = fake_get_object # Fail due to all direct_get_object calls failing self.assertFalse(cs.container_sync_row( {'deleted': False, @@ -875,25 +940,22 @@ class TestContainerSync(unittest.TestCase): {'account': 'a', 'container': 'c', 'storage_policy_index': 0}, realm, realm_key)) self.assertEquals(cs.container_puts, 2) - self.assertEquals(len(exc), 3) - self.assertEquals(str(exc[-3]), 'test other exception') - self.assertEquals(str(exc[-2]), 'test client exception') + self.assertEquals(len(exc), 1) self.assertEquals(str(exc[-1]), 'test client exception') - def fake_direct_get_object(node, part, account, container, obj, - headers, resp_chunk_size=1): + def fake_get_object(acct, con, obj, headers, acceptable_statuses): + self.assertEquals(headers['X-Newest'], True) self.assertEquals(headers['X-Backend-Storage-Policy-Index'], '0') - return ({'other-header': 'other header value', - 'x-timestamp': '1.2', 'etag': '"etagvalue"'}, + return (200, {'other-header': 'other header value', + 'x-timestamp': '1.2', 'etag': '"etagvalue"'}, iter('contents')) def fake_put_object(*args, **kwargs): raise ClientException('test client exception', http_status=401) - sync.direct_get_object = fake_direct_get_object + cs.swift.get_object = fake_get_object sync.put_object = fake_put_object - cs.logger = FakeLogger() # Fail due to 401 self.assertFalse(cs.container_sync_row( {'deleted': False, @@ -903,15 +965,13 @@ class TestContainerSync(unittest.TestCase): {'account': 'a', 'container': 'c', 'storage_policy_index': 0}, realm, realm_key)) self.assertEquals(cs.container_puts, 2) - self.assert_(re.match('Unauth ', - cs.logger.log_dict['info'][0][0][0])) + self.assertLogMessage('info', 'Unauth') def fake_put_object(*args, **kwargs): raise ClientException('test client exception', http_status=404) sync.put_object = fake_put_object # Fail due to 404 - cs.logger = FakeLogger() self.assertFalse(cs.container_sync_row( {'deleted': False, 'name': 'object', @@ -920,8 +980,7 @@ class TestContainerSync(unittest.TestCase): {'account': 'a', 'container': 'c', 'storage_policy_index': 0}, realm, realm_key)) self.assertEquals(cs.container_puts, 2) - self.assert_(re.match('Not found ', - cs.logger.log_dict['info'][0][0][0])) + self.assertLogMessage('info', 'Not found', 1) def fake_put_object(*args, **kwargs): raise ClientException('test client exception', http_status=503) @@ -936,29 +995,32 @@ class TestContainerSync(unittest.TestCase): {'account': 'a', 'container': 'c', 'storage_policy_index': 0}, realm, realm_key)) self.assertEquals(cs.container_puts, 2) - self.assertTrue( - cs.logger.log_dict['exception'][0][0][0].startswith( - 'ERROR Syncing ')) + self.assertLogMessage('error', 'ERROR Syncing') finally: sync.uuid = orig_uuid sync.shuffle = orig_shuffle sync.put_object = orig_put_object - sync.direct_get_object = orig_direct_get_object def test_select_http_proxy_None(self): - cs = sync.ContainerSync( - {'sync_proxy': ''}, container_ring=FakeRing()) + + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync( + {'sync_proxy': ''}, container_ring=FakeRing()) self.assertEqual(cs.select_http_proxy(), None) def test_select_http_proxy_one(self): - cs = sync.ContainerSync( - {'sync_proxy': 'http://one'}, container_ring=FakeRing()) + + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync( + {'sync_proxy': 'http://one'}, container_ring=FakeRing()) self.assertEqual(cs.select_http_proxy(), 'http://one') def test_select_http_proxy_multiple(self): - cs = sync.ContainerSync( - {'sync_proxy': 'http://one,http://two,http://three'}, - container_ring=FakeRing()) + + with mock.patch('swift.container.sync.InternalClient'): + cs = sync.ContainerSync( + {'sync_proxy': 'http://one,http://two,http://three'}, + container_ring=FakeRing()) self.assertEqual( set(cs.http_proxies), set(['http://one', 'http://two', 'http://three'])) diff --git a/test/unit/obj/test_auditor.py b/test/unit/obj/test_auditor.py index e8f8a2b16a..3cfcb47573 100644 --- a/test/unit/obj/test_auditor.py +++ b/test/unit/obj/test_auditor.py @@ -28,7 +28,7 @@ from swift.obj.diskfile import DiskFile, write_metadata, invalidate_hash, \ get_data_dir, DiskFileManager, AuditLocation from swift.common.utils import hash_path, mkdirs, normalize_timestamp, \ storage_directory -from swift.common.storage_policy import StoragePolicy +from swift.common.storage_policy import StoragePolicy, POLICIES _mocked_policies = [StoragePolicy(0, 'zero', False), @@ -48,12 +48,16 @@ class TestAuditor(unittest.TestCase): os.mkdir(os.path.join(self.devices, 'sdb')) # policy 0 - self.objects = os.path.join(self.devices, 'sda', get_data_dir(0)) - self.objects_2 = os.path.join(self.devices, 'sdb', get_data_dir(0)) + self.objects = os.path.join(self.devices, 'sda', + get_data_dir(POLICIES[0])) + self.objects_2 = os.path.join(self.devices, 'sdb', + get_data_dir(POLICIES[0])) os.mkdir(self.objects) # policy 1 - self.objects_p1 = os.path.join(self.devices, 'sda', get_data_dir(1)) - self.objects_2_p1 = os.path.join(self.devices, 'sdb', get_data_dir(1)) + self.objects_p1 = os.path.join(self.devices, 'sda', + get_data_dir(POLICIES[1])) + self.objects_2_p1 = os.path.join(self.devices, 'sdb', + get_data_dir(POLICIES[1])) os.mkdir(self.objects_p1) self.parts = self.parts_p1 = {} @@ -70,9 +74,10 @@ class TestAuditor(unittest.TestCase): self.df_mgr = DiskFileManager(self.conf, self.logger) # diskfiles for policy 0, 1 - self.disk_file = self.df_mgr.get_diskfile('sda', '0', 'a', 'c', 'o', 0) + self.disk_file = self.df_mgr.get_diskfile('sda', '0', 'a', 'c', 'o', + policy=POLICIES[0]) self.disk_file_p1 = self.df_mgr.get_diskfile('sda', '0', 'a', 'c', - 'o', 1) + 'o', policy=POLICIES[1]) def tearDown(self): rmtree(os.path.dirname(self.testdir), ignore_errors=1) @@ -125,13 +130,15 @@ class TestAuditor(unittest.TestCase): pre_quarantines = auditor_worker.quarantines auditor_worker.object_audit( - AuditLocation(disk_file._datadir, 'sda', '0')) + AuditLocation(disk_file._datadir, 'sda', '0', + policy=POLICIES.legacy)) self.assertEquals(auditor_worker.quarantines, pre_quarantines) os.write(writer._fd, 'extra_data') auditor_worker.object_audit( - AuditLocation(disk_file._datadir, 'sda', '0')) + AuditLocation(disk_file._datadir, 'sda', '0', + policy=POLICIES.legacy)) self.assertEquals(auditor_worker.quarantines, pre_quarantines + 1) run_tests(self.disk_file) @@ -156,10 +163,12 @@ class TestAuditor(unittest.TestCase): pre_quarantines = auditor_worker.quarantines # remake so it will have metadata - self.disk_file = self.df_mgr.get_diskfile('sda', '0', 'a', 'c', 'o') + self.disk_file = self.df_mgr.get_diskfile('sda', '0', 'a', 'c', 'o', + policy=POLICIES.legacy) auditor_worker.object_audit( - AuditLocation(self.disk_file._datadir, 'sda', '0')) + AuditLocation(self.disk_file._datadir, 'sda', '0', + policy=POLICIES.legacy)) self.assertEquals(auditor_worker.quarantines, pre_quarantines) etag = md5() etag.update('1' + '0' * 1023) @@ -171,7 +180,8 @@ class TestAuditor(unittest.TestCase): writer.put(metadata) auditor_worker.object_audit( - AuditLocation(self.disk_file._datadir, 'sda', '0')) + AuditLocation(self.disk_file._datadir, 'sda', '0', + policy=POLICIES.legacy)) self.assertEquals(auditor_worker.quarantines, pre_quarantines + 1) def test_object_audit_no_meta(self): @@ -186,7 +196,8 @@ class TestAuditor(unittest.TestCase): self.rcache, self.devices) pre_quarantines = auditor_worker.quarantines auditor_worker.object_audit( - AuditLocation(self.disk_file._datadir, 'sda', '0')) + AuditLocation(self.disk_file._datadir, 'sda', '0', + policy=POLICIES.legacy)) self.assertEquals(auditor_worker.quarantines, pre_quarantines + 1) def test_object_audit_will_not_swallow_errors_in_tests(self): @@ -203,7 +214,8 @@ class TestAuditor(unittest.TestCase): with mock.patch.object(DiskFileManager, 'get_diskfile_from_audit_location', blowup): self.assertRaises(NameError, auditor_worker.object_audit, - AuditLocation(os.path.dirname(path), 'sda', '0')) + AuditLocation(os.path.dirname(path), 'sda', '0', + policy=POLICIES.legacy)) def test_failsafe_object_audit_will_swallow_errors_in_tests(self): timestamp = str(normalize_timestamp(time.time())) @@ -216,9 +228,11 @@ class TestAuditor(unittest.TestCase): def blowup(*args): raise NameError('tpyo') - with mock.patch('swift.obj.diskfile.DiskFile', blowup): + with mock.patch('swift.obj.diskfile.DiskFileManager.diskfile_cls', + blowup): auditor_worker.failsafe_object_audit( - AuditLocation(os.path.dirname(path), 'sda', '0')) + AuditLocation(os.path.dirname(path), 'sda', '0', + policy=POLICIES.legacy)) self.assertEquals(auditor_worker.errors, 1) def test_generic_exception_handling(self): @@ -240,7 +254,8 @@ class TestAuditor(unittest.TestCase): 'Content-Length': str(os.fstat(writer._fd).st_size), } writer.put(metadata) - with mock.patch('swift.obj.diskfile.DiskFile', lambda *_: 1 / 0): + with mock.patch('swift.obj.diskfile.DiskFileManager.diskfile_cls', + lambda *_: 1 / 0): auditor_worker.audit_all_objects() self.assertEquals(auditor_worker.errors, pre_errors + 1) @@ -368,7 +383,8 @@ class TestAuditor(unittest.TestCase): } writer.put(metadata) auditor_worker.audit_all_objects() - self.disk_file = self.df_mgr.get_diskfile('sda', '0', 'a', 'c', 'ob') + self.disk_file = self.df_mgr.get_diskfile('sda', '0', 'a', 'c', 'ob', + policy=POLICIES.legacy) data = '1' * 10 etag = md5() with self.disk_file.create() as writer: @@ -424,7 +440,7 @@ class TestAuditor(unittest.TestCase): name_hash = hash_path('a', 'c', 'o') dir_path = os.path.join( self.devices, 'sda', - storage_directory(get_data_dir(0), '0', name_hash)) + storage_directory(get_data_dir(POLICIES[0]), '0', name_hash)) ts_file_path = os.path.join(dir_path, '99999.ts') if not os.path.exists(dir_path): mkdirs(dir_path) @@ -474,9 +490,8 @@ class TestAuditor(unittest.TestCase): DiskFile._quarantine(self, data_file, msg) self.setup_bad_zero_byte() - was_df = auditor.diskfile.DiskFile - try: - auditor.diskfile.DiskFile = FakeFile + with mock.patch('swift.obj.diskfile.DiskFileManager.diskfile_cls', + FakeFile): kwargs = {'mode': 'once'} kwargs['zero_byte_fps'] = 50 self.auditor.run_audit(**kwargs) @@ -484,8 +499,6 @@ class TestAuditor(unittest.TestCase): 'sda', 'quarantined', 'objects') self.assertTrue(os.path.isdir(quarantine_path)) self.assertTrue(rat[0]) - finally: - auditor.diskfile.DiskFile = was_df @mock.patch.object(auditor.ObjectAuditor, 'run_audit') @mock.patch('os.fork', return_value=0) diff --git a/test/unit/obj/test_diskfile.py b/test/unit/obj/test_diskfile.py index 22fd17aa1b..2ccf3b1364 100644 --- a/test/unit/obj/test_diskfile.py +++ b/test/unit/obj/test_diskfile.py @@ -19,6 +19,7 @@ import cPickle as pickle import os import errno +import itertools import mock import unittest import email @@ -26,6 +27,8 @@ import tempfile import uuid import xattr import re +from collections import defaultdict +from random import shuffle, randint from shutil import rmtree from time import time from tempfile import mkdtemp @@ -35,7 +38,7 @@ from gzip import GzipFile from eventlet import hubs, timeout, tpool from test.unit import (FakeLogger, mock as unit_mock, temptree, - patch_policies, debug_logger) + patch_policies, debug_logger, EMPTY_ETAG) from nose import SkipTest from swift.obj import diskfile @@ -45,32 +48,61 @@ from swift.common import ring from swift.common.splice import splice from swift.common.exceptions import DiskFileNotExist, DiskFileQuarantined, \ DiskFileDeviceUnavailable, DiskFileDeleted, DiskFileNotOpen, \ - DiskFileError, ReplicationLockTimeout, PathNotDir, DiskFileCollision, \ + DiskFileError, ReplicationLockTimeout, DiskFileCollision, \ DiskFileExpired, SwiftException, DiskFileNoSpace, DiskFileXattrNotSupported -from swift.common.storage_policy import POLICIES, get_policy_string -from functools import partial +from swift.common.storage_policy import ( + POLICIES, get_policy_string, StoragePolicy, ECStoragePolicy, + BaseStoragePolicy, REPL_POLICY, EC_POLICY) -get_data_dir = partial(get_policy_string, diskfile.DATADIR_BASE) -get_tmp_dir = partial(get_policy_string, diskfile.TMP_BASE) +test_policies = [ + StoragePolicy(0, name='zero', is_default=True), + ECStoragePolicy(1, name='one', is_default=False, + ec_type='jerasure_rs_vand', + ec_ndata=10, ec_nparity=4), +] -def _create_test_ring(path): - testgz = os.path.join(path, 'object.ring.gz') +def find_paths_with_matching_suffixes(needed_matches=2, needed_suffixes=3): + paths = defaultdict(list) + while True: + path = ('a', 'c', uuid.uuid4().hex) + hash_ = hash_path(*path) + suffix = hash_[-3:] + paths[suffix].append(path) + if len(paths) < needed_suffixes: + # in the extreamly unlikely situation where you land the matches + # you need before you get the total suffixes you need - it's + # simpler to just ignore this suffix for now + continue + if len(paths[suffix]) >= needed_matches: + break + return paths, suffix + + +def _create_test_ring(path, policy): + ring_name = get_policy_string('object', policy) + testgz = os.path.join(path, ring_name + '.ring.gz') intended_replica2part2dev_id = [ [0, 1, 2, 3, 4, 5, 6], [1, 2, 3, 0, 5, 6, 4], [2, 3, 0, 1, 6, 4, 5]] intended_devs = [ - {'id': 0, 'device': 'sda', 'zone': 0, 'ip': '127.0.0.0', 'port': 6000}, - {'id': 1, 'device': 'sda', 'zone': 1, 'ip': '127.0.0.1', 'port': 6000}, - {'id': 2, 'device': 'sda', 'zone': 2, 'ip': '127.0.0.2', 'port': 6000}, - {'id': 3, 'device': 'sda', 'zone': 4, 'ip': '127.0.0.3', 'port': 6000}, - {'id': 4, 'device': 'sda', 'zone': 5, 'ip': '127.0.0.4', 'port': 6000}, - {'id': 5, 'device': 'sda', 'zone': 6, + {'id': 0, 'device': 'sda1', 'zone': 0, 'ip': '127.0.0.0', + 'port': 6000}, + {'id': 1, 'device': 'sda1', 'zone': 1, 'ip': '127.0.0.1', + 'port': 6000}, + {'id': 2, 'device': 'sda1', 'zone': 2, 'ip': '127.0.0.2', + 'port': 6000}, + {'id': 3, 'device': 'sda1', 'zone': 4, 'ip': '127.0.0.3', + 'port': 6000}, + {'id': 4, 'device': 'sda1', 'zone': 5, 'ip': '127.0.0.4', + 'port': 6000}, + {'id': 5, 'device': 'sda1', 'zone': 6, 'ip': 'fe80::202:b3ff:fe1e:8329', 'port': 6000}, - {'id': 6, 'device': 'sda', 'zone': 7, - 'ip': '2001:0db8:85a3:0000:0000:8a2e:0370:7334', 'port': 6000}] + {'id': 6, 'device': 'sda1', 'zone': 7, + 'ip': '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + 'port': 6000}] intended_part_shift = 30 intended_reload_time = 15 with closing(GzipFile(testgz, 'wb')) as f: @@ -78,7 +110,7 @@ def _create_test_ring(path): ring.RingData(intended_replica2part2dev_id, intended_devs, intended_part_shift), f) - return ring.Ring(path, ring_name='object', + return ring.Ring(path, ring_name=ring_name, reload_time=intended_reload_time) @@ -88,13 +120,13 @@ class TestDiskFileModuleMethods(unittest.TestCase): def setUp(self): utils.HASH_PATH_SUFFIX = 'endcap' utils.HASH_PATH_PREFIX = '' - # Setup a test ring (stolen from common/test_ring.py) + # Setup a test ring per policy (stolen from common/test_ring.py) self.testdir = tempfile.mkdtemp() self.devices = os.path.join(self.testdir, 'node') rmtree(self.testdir, ignore_errors=1) os.mkdir(self.testdir) os.mkdir(self.devices) - self.existing_device = 'sda' + self.existing_device = 'sda1' os.mkdir(os.path.join(self.devices, self.existing_device)) self.objects = os.path.join(self.devices, self.existing_device, 'objects') @@ -103,7 +135,7 @@ class TestDiskFileModuleMethods(unittest.TestCase): for part in ['0', '1', '2', '3']: self.parts[part] = os.path.join(self.objects, part) os.mkdir(os.path.join(self.objects, part)) - self.ring = _create_test_ring(self.testdir) + self.ring = _create_test_ring(self.testdir, POLICIES.legacy) self.conf = dict( swift_dir=self.testdir, devices=self.devices, mount_check='false', timeout='300', stats_interval='1') @@ -112,59 +144,58 @@ class TestDiskFileModuleMethods(unittest.TestCase): def tearDown(self): rmtree(self.testdir, ignore_errors=1) - def _create_diskfile(self, policy_idx=0): + def _create_diskfile(self, policy): return self.df_mgr.get_diskfile(self.existing_device, '0', 'a', 'c', 'o', - policy_idx) + policy=policy) - def test_extract_policy_index(self): + def test_extract_policy(self): # good path names pn = 'objects/0/606/1984527ed7ef6247c78606/1401379842.14643.data' - self.assertEqual(diskfile.extract_policy_index(pn), 0) + self.assertEqual(diskfile.extract_policy(pn), POLICIES[0]) pn = 'objects-1/0/606/198452b6ef6247c78606/1401379842.14643.data' - self.assertEqual(diskfile.extract_policy_index(pn), 1) - good_path = '/srv/node/sda1/objects-1/1/abc/def/1234.data' - self.assertEquals(1, diskfile.extract_policy_index(good_path)) - good_path = '/srv/node/sda1/objects/1/abc/def/1234.data' - self.assertEquals(0, diskfile.extract_policy_index(good_path)) + self.assertEqual(diskfile.extract_policy(pn), POLICIES[1]) - # short paths still ok - path = '/srv/node/sda1/objects/1/1234.data' - self.assertEqual(diskfile.extract_policy_index(path), 0) - path = '/srv/node/sda1/objects-1/1/1234.data' - self.assertEqual(diskfile.extract_policy_index(path), 1) - - # leading slash, just in case + # leading slash pn = '/objects/0/606/1984527ed7ef6247c78606/1401379842.14643.data' - self.assertEqual(diskfile.extract_policy_index(pn), 0) + self.assertEqual(diskfile.extract_policy(pn), POLICIES[0]) pn = '/objects-1/0/606/198452b6ef6247c78606/1401379842.14643.data' - self.assertEqual(diskfile.extract_policy_index(pn), 1) + self.assertEqual(diskfile.extract_policy(pn), POLICIES[1]) - # bad policy index + # full paths + good_path = '/srv/node/sda1/objects-1/1/abc/def/1234.data' + self.assertEqual(diskfile.extract_policy(good_path), POLICIES[1]) + good_path = '/srv/node/sda1/objects/1/abc/def/1234.data' + self.assertEqual(diskfile.extract_policy(good_path), POLICIES[0]) + + # short paths + path = '/srv/node/sda1/objects/1/1234.data' + self.assertEqual(diskfile.extract_policy(path), POLICIES[0]) + path = '/srv/node/sda1/objects-1/1/1234.data' + self.assertEqual(diskfile.extract_policy(path), POLICIES[1]) + + # well formatted but, unknown policy index pn = 'objects-2/0/606/198427efcff042c78606/1401379842.14643.data' - self.assertEqual(diskfile.extract_policy_index(pn), 0) + self.assertEqual(diskfile.extract_policy(pn), None) + + # malformed path + self.assertEqual(diskfile.extract_policy(''), None) bad_path = '/srv/node/sda1/objects-t/1/abc/def/1234.data' - self.assertRaises(ValueError, - diskfile.extract_policy_index, bad_path) - - # malformed path (no objects dir or nothing at all) + self.assertEqual(diskfile.extract_policy(bad_path), None) pn = 'XXXX/0/606/1984527ed42b6ef6247c78606/1401379842.14643.data' - self.assertEqual(diskfile.extract_policy_index(pn), 0) - self.assertEqual(diskfile.extract_policy_index(''), 0) - - # no datadir base in path + self.assertEqual(diskfile.extract_policy(pn), None) bad_path = '/srv/node/sda1/foo-1/1/abc/def/1234.data' - self.assertEqual(diskfile.extract_policy_index(bad_path), 0) + self.assertEqual(diskfile.extract_policy(bad_path), None) bad_path = '/srv/node/sda1/obj1/1/abc/def/1234.data' - self.assertEqual(diskfile.extract_policy_index(bad_path), 0) + self.assertEqual(diskfile.extract_policy(bad_path), None) def test_quarantine_renamer(self): for policy in POLICIES: # we use this for convenience, not really about a diskfile layout - df = self._create_diskfile(policy_idx=policy.idx) + df = self._create_diskfile(policy=policy) mkdirs(df._datadir) exp_dir = os.path.join(self.devices, 'quarantined', - get_data_dir(policy.idx), + diskfile.get_data_dir(policy), os.path.basename(df._datadir)) qbit = os.path.join(df._datadir, 'qbit') with open(qbit, 'w') as f: @@ -174,38 +205,28 @@ class TestDiskFileModuleMethods(unittest.TestCase): self.assertRaises(OSError, diskfile.quarantine_renamer, self.devices, qbit) - def test_hash_suffix_enoent(self): - self.assertRaises(PathNotDir, diskfile.hash_suffix, - os.path.join(self.testdir, "doesnotexist"), 101) - - def test_hash_suffix_oserror(self): - mocked_os_listdir = mock.Mock( - side_effect=OSError(errno.EACCES, os.strerror(errno.EACCES))) - with mock.patch("os.listdir", mocked_os_listdir): - self.assertRaises(OSError, diskfile.hash_suffix, - os.path.join(self.testdir, "doesnotexist"), 101) - def test_get_data_dir(self): - self.assertEquals(diskfile.get_data_dir(0), diskfile.DATADIR_BASE) - self.assertEquals(diskfile.get_data_dir(1), + self.assertEquals(diskfile.get_data_dir(POLICIES[0]), + diskfile.DATADIR_BASE) + self.assertEquals(diskfile.get_data_dir(POLICIES[1]), diskfile.DATADIR_BASE + "-1") self.assertRaises(ValueError, diskfile.get_data_dir, 'junk') self.assertRaises(ValueError, diskfile.get_data_dir, 99) def test_get_async_dir(self): - self.assertEquals(diskfile.get_async_dir(0), + self.assertEquals(diskfile.get_async_dir(POLICIES[0]), diskfile.ASYNCDIR_BASE) - self.assertEquals(diskfile.get_async_dir(1), + self.assertEquals(diskfile.get_async_dir(POLICIES[1]), diskfile.ASYNCDIR_BASE + "-1") self.assertRaises(ValueError, diskfile.get_async_dir, 'junk') self.assertRaises(ValueError, diskfile.get_async_dir, 99) def test_get_tmp_dir(self): - self.assertEquals(diskfile.get_tmp_dir(0), + self.assertEquals(diskfile.get_tmp_dir(POLICIES[0]), diskfile.TMP_BASE) - self.assertEquals(diskfile.get_tmp_dir(1), + self.assertEquals(diskfile.get_tmp_dir(POLICIES[1]), diskfile.TMP_BASE + "-1") self.assertRaises(ValueError, diskfile.get_tmp_dir, 'junk') @@ -221,7 +242,7 @@ class TestDiskFileModuleMethods(unittest.TestCase): self.devices, self.existing_device, tmp_part) self.assertFalse(os.path.isdir(tmp_path)) pickle_args = (self.existing_device, 'a', 'c', 'o', - 'data', 0.0, int(policy)) + 'data', 0.0, policy) # async updates don't create their tmpdir on their own self.assertRaises(OSError, self.df_mgr.pickle_async_update, *pickle_args) @@ -231,438 +252,6 @@ class TestDiskFileModuleMethods(unittest.TestCase): # check tempdir self.assertTrue(os.path.isdir(tmp_path)) - def test_hash_suffix_hash_dir_is_file_quarantine(self): - df = self._create_diskfile() - mkdirs(os.path.dirname(df._datadir)) - open(df._datadir, 'wb').close() - ohash = hash_path('a', 'c', 'o') - data_dir = ohash[-3:] - whole_path_from = os.path.join(self.objects, '0', data_dir) - orig_quarantine_renamer = diskfile.quarantine_renamer - called = [False] - - def wrapped(*args, **kwargs): - called[0] = True - return orig_quarantine_renamer(*args, **kwargs) - - try: - diskfile.quarantine_renamer = wrapped - diskfile.hash_suffix(whole_path_from, 101) - finally: - diskfile.quarantine_renamer = orig_quarantine_renamer - self.assertTrue(called[0]) - - def test_hash_suffix_one_file(self): - df = self._create_diskfile() - mkdirs(df._datadir) - f = open( - os.path.join(df._datadir, - Timestamp(time() - 100).internal + '.ts'), - 'wb') - f.write('1234567890') - f.close() - ohash = hash_path('a', 'c', 'o') - data_dir = ohash[-3:] - whole_path_from = os.path.join(self.objects, '0', data_dir) - diskfile.hash_suffix(whole_path_from, 101) - self.assertEquals(len(os.listdir(self.parts['0'])), 1) - - diskfile.hash_suffix(whole_path_from, 99) - self.assertEquals(len(os.listdir(self.parts['0'])), 0) - - def test_hash_suffix_oserror_on_hcl(self): - df = self._create_diskfile() - mkdirs(df._datadir) - f = open( - os.path.join(df._datadir, - Timestamp(time() - 100).internal + '.ts'), - 'wb') - f.write('1234567890') - f.close() - ohash = hash_path('a', 'c', 'o') - data_dir = ohash[-3:] - whole_path_from = os.path.join(self.objects, '0', data_dir) - state = [0] - orig_os_listdir = os.listdir - - def mock_os_listdir(*args, **kwargs): - # We want the first call to os.listdir() to succeed, which is the - # one directly from hash_suffix() itself, but then we want to fail - # the next call to os.listdir() which is from - # hash_cleanup_listdir() - if state[0] == 1: - raise OSError(errno.EACCES, os.strerror(errno.EACCES)) - state[0] = 1 - return orig_os_listdir(*args, **kwargs) - - with mock.patch('os.listdir', mock_os_listdir): - self.assertRaises(OSError, diskfile.hash_suffix, whole_path_from, - 101) - - def test_hash_suffix_multi_file_one(self): - df = self._create_diskfile() - mkdirs(df._datadir) - for tdiff in [1, 50, 100, 500]: - for suff in ['.meta', '.data', '.ts']: - f = open( - os.path.join( - df._datadir, - Timestamp(int(time()) - tdiff).internal + suff), - 'wb') - f.write('1234567890') - f.close() - - ohash = hash_path('a', 'c', 'o') - data_dir = ohash[-3:] - whole_path_from = os.path.join(self.objects, '0', data_dir) - hsh_path = os.listdir(whole_path_from)[0] - whole_hsh_path = os.path.join(whole_path_from, hsh_path) - - diskfile.hash_suffix(whole_path_from, 99) - # only the tombstone should be left - self.assertEquals(len(os.listdir(whole_hsh_path)), 1) - - def test_hash_suffix_multi_file_two(self): - df = self._create_diskfile() - mkdirs(df._datadir) - for tdiff in [1, 50, 100, 500]: - suffs = ['.meta', '.data'] - if tdiff > 50: - suffs.append('.ts') - for suff in suffs: - f = open( - os.path.join( - df._datadir, - Timestamp(int(time()) - tdiff).internal + suff), - 'wb') - f.write('1234567890') - f.close() - - ohash = hash_path('a', 'c', 'o') - data_dir = ohash[-3:] - whole_path_from = os.path.join(self.objects, '0', data_dir) - hsh_path = os.listdir(whole_path_from)[0] - whole_hsh_path = os.path.join(whole_path_from, hsh_path) - - diskfile.hash_suffix(whole_path_from, 99) - # only the meta and data should be left - self.assertEquals(len(os.listdir(whole_hsh_path)), 2) - - def test_hash_suffix_hsh_path_disappearance(self): - orig_rmdir = os.rmdir - - def _rmdir(path): - # Done twice to recreate what happens when it doesn't exist. - orig_rmdir(path) - orig_rmdir(path) - - df = self.df_mgr.get_diskfile('sda', '0', 'a', 'c', 'o') - mkdirs(df._datadir) - ohash = hash_path('a', 'c', 'o') - suffix = ohash[-3:] - suffix_path = os.path.join(self.objects, '0', suffix) - with mock.patch('os.rmdir', _rmdir): - # If hash_suffix doesn't handle the exception _rmdir will raise, - # this test will fail. - diskfile.hash_suffix(suffix_path, 123) - - def test_invalidate_hash(self): - - def assertFileData(file_path, data): - with open(file_path, 'r') as fp: - fdata = fp.read() - self.assertEquals(pickle.loads(fdata), pickle.loads(data)) - - df = self._create_diskfile() - mkdirs(df._datadir) - ohash = hash_path('a', 'c', 'o') - data_dir = ohash[-3:] - whole_path_from = os.path.join(self.objects, '0', data_dir) - hashes_file = os.path.join(self.objects, '0', - diskfile.HASH_FILE) - # test that non existent file except caught - self.assertEquals(diskfile.invalidate_hash(whole_path_from), - None) - # test that hashes get cleared - check_pickle_data = pickle.dumps({data_dir: None}, - diskfile.PICKLE_PROTOCOL) - for data_hash in [{data_dir: None}, {data_dir: 'abcdefg'}]: - with open(hashes_file, 'wb') as fp: - pickle.dump(data_hash, fp, diskfile.PICKLE_PROTOCOL) - diskfile.invalidate_hash(whole_path_from) - assertFileData(hashes_file, check_pickle_data) - - def test_invalidate_hash_bad_pickle(self): - df = self._create_diskfile() - mkdirs(df._datadir) - ohash = hash_path('a', 'c', 'o') - data_dir = ohash[-3:] - whole_path_from = os.path.join(self.objects, '0', data_dir) - hashes_file = os.path.join(self.objects, '0', - diskfile.HASH_FILE) - for data_hash in [{data_dir: None}, {data_dir: 'abcdefg'}]: - with open(hashes_file, 'wb') as fp: - fp.write('bad hash data') - try: - diskfile.invalidate_hash(whole_path_from) - except Exception as err: - self.fail("Unexpected exception raised: %s" % err) - else: - pass - - def test_get_hashes(self): - df = self._create_diskfile() - mkdirs(df._datadir) - with open( - os.path.join(df._datadir, - Timestamp(time()).internal + '.ts'), - 'wb') as f: - f.write('1234567890') - part = os.path.join(self.objects, '0') - hashed, hashes = diskfile.get_hashes(part) - self.assertEquals(hashed, 1) - self.assert_('a83' in hashes) - hashed, hashes = diskfile.get_hashes(part, do_listdir=True) - self.assertEquals(hashed, 0) - self.assert_('a83' in hashes) - hashed, hashes = diskfile.get_hashes(part, recalculate=['a83']) - self.assertEquals(hashed, 1) - self.assert_('a83' in hashes) - - def test_get_hashes_bad_dir(self): - df = self._create_diskfile() - mkdirs(df._datadir) - with open(os.path.join(self.objects, '0', 'bad'), 'wb') as f: - f.write('1234567890') - part = os.path.join(self.objects, '0') - hashed, hashes = diskfile.get_hashes(part) - self.assertEquals(hashed, 1) - self.assert_('a83' in hashes) - self.assert_('bad' not in hashes) - - def test_get_hashes_unmodified(self): - df = self._create_diskfile() - mkdirs(df._datadir) - with open( - os.path.join(df._datadir, - Timestamp(time()).internal + '.ts'), - 'wb') as f: - f.write('1234567890') - part = os.path.join(self.objects, '0') - hashed, hashes = diskfile.get_hashes(part) - i = [0] - - def _getmtime(filename): - i[0] += 1 - return 1 - with unit_mock({'swift.obj.diskfile.getmtime': _getmtime}): - hashed, hashes = diskfile.get_hashes( - part, recalculate=['a83']) - self.assertEquals(i[0], 2) - - def test_get_hashes_unmodified_norecalc(self): - df = self._create_diskfile() - mkdirs(df._datadir) - with open( - os.path.join(df._datadir, - Timestamp(time()).internal + '.ts'), - 'wb') as f: - f.write('1234567890') - part = os.path.join(self.objects, '0') - hashed, hashes_0 = diskfile.get_hashes(part) - self.assertEqual(hashed, 1) - self.assertTrue('a83' in hashes_0) - hashed, hashes_1 = diskfile.get_hashes(part) - self.assertEqual(hashed, 0) - self.assertTrue('a83' in hashes_0) - self.assertEqual(hashes_1, hashes_0) - - def test_get_hashes_hash_suffix_error(self): - df = self._create_diskfile() - mkdirs(df._datadir) - with open( - os.path.join(df._datadir, - Timestamp(time()).internal + '.ts'), - 'wb') as f: - f.write('1234567890') - part = os.path.join(self.objects, '0') - mocked_hash_suffix = mock.MagicMock( - side_effect=OSError(errno.EACCES, os.strerror(errno.EACCES))) - with mock.patch('swift.obj.diskfile.hash_suffix', mocked_hash_suffix): - hashed, hashes = diskfile.get_hashes(part) - self.assertEqual(hashed, 0) - self.assertEqual(hashes, {'a83': None}) - - def test_get_hashes_unmodified_and_zero_bytes(self): - df = self._create_diskfile() - mkdirs(df._datadir) - part = os.path.join(self.objects, '0') - open(os.path.join(part, diskfile.HASH_FILE), 'w') - # Now the hash file is zero bytes. - i = [0] - - def _getmtime(filename): - i[0] += 1 - return 1 - with unit_mock({'swift.obj.diskfile.getmtime': _getmtime}): - hashed, hashes = diskfile.get_hashes( - part, recalculate=[]) - # getmtime will actually not get called. Initially, the pickle.load - # will raise an exception first and later, force_rewrite will - # short-circuit the if clause to determine whether to write out a - # fresh hashes_file. - self.assertEquals(i[0], 0) - self.assertTrue('a83' in hashes) - - def test_get_hashes_modified(self): - df = self._create_diskfile() - mkdirs(df._datadir) - with open( - os.path.join(df._datadir, - Timestamp(time()).internal + '.ts'), - 'wb') as f: - f.write('1234567890') - part = os.path.join(self.objects, '0') - hashed, hashes = diskfile.get_hashes(part) - i = [0] - - def _getmtime(filename): - if i[0] < 3: - i[0] += 1 - return i[0] - with unit_mock({'swift.obj.diskfile.getmtime': _getmtime}): - hashed, hashes = diskfile.get_hashes( - part, recalculate=['a83']) - self.assertEquals(i[0], 3) - - def check_hash_cleanup_listdir(self, input_files, output_files): - orig_unlink = os.unlink - file_list = list(input_files) - - def mock_listdir(path): - return list(file_list) - - def mock_unlink(path): - # timestamp 1 is a special tag to pretend a file disappeared while - # working. - if '/0000000001.00000.' in path: - # Using actual os.unlink to reproduce exactly what OSError it - # raises. - orig_unlink(uuid.uuid4().hex) - file_list.remove(os.path.basename(path)) - - with unit_mock({'os.listdir': mock_listdir, 'os.unlink': mock_unlink}): - self.assertEquals(diskfile.hash_cleanup_listdir('/whatever'), - output_files) - - def test_hash_cleanup_listdir_purge_data_newer_ts(self): - # purge .data if there's a newer .ts - file1 = Timestamp(time()).internal + '.data' - file2 = Timestamp(time() + 1).internal + '.ts' - file_list = [file1, file2] - self.check_hash_cleanup_listdir(file_list, [file2]) - - def test_hash_cleanup_listdir_purge_ts_newer_data(self): - # purge .ts if there's a newer .data - file1 = Timestamp(time()).internal + '.ts' - file2 = Timestamp(time() + 1).internal + '.data' - file_list = [file1, file2] - self.check_hash_cleanup_listdir(file_list, [file2]) - - def test_hash_cleanup_listdir_keep_meta_data_purge_ts(self): - # keep .meta and .data if meta newer than data and purge .ts - file1 = Timestamp(time()).internal + '.ts' - file2 = Timestamp(time() + 1).internal + '.data' - file3 = Timestamp(time() + 2).internal + '.meta' - file_list = [file1, file2, file3] - self.check_hash_cleanup_listdir(file_list, [file3, file2]) - - def test_hash_cleanup_listdir_keep_one_ts(self): - # keep only latest of multiple .ts files - file1 = Timestamp(time()).internal + '.ts' - file2 = Timestamp(time() + 1).internal + '.ts' - file3 = Timestamp(time() + 2).internal + '.ts' - file_list = [file1, file2, file3] - self.check_hash_cleanup_listdir(file_list, [file3]) - - def test_hash_cleanup_listdir_keep_one_data(self): - # keep only latest of multiple .data files - file1 = Timestamp(time()).internal + '.data' - file2 = Timestamp(time() + 1).internal + '.data' - file3 = Timestamp(time() + 2).internal + '.data' - file_list = [file1, file2, file3] - self.check_hash_cleanup_listdir(file_list, [file3]) - - def test_hash_cleanup_listdir_keep_one_meta(self): - # keep only latest of multiple .meta files - file1 = Timestamp(time()).internal + '.data' - file2 = Timestamp(time() + 1).internal + '.meta' - file3 = Timestamp(time() + 2).internal + '.meta' - file_list = [file1, file2, file3] - self.check_hash_cleanup_listdir(file_list, [file3, file1]) - - def test_hash_cleanup_listdir_ignore_orphaned_ts(self): - # A more recent orphaned .meta file will prevent old .ts files - # from being cleaned up otherwise - file1 = Timestamp(time()).internal + '.ts' - file2 = Timestamp(time() + 1).internal + '.ts' - file3 = Timestamp(time() + 2).internal + '.meta' - file_list = [file1, file2, file3] - self.check_hash_cleanup_listdir(file_list, [file3, file2]) - - def test_hash_cleanup_listdir_purge_old_data_only(self): - # Oldest .data will be purge, .meta and .ts won't be touched - file1 = Timestamp(time()).internal + '.data' - file2 = Timestamp(time() + 1).internal + '.ts' - file3 = Timestamp(time() + 2).internal + '.meta' - file_list = [file1, file2, file3] - self.check_hash_cleanup_listdir(file_list, [file3, file2]) - - def test_hash_cleanup_listdir_purge_old_ts(self): - # A single old .ts file will be removed - file1 = Timestamp(time() - (diskfile.ONE_WEEK + 1)).internal + '.ts' - file_list = [file1] - self.check_hash_cleanup_listdir(file_list, []) - - def test_hash_cleanup_listdir_meta_keeps_old_ts(self): - # An orphaned .meta will not clean up a very old .ts - file1 = Timestamp(time() - (diskfile.ONE_WEEK + 1)).internal + '.ts' - file2 = Timestamp(time() + 2).internal + '.meta' - file_list = [file1, file2] - self.check_hash_cleanup_listdir(file_list, [file2, file1]) - - def test_hash_cleanup_listdir_keep_single_old_data(self): - # A single old .data file will not be removed - file1 = Timestamp(time() - (diskfile.ONE_WEEK + 1)).internal + '.data' - file_list = [file1] - self.check_hash_cleanup_listdir(file_list, [file1]) - - def test_hash_cleanup_listdir_keep_single_old_meta(self): - # A single old .meta file will not be removed - file1 = Timestamp(time() - (diskfile.ONE_WEEK + 1)).internal + '.meta' - file_list = [file1] - self.check_hash_cleanup_listdir(file_list, [file1]) - - def test_hash_cleanup_listdir_disappeared_path(self): - # Next line listing a non-existent dir used to propagate the OSError; - # now should mute that. - self.assertEqual(diskfile.hash_cleanup_listdir(uuid.uuid4().hex), []) - - def test_hash_cleanup_listdir_disappeared_before_unlink_1(self): - # Timestamp 1 makes other test routines pretend the file disappeared - # while working. - file1 = '0000000001.00000.ts' - file_list = [file1] - self.check_hash_cleanup_listdir(file_list, []) - - def test_hash_cleanup_listdir_disappeared_before_unlink_2(self): - # Timestamp 1 makes other test routines pretend the file disappeared - # while working. - file1 = '0000000001.00000.data' - file2 = '0000000002.00000.ts' - file_list = [file1, file2] - self.check_hash_cleanup_listdir(file_list, [file2]) - @patch_policies class TestObjectAuditLocationGenerator(unittest.TestCase): @@ -677,7 +266,8 @@ class TestObjectAuditLocationGenerator(unittest.TestCase): pass def test_audit_location_class(self): - al = diskfile.AuditLocation('abc', '123', '_-_') + al = diskfile.AuditLocation('abc', '123', '_-_', + policy=POLICIES.legacy) self.assertEqual(str(al), 'abc') def test_finding_of_hashdirs(self): @@ -705,6 +295,7 @@ class TestObjectAuditLocationGenerator(unittest.TestCase): "6c3", "fcd938702024c25fef6c32fef05298eb")) os.makedirs(os.path.join(tmpdir, "sdq", "objects-fud", "foo")) + os.makedirs(os.path.join(tmpdir, "sdq", "objects-+1", "foo")) self._make_file(os.path.join(tmpdir, "sdp", "objects", "1519", "fed")) @@ -723,7 +314,7 @@ class TestObjectAuditLocationGenerator(unittest.TestCase): "4f9eee668b66c6f0250bfa3c7ab9e51e")) logger = debug_logger() - locations = [(loc.path, loc.device, loc.partition) + locations = [(loc.path, loc.device, loc.partition, loc.policy) for loc in diskfile.object_audit_location_generator( devices=tmpdir, mount_check=False, logger=logger)] @@ -732,44 +323,42 @@ class TestObjectAuditLocationGenerator(unittest.TestCase): # expect some warnings about those bad dirs warnings = logger.get_lines_for_level('warning') self.assertEqual(set(warnings), set([ - 'Directory objects- does not map to a valid policy', - 'Directory objects-2 does not map to a valid policy', - 'Directory objects-99 does not map to a valid policy', - 'Directory objects-fud does not map to a valid policy'])) + ("Directory 'objects-' does not map to a valid policy " + "(Unknown policy, for index '')"), + ("Directory 'objects-2' does not map to a valid policy " + "(Unknown policy, for index '2')"), + ("Directory 'objects-99' does not map to a valid policy " + "(Unknown policy, for index '99')"), + ("Directory 'objects-fud' does not map to a valid policy " + "(Unknown policy, for index 'fud')"), + ("Directory 'objects-+1' does not map to a valid policy " + "(Unknown policy, for index '+1')"), + ])) expected = \ [(os.path.join(tmpdir, "sdp", "objects-1", "9970", "ca5", "4a943bc72c2e647c4675923d58cf4ca5"), - "sdp", "9970"), + "sdp", "9970", POLICIES[1]), (os.path.join(tmpdir, "sdp", "objects", "1519", "aca", "5c1fdc1ffb12e5eaf84edc30d8b67aca"), - "sdp", "1519"), + "sdp", "1519", POLICIES[0]), (os.path.join(tmpdir, "sdp", "objects", "1519", "aca", "fdfd184d39080020bc8b487f8a7beaca"), - "sdp", "1519"), + "sdp", "1519", POLICIES[0]), (os.path.join(tmpdir, "sdp", "objects", "1519", "df2", "b0fe7af831cc7b1af5bf486b1c841df2"), - "sdp", "1519"), + "sdp", "1519", POLICIES[0]), (os.path.join(tmpdir, "sdp", "objects", "9720", "ca5", "4a943bc72c2e647c4675923d58cf4ca5"), - "sdp", "9720"), - (os.path.join(tmpdir, "sdq", "objects-", "1135", "6c3", - "fcd938702024c25fef6c32fef05298eb"), - "sdq", "1135"), - (os.path.join(tmpdir, "sdq", "objects-2", "9971", "8eb", - "fcd938702024c25fef6c32fef05298eb"), - "sdq", "9971"), - (os.path.join(tmpdir, "sdq", "objects-99", "9972", "8eb", - "fcd938702024c25fef6c32fef05298eb"), - "sdq", "9972"), + "sdp", "9720", POLICIES[0]), (os.path.join(tmpdir, "sdq", "objects", "3071", "8eb", "fcd938702024c25fef6c32fef05298eb"), - "sdq", "3071"), + "sdq", "3071", POLICIES[0]), ] self.assertEqual(locations, expected) # now without a logger - locations = [(loc.path, loc.device, loc.partition) + locations = [(loc.path, loc.device, loc.partition, loc.policy) for loc in diskfile.object_audit_location_generator( devices=tmpdir, mount_check=False)] locations.sort() @@ -789,7 +378,7 @@ class TestObjectAuditLocationGenerator(unittest.TestCase): "4993d582f41be9771505a8d4cb237a10")) locations = [ - (loc.path, loc.device, loc.partition) + (loc.path, loc.device, loc.partition, loc.policy) for loc in diskfile.object_audit_location_generator( devices=tmpdir, mount_check=True)] locations.sort() @@ -799,12 +388,12 @@ class TestObjectAuditLocationGenerator(unittest.TestCase): [(os.path.join(tmpdir, "sdp", "objects", "2607", "df3", "ec2871fe724411f91787462f97d30df3"), - "sdp", "2607")]) + "sdp", "2607", POLICIES[0])]) # Do it again, this time with a logger. ml = mock.MagicMock() locations = [ - (loc.path, loc.device, loc.partition) + (loc.path, loc.device, loc.partition, loc.policy) for loc in diskfile.object_audit_location_generator( devices=tmpdir, mount_check=True, logger=ml)] ml.debug.assert_called_once_with( @@ -817,7 +406,7 @@ class TestObjectAuditLocationGenerator(unittest.TestCase): # only normal FS corruption should be skipped over silently. def list_locations(dirname): - return [(loc.path, loc.device, loc.partition) + return [(loc.path, loc.device, loc.partition, loc.policy) for loc in diskfile.object_audit_location_generator( devices=dirname, mount_check=False)] @@ -843,7 +432,45 @@ class TestObjectAuditLocationGenerator(unittest.TestCase): self.assertRaises(OSError, list_locations, tmpdir) -class TestDiskFileManager(unittest.TestCase): +class TestDiskFileRouter(unittest.TestCase): + + def test_register(self): + with mock.patch.dict( + diskfile.DiskFileRouter.policy_type_to_manager_cls, {}): + @diskfile.DiskFileRouter.register('test-policy') + class TestDiskFileManager(diskfile.DiskFileManager): + pass + + @BaseStoragePolicy.register('test-policy') + class TestStoragePolicy(BaseStoragePolicy): + pass + + with patch_policies([TestStoragePolicy(0, 'test')]): + router = diskfile.DiskFileRouter({}, debug_logger('test')) + manager = router[POLICIES.default] + self.assertTrue(isinstance(manager, TestDiskFileManager)) + + +class BaseDiskFileTestMixin(object): + """ + Bag of helpers that are useful in the per-policy DiskFile test classes. + """ + + def _manager_mock(self, manager_attribute_name, df=None): + mgr_cls = df._manager.__class__ if df else self.mgr_cls + return '.'.join([ + mgr_cls.__module__, mgr_cls.__name__, manager_attribute_name]) + + +class DiskFileManagerMixin(BaseDiskFileTestMixin): + """ + Abstract test method mixin for concrete test cases - this class + won't get picked up by test runners because it doesn't subclass + unittest.TestCase and doesn't have [Tt]est in the name. + """ + + # set mgr_cls on subclasses + mgr_cls = None def setUp(self): self.tmpdir = mkdtemp() @@ -851,17 +478,111 @@ class TestDiskFileManager(unittest.TestCase): self.tmpdir, 'tmp_test_obj_server_DiskFile') self.existing_device1 = 'sda1' self.existing_device2 = 'sda2' - mkdirs(os.path.join(self.testdir, self.existing_device1, 'tmp')) - mkdirs(os.path.join(self.testdir, self.existing_device2, 'tmp')) + for policy in POLICIES: + mkdirs(os.path.join(self.testdir, self.existing_device1, + diskfile.get_tmp_dir(policy))) + mkdirs(os.path.join(self.testdir, self.existing_device2, + diskfile.get_tmp_dir(policy))) self._orig_tpool_exc = tpool.execute tpool.execute = lambda f, *args, **kwargs: f(*args, **kwargs) self.conf = dict(devices=self.testdir, mount_check='false', keep_cache_size=2 * 1024) - self.df_mgr = diskfile.DiskFileManager(self.conf, FakeLogger()) + self.logger = debug_logger('test-' + self.__class__.__name__) + self.df_mgr = self.mgr_cls(self.conf, self.logger) + self.df_router = diskfile.DiskFileRouter(self.conf, self.logger) def tearDown(self): rmtree(self.tmpdir, ignore_errors=1) + def _get_diskfile(self, policy, frag_index=None): + df_mgr = self.df_router[policy] + return df_mgr.get_diskfile('sda1', '0', 'a', 'c', 'o', + policy=policy, frag_index=frag_index) + + def _test_get_ondisk_files(self, scenarios, policy, + frag_index=None): + class_under_test = self._get_diskfile(policy, frag_index=frag_index) + with mock.patch('swift.obj.diskfile.os.listdir', + lambda _: []): + self.assertEqual((None, None, None), + class_under_test._get_ondisk_file()) + + returned_ext_order = ('.data', '.meta', '.ts') + for test in scenarios: + chosen = dict((f[1], os.path.join(class_under_test._datadir, f[0])) + for f in test if f[1]) + expected = tuple(chosen.get(ext) for ext in returned_ext_order) + files = list(zip(*test)[0]) + for _order in ('ordered', 'shuffled', 'shuffled'): + class_under_test = self._get_diskfile(policy, frag_index) + try: + with mock.patch('swift.obj.diskfile.os.listdir', + lambda _: files): + actual = class_under_test._get_ondisk_file() + self.assertEqual(expected, actual, + 'Expected %s from %s but got %s' + % (expected, files, actual)) + except AssertionError as e: + self.fail('%s with files %s' % (str(e), files)) + shuffle(files) + + def _test_hash_cleanup_listdir_files(self, scenarios, policy, + reclaim_age=None): + # check that expected files are left in hashdir after cleanup + for test in scenarios: + class_under_test = self.df_router[policy] + files = list(zip(*test)[0]) + hashdir = os.path.join(self.testdir, str(uuid.uuid4())) + os.mkdir(hashdir) + for fname in files: + open(os.path.join(hashdir, fname), 'w') + expected_after_cleanup = set([f[0] for f in test + if (f[2] if len(f) > 2 else f[1])]) + if reclaim_age: + class_under_test.hash_cleanup_listdir( + hashdir, reclaim_age=reclaim_age) + else: + with mock.patch('swift.obj.diskfile.time') as mock_time: + # don't reclaim anything + mock_time.time.return_value = 0.0 + class_under_test.hash_cleanup_listdir(hashdir) + after_cleanup = set(os.listdir(hashdir)) + errmsg = "expected %r, got %r for test %r" % ( + sorted(expected_after_cleanup), sorted(after_cleanup), test + ) + self.assertEqual(expected_after_cleanup, after_cleanup, errmsg) + + def _test_yield_hashes_cleanup(self, scenarios, policy): + # opportunistic test to check that yield_hashes cleans up dir using + # same scenarios as passed to _test_hash_cleanup_listdir_files + for test in scenarios: + class_under_test = self.df_router[policy] + files = list(zip(*test)[0]) + dev_path = os.path.join(self.testdir, str(uuid.uuid4())) + hashdir = os.path.join( + dev_path, diskfile.get_data_dir(policy), + '0', 'abc', '9373a92d072897b136b3fc06595b4abc') + os.makedirs(hashdir) + for fname in files: + open(os.path.join(hashdir, fname), 'w') + expected_after_cleanup = set([f[0] for f in test + if f[1] or len(f) > 2 and f[2]]) + with mock.patch('swift.obj.diskfile.time') as mock_time: + # don't reclaim anything + mock_time.time.return_value = 0.0 + mock_func = 'swift.obj.diskfile.DiskFileManager.get_dev_path' + with mock.patch(mock_func) as mock_path: + mock_path.return_value = dev_path + for _ in class_under_test.yield_hashes( + 'ignored', '0', policy, suffixes=['abc']): + # return values are tested in test_yield_hashes_* + pass + after_cleanup = set(os.listdir(hashdir)) + errmsg = "expected %r, got %r for test %r" % ( + sorted(expected_after_cleanup), sorted(after_cleanup), test + ) + self.assertEqual(expected_after_cleanup, after_cleanup, errmsg) + def test_construct_dev_path(self): res_path = self.df_mgr.construct_dev_path('abc') self.assertEqual(os.path.join(self.df_mgr.devices, 'abc'), res_path) @@ -872,12 +593,13 @@ class TestDiskFileManager(unittest.TestCase): with mock.patch('swift.obj.diskfile.write_pickle') as wp: self.df_mgr.pickle_async_update(self.existing_device1, 'a', 'c', 'o', - dict(a=1, b=2), ts, 0) + dict(a=1, b=2), ts, POLICIES[0]) dp = self.df_mgr.construct_dev_path(self.existing_device1) ohash = diskfile.hash_path('a', 'c', 'o') wp.assert_called_with({'a': 1, 'b': 2}, - os.path.join(dp, diskfile.get_async_dir(0), - ohash[-3:], ohash + '-' + ts), + os.path.join( + dp, diskfile.get_async_dir(POLICIES[0]), + ohash[-3:], ohash + '-' + ts), os.path.join(dp, 'tmp')) self.df_mgr.logger.increment.assert_called_with('async_pendings') @@ -885,32 +607,16 @@ class TestDiskFileManager(unittest.TestCase): locations = list(self.df_mgr.object_audit_location_generator()) self.assertEqual(locations, []) - def test_get_hashes_bad_dev(self): - self.df_mgr.mount_check = True - with mock.patch('swift.obj.diskfile.check_mount', - mock.MagicMock(side_effect=[False])): - self.assertRaises(DiskFileDeviceUnavailable, - self.df_mgr.get_hashes, 'sdb1', '0', '123', - 'objects') - - def test_get_hashes_w_nothing(self): - hashes = self.df_mgr.get_hashes(self.existing_device1, '0', '123', '0') - self.assertEqual(hashes, {}) - # get_hashes creates the partition path, so call again for code - # path coverage, ensuring the result is unchanged - hashes = self.df_mgr.get_hashes(self.existing_device1, '0', '123', '0') - self.assertEqual(hashes, {}) - def test_replication_lock_on(self): # Double check settings self.df_mgr.replication_one_per_device = True self.df_mgr.replication_lock_timeout = 0.1 dev_path = os.path.join(self.testdir, self.existing_device1) - with self.df_mgr.replication_lock(dev_path): + with self.df_mgr.replication_lock(self.existing_device1): lock_exc = None exc = None try: - with self.df_mgr.replication_lock(dev_path): + with self.df_mgr.replication_lock(self.existing_device1): raise Exception( '%r was not replication locked!' % dev_path) except ReplicationLockTimeout as err: @@ -943,12 +649,10 @@ class TestDiskFileManager(unittest.TestCase): # Double check settings self.df_mgr.replication_one_per_device = True self.df_mgr.replication_lock_timeout = 0.1 - dev_path = os.path.join(self.testdir, self.existing_device1) - dev_path2 = os.path.join(self.testdir, self.existing_device2) - with self.df_mgr.replication_lock(dev_path): + with self.df_mgr.replication_lock(self.existing_device1): lock_exc = None try: - with self.df_mgr.replication_lock(dev_path2): + with self.df_mgr.replication_lock(self.existing_device2): pass except ReplicationLockTimeout as err: lock_exc = err @@ -965,10 +669,1094 @@ class TestDiskFileManager(unittest.TestCase): self.assertTrue('splice()' in warnings[-1]) self.assertFalse(mgr.use_splice) + def test_get_diskfile_from_hash_dev_path_fail(self): + self.df_mgr.get_dev_path = mock.MagicMock(return_value=None) + with nested( + mock.patch(self._manager_mock('diskfile_cls')), + mock.patch(self._manager_mock('hash_cleanup_listdir')), + mock.patch('swift.obj.diskfile.read_metadata')) as \ + (dfclass, hclistdir, readmeta): + hclistdir.return_value = ['1381679759.90941.data'] + readmeta.return_value = {'name': '/a/c/o'} + self.assertRaises( + DiskFileDeviceUnavailable, + self.df_mgr.get_diskfile_from_hash, + 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', POLICIES[0]) + + def test_get_diskfile_from_hash_not_dir(self): + self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') + with nested( + mock.patch(self._manager_mock('diskfile_cls')), + mock.patch(self._manager_mock('hash_cleanup_listdir')), + mock.patch('swift.obj.diskfile.read_metadata'), + mock.patch(self._manager_mock('quarantine_renamer'))) as \ + (dfclass, hclistdir, readmeta, quarantine_renamer): + osexc = OSError() + osexc.errno = errno.ENOTDIR + hclistdir.side_effect = osexc + readmeta.return_value = {'name': '/a/c/o'} + self.assertRaises( + DiskFileNotExist, + self.df_mgr.get_diskfile_from_hash, + 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', POLICIES[0]) + quarantine_renamer.assert_called_once_with( + '/srv/dev/', + '/srv/dev/objects/9/900/9a7175077c01a23ade5956b8a2bba900') + + def test_get_diskfile_from_hash_no_dir(self): + self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') + with nested( + mock.patch(self._manager_mock('diskfile_cls')), + mock.patch(self._manager_mock('hash_cleanup_listdir')), + mock.patch('swift.obj.diskfile.read_metadata')) as \ + (dfclass, hclistdir, readmeta): + osexc = OSError() + osexc.errno = errno.ENOENT + hclistdir.side_effect = osexc + readmeta.return_value = {'name': '/a/c/o'} + self.assertRaises( + DiskFileNotExist, + self.df_mgr.get_diskfile_from_hash, + 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', POLICIES[0]) + + def test_get_diskfile_from_hash_other_oserror(self): + self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') + with nested( + mock.patch(self._manager_mock('diskfile_cls')), + mock.patch(self._manager_mock('hash_cleanup_listdir')), + mock.patch('swift.obj.diskfile.read_metadata')) as \ + (dfclass, hclistdir, readmeta): + osexc = OSError() + hclistdir.side_effect = osexc + readmeta.return_value = {'name': '/a/c/o'} + self.assertRaises( + OSError, + self.df_mgr.get_diskfile_from_hash, + 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', POLICIES[0]) + + def test_get_diskfile_from_hash_no_actual_files(self): + self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') + with nested( + mock.patch(self._manager_mock('diskfile_cls')), + mock.patch(self._manager_mock('hash_cleanup_listdir')), + mock.patch('swift.obj.diskfile.read_metadata')) as \ + (dfclass, hclistdir, readmeta): + hclistdir.return_value = [] + readmeta.return_value = {'name': '/a/c/o'} + self.assertRaises( + DiskFileNotExist, + self.df_mgr.get_diskfile_from_hash, + 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', POLICIES[0]) + + def test_get_diskfile_from_hash_read_metadata_problem(self): + self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') + with nested( + mock.patch(self._manager_mock('diskfile_cls')), + mock.patch(self._manager_mock('hash_cleanup_listdir')), + mock.patch('swift.obj.diskfile.read_metadata')) as \ + (dfclass, hclistdir, readmeta): + hclistdir.return_value = ['1381679759.90941.data'] + readmeta.side_effect = EOFError() + self.assertRaises( + DiskFileNotExist, + self.df_mgr.get_diskfile_from_hash, + 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', POLICIES[0]) + + def test_get_diskfile_from_hash_no_meta_name(self): + self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') + with nested( + mock.patch(self._manager_mock('diskfile_cls')), + mock.patch(self._manager_mock('hash_cleanup_listdir')), + mock.patch('swift.obj.diskfile.read_metadata')) as \ + (dfclass, hclistdir, readmeta): + hclistdir.return_value = ['1381679759.90941.data'] + readmeta.return_value = {} + try: + self.df_mgr.get_diskfile_from_hash( + 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', + POLICIES[0]) + except DiskFileNotExist as err: + exc = err + self.assertEqual(str(exc), '') + + def test_get_diskfile_from_hash_bad_meta_name(self): + self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') + with nested( + mock.patch(self._manager_mock('diskfile_cls')), + mock.patch(self._manager_mock('hash_cleanup_listdir')), + mock.patch('swift.obj.diskfile.read_metadata')) as \ + (dfclass, hclistdir, readmeta): + hclistdir.return_value = ['1381679759.90941.data'] + readmeta.return_value = {'name': 'bad'} + try: + self.df_mgr.get_diskfile_from_hash( + 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', + POLICIES[0]) + except DiskFileNotExist as err: + exc = err + self.assertEqual(str(exc), '') + + def test_get_diskfile_from_hash(self): + self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') + with nested( + mock.patch(self._manager_mock('diskfile_cls')), + mock.patch(self._manager_mock('hash_cleanup_listdir')), + mock.patch('swift.obj.diskfile.read_metadata')) as \ + (dfclass, hclistdir, readmeta): + hclistdir.return_value = ['1381679759.90941.data'] + readmeta.return_value = {'name': '/a/c/o'} + self.df_mgr.get_diskfile_from_hash( + 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', POLICIES[0]) + dfclass.assert_called_once_with( + self.df_mgr, '/srv/dev/', self.df_mgr.threadpools['dev'], '9', + 'a', 'c', 'o', policy=POLICIES[0]) + hclistdir.assert_called_once_with( + '/srv/dev/objects/9/900/9a7175077c01a23ade5956b8a2bba900', + 604800) + readmeta.assert_called_once_with( + '/srv/dev/objects/9/900/9a7175077c01a23ade5956b8a2bba900/' + '1381679759.90941.data') + + def test_listdir_enoent(self): + oserror = OSError() + oserror.errno = errno.ENOENT + self.df_mgr.logger.error = mock.MagicMock() + with mock.patch('os.listdir', side_effect=oserror): + self.assertEqual(self.df_mgr._listdir('path'), []) + self.assertEqual(self.df_mgr.logger.error.mock_calls, []) + + def test_listdir_other_oserror(self): + oserror = OSError() + self.df_mgr.logger.error = mock.MagicMock() + with mock.patch('os.listdir', side_effect=oserror): + self.assertEqual(self.df_mgr._listdir('path'), []) + self.df_mgr.logger.error.assert_called_once_with( + 'ERROR: Skipping %r due to error with listdir attempt: %s', + 'path', oserror) + + def test_listdir(self): + self.df_mgr.logger.error = mock.MagicMock() + with mock.patch('os.listdir', return_value=['abc', 'def']): + self.assertEqual(self.df_mgr._listdir('path'), ['abc', 'def']) + self.assertEqual(self.df_mgr.logger.error.mock_calls, []) + + def test_yield_suffixes_dev_path_fail(self): + self.df_mgr.get_dev_path = mock.MagicMock(return_value=None) + exc = None + try: + list(self.df_mgr.yield_suffixes(self.existing_device1, '9', 0)) + except DiskFileDeviceUnavailable as err: + exc = err + self.assertEqual(str(exc), '') + + def test_yield_suffixes(self): + self.df_mgr._listdir = mock.MagicMock(return_value=[ + 'abc', 'def', 'ghi', 'abcd', '012']) + dev = self.existing_device1 + self.assertEqual( + list(self.df_mgr.yield_suffixes(dev, '9', POLICIES[0])), + [(self.testdir + '/' + dev + '/objects/9/abc', 'abc'), + (self.testdir + '/' + dev + '/objects/9/def', 'def'), + (self.testdir + '/' + dev + '/objects/9/012', '012')]) + + def test_yield_hashes_dev_path_fail(self): + self.df_mgr.get_dev_path = mock.MagicMock(return_value=None) + exc = None + try: + list(self.df_mgr.yield_hashes(self.existing_device1, '9', + POLICIES[0])) + except DiskFileDeviceUnavailable as err: + exc = err + self.assertEqual(str(exc), '') + + def test_yield_hashes_empty(self): + def _listdir(path): + return [] + + with mock.patch('os.listdir', _listdir): + self.assertEqual(list(self.df_mgr.yield_hashes( + self.existing_device1, '9', POLICIES[0])), []) + + def test_yield_hashes_empty_suffixes(self): + def _listdir(path): + return [] + + with mock.patch('os.listdir', _listdir): + self.assertEqual( + list(self.df_mgr.yield_hashes(self.existing_device1, '9', + POLICIES[0], + suffixes=['456'])), []) + + def _check_yield_hashes(self, policy, suffix_map, expected, **kwargs): + device = self.existing_device1 + part = '9' + part_path = os.path.join( + self.testdir, device, diskfile.get_data_dir(policy), part) + + def _listdir(path): + if path == part_path: + return suffix_map.keys() + for suff, hash_map in suffix_map.items(): + if path == os.path.join(part_path, suff): + return hash_map.keys() + for hash_, files in hash_map.items(): + if path == os.path.join(part_path, suff, hash_): + return files + self.fail('Unexpected listdir of %r' % path) + expected_items = [ + (os.path.join(part_path, hash_[-3:], hash_), hash_, + Timestamp(ts).internal) + for hash_, ts in expected.items()] + with nested( + mock.patch('os.listdir', _listdir), + mock.patch('os.unlink')): + df_mgr = self.df_router[policy] + hash_items = list(df_mgr.yield_hashes( + device, part, policy, **kwargs)) + expected = sorted(expected_items) + actual = sorted(hash_items) + self.assertEqual(actual, expected, + 'Expected %s but got %s' % (expected, actual)) + + def test_yield_hashes_tombstones(self): + ts_iter = (Timestamp(t) for t in itertools.count(int(time()))) + ts1 = next(ts_iter) + ts2 = next(ts_iter) + ts3 = next(ts_iter) + suffix_map = { + '27e': { + '1111111111111111111111111111127e': [ + ts1.internal + '.ts'], + '2222222222222222222222222222227e': [ + ts2.internal + '.ts'], + }, + 'd41': { + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaad41': [] + }, + 'd98': {}, + '00b': { + '3333333333333333333333333333300b': [ + ts1.internal + '.ts', + ts2.internal + '.ts', + ts3.internal + '.ts', + ] + }, + '204': { + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbb204': [ + ts3.internal + '.ts', + ] + } + } + expected = { + '1111111111111111111111111111127e': ts1.internal, + '2222222222222222222222222222227e': ts2.internal, + '3333333333333333333333333333300b': ts3.internal, + } + for policy in POLICIES: + self._check_yield_hashes(policy, suffix_map, expected, + suffixes=['27e', '00b']) + @patch_policies -class TestDiskFile(unittest.TestCase): - """Test swift.obj.diskfile.DiskFile""" +class TestDiskFileManager(DiskFileManagerMixin, unittest.TestCase): + + mgr_cls = diskfile.DiskFileManager + + def test_get_ondisk_files_with_repl_policy(self): + # Each scenario specifies a list of (filename, extension) tuples. If + # extension is set then that filename should be returned by the method + # under test for that extension type. + scenarios = [[('0000000007.00000.data', '.data')], + + [('0000000007.00000.ts', '.ts')], + + # older tombstone is ignored + [('0000000007.00000.ts', '.ts'), + ('0000000006.00000.ts', False)], + + # older data is ignored + [('0000000007.00000.data', '.data'), + ('0000000006.00000.data', False), + ('0000000004.00000.ts', False)], + + # newest meta trumps older meta + [('0000000009.00000.meta', '.meta'), + ('0000000008.00000.meta', False), + ('0000000007.00000.data', '.data'), + ('0000000004.00000.ts', False)], + + # meta older than data is ignored + [('0000000007.00000.data', '.data'), + ('0000000006.00000.meta', False), + ('0000000004.00000.ts', False)], + + # meta without data is ignored + [('0000000007.00000.meta', False, True), + ('0000000006.00000.ts', '.ts'), + ('0000000004.00000.data', False)], + + # tombstone trumps meta and data at same timestamp + [('0000000006.00000.meta', False), + ('0000000006.00000.ts', '.ts'), + ('0000000006.00000.data', False)], + ] + + self._test_get_ondisk_files(scenarios, POLICIES[0], None) + self._test_hash_cleanup_listdir_files(scenarios, POLICIES[0]) + self._test_yield_hashes_cleanup(scenarios, POLICIES[0]) + + def test_get_ondisk_files_with_stray_meta(self): + # get_ondisk_files does not tolerate a stray .meta file + + class_under_test = self._get_diskfile(POLICIES[0]) + files = ['0000000007.00000.meta'] + + self.assertRaises(AssertionError, + class_under_test.manager.get_ondisk_files, files, + self.testdir) + + def test_yield_hashes(self): + old_ts = '1383180000.12345' + fresh_ts = Timestamp(time() - 10).internal + fresher_ts = Timestamp(time() - 1).internal + suffix_map = { + 'abc': { + '9373a92d072897b136b3fc06595b4abc': [ + fresh_ts + '.ts'], + }, + '456': { + '9373a92d072897b136b3fc06595b0456': [ + old_ts + '.data'], + '9373a92d072897b136b3fc06595b7456': [ + fresh_ts + '.ts', + fresher_ts + '.data'], + }, + 'def': {}, + } + expected = { + '9373a92d072897b136b3fc06595b4abc': fresh_ts, + '9373a92d072897b136b3fc06595b0456': old_ts, + '9373a92d072897b136b3fc06595b7456': fresher_ts, + } + self._check_yield_hashes(POLICIES.default, suffix_map, expected) + + def test_yield_hashes_yields_meta_timestamp(self): + ts_iter = (Timestamp(t) for t in itertools.count(int(time()))) + ts1 = next(ts_iter) + ts2 = next(ts_iter) + ts3 = next(ts_iter) + suffix_map = { + 'abc': { + '9373a92d072897b136b3fc06595b4abc': [ + ts1.internal + '.ts', + ts2.internal + '.meta'], + }, + '456': { + '9373a92d072897b136b3fc06595b0456': [ + ts1.internal + '.data', + ts2.internal + '.meta', + ts3.internal + '.meta'], + '9373a92d072897b136b3fc06595b7456': [ + ts1.internal + '.data', + ts2.internal + '.meta'], + }, + } + expected = { + '9373a92d072897b136b3fc06595b4abc': ts2, + '9373a92d072897b136b3fc06595b0456': ts3, + '9373a92d072897b136b3fc06595b7456': ts2, + } + self._check_yield_hashes(POLICIES.default, suffix_map, expected) + + def test_yield_hashes_suffix_filter(self): + # test again with limited suffixes + old_ts = '1383180000.12345' + fresh_ts = Timestamp(time() - 10).internal + fresher_ts = Timestamp(time() - 1).internal + suffix_map = { + 'abc': { + '9373a92d072897b136b3fc06595b4abc': [ + fresh_ts + '.ts'], + }, + '456': { + '9373a92d072897b136b3fc06595b0456': [ + old_ts + '.data'], + '9373a92d072897b136b3fc06595b7456': [ + fresh_ts + '.ts', + fresher_ts + '.data'], + }, + 'def': {}, + } + expected = { + '9373a92d072897b136b3fc06595b0456': old_ts, + '9373a92d072897b136b3fc06595b7456': fresher_ts, + } + self._check_yield_hashes(POLICIES.default, suffix_map, expected, + suffixes=['456']) + + def test_yield_hashes_fails_with_bad_ondisk_filesets(self): + ts_iter = (Timestamp(t) for t in itertools.count(int(time()))) + ts1 = next(ts_iter) + suffix_map = { + '456': { + '9373a92d072897b136b3fc06595b0456': [ + ts1.internal + '.data'], + '9373a92d072897b136b3fc06595ba456': [ + ts1.internal + '.meta'], + }, + } + expected = { + '9373a92d072897b136b3fc06595b0456': ts1, + } + try: + self._check_yield_hashes(POLICIES.default, suffix_map, expected, + frag_index=2) + self.fail('Expected AssertionError') + except AssertionError: + pass + + +@patch_policies(with_ec_default=True) +class TestECDiskFileManager(DiskFileManagerMixin, unittest.TestCase): + + mgr_cls = diskfile.ECDiskFileManager + + def test_get_ondisk_files_with_ec_policy(self): + # Each scenario specifies a list of (filename, extension, [survives]) + # tuples. If extension is set then that filename should be returned by + # the method under test for that extension type. If the optional + # 'survives' is True, the filename should still be in the dir after + # cleanup. + scenarios = [[('0000000007.00000.ts', '.ts')], + + [('0000000007.00000.ts', '.ts'), + ('0000000006.00000.ts', False)], + + # highest frag index is chosen by default + [('0000000007.00000.durable', '.durable'), + ('0000000007.00000#1.data', '.data'), + ('0000000007.00000#0.data', False, True)], + + # data with no durable is ignored + [('0000000007.00000#0.data', False, True)], + + # data newer than durable is ignored + [('0000000008.00000#1.data', False, True), + ('0000000007.00000.durable', '.durable'), + ('0000000007.00000#1.data', '.data'), + ('0000000007.00000#0.data', False, True)], + + # data newer than durable ignored, even if its only data + [('0000000008.00000#1.data', False, True), + ('0000000007.00000.durable', False, False)], + + # data older than durable is ignored + [('0000000007.00000.durable', '.durable'), + ('0000000007.00000#1.data', '.data'), + ('0000000006.00000#1.data', False), + ('0000000004.00000.ts', False)], + + # data older than durable ignored, even if its only data + [('0000000007.00000.durable', False, False), + ('0000000006.00000#1.data', False), + ('0000000004.00000.ts', False)], + + # newer meta trumps older meta + [('0000000009.00000.meta', '.meta'), + ('0000000008.00000.meta', False), + ('0000000007.00000.durable', '.durable'), + ('0000000007.00000#14.data', '.data'), + ('0000000004.00000.ts', False)], + + # older meta is ignored + [('0000000007.00000.durable', '.durable'), + ('0000000007.00000#14.data', '.data'), + ('0000000006.00000.meta', False), + ('0000000004.00000.ts', False)], + + # tombstone trumps meta, data, durable at older timestamp + [('0000000006.00000.ts', '.ts'), + ('0000000005.00000.meta', False), + ('0000000004.00000.durable', False), + ('0000000004.00000#0.data', False)], + + # tombstone trumps meta, data, durable at same timestamp + [('0000000006.00000.meta', False), + ('0000000006.00000.ts', '.ts'), + ('0000000006.00000.durable', False), + ('0000000006.00000#0.data', False)], + + # missing durable invalidates data + [('0000000006.00000.meta', False, True), + ('0000000006.00000#0.data', False, True)] + ] + + self._test_get_ondisk_files(scenarios, POLICIES.default, None) + self._test_hash_cleanup_listdir_files(scenarios, POLICIES.default) + self._test_yield_hashes_cleanup(scenarios, POLICIES.default) + + def test_get_ondisk_files_with_ec_policy_and_frag_index(self): + # Each scenario specifies a list of (filename, extension) tuples. If + # extension is set then that filename should be returned by the method + # under test for that extension type. + scenarios = [[('0000000007.00000#2.data', False, True), + ('0000000007.00000#1.data', '.data'), + ('0000000007.00000#0.data', False, True), + ('0000000007.00000.durable', '.durable')], + + # specific frag newer than durable is ignored + [('0000000007.00000#2.data', False, True), + ('0000000007.00000#1.data', False, True), + ('0000000007.00000#0.data', False, True), + ('0000000006.00000.durable', '.durable')], + + # specific frag older than durable is ignored + [('0000000007.00000#2.data', False), + ('0000000007.00000#1.data', False), + ('0000000007.00000#0.data', False), + ('0000000008.00000.durable', '.durable')], + + # specific frag older than newest durable is ignored + # even if is also has a durable + [('0000000007.00000#2.data', False), + ('0000000007.00000#1.data', False), + ('0000000007.00000.durable', False), + ('0000000008.00000#0.data', False), + ('0000000008.00000.durable', '.durable')], + + # meta included when frag index is specified + [('0000000009.00000.meta', '.meta'), + ('0000000007.00000#2.data', False, True), + ('0000000007.00000#1.data', '.data'), + ('0000000007.00000#0.data', False, True), + ('0000000007.00000.durable', '.durable')], + + # specific frag older than tombstone is ignored + [('0000000009.00000.ts', '.ts'), + ('0000000007.00000#2.data', False), + ('0000000007.00000#1.data', False), + ('0000000007.00000#0.data', False), + ('0000000007.00000.durable', False)], + + # no data file returned if specific frag index missing + [('0000000007.00000#2.data', False, True), + ('0000000007.00000#14.data', False, True), + ('0000000007.00000#0.data', False, True), + ('0000000007.00000.durable', '.durable')], + + # meta ignored if specific frag index missing + [('0000000008.00000.meta', False, True), + ('0000000007.00000#14.data', False, True), + ('0000000007.00000#0.data', False, True), + ('0000000007.00000.durable', '.durable')], + + # meta ignored if no data files + # Note: this is anomalous, because we are specifying a + # frag_index, get_ondisk_files will tolerate .meta with + # no .data + [('0000000088.00000.meta', False, True), + ('0000000077.00000.durable', '.durable')] + ] + + self._test_get_ondisk_files(scenarios, POLICIES.default, frag_index=1) + # note: not calling self._test_hash_cleanup_listdir_files(scenarios, 0) + # here due to the anomalous scenario as commented above + + def test_hash_cleanup_listdir_reclaim(self): + # Each scenario specifies a list of (filename, extension, [survives]) + # tuples. If extension is set or 'survives' is True, the filename + # should still be in the dir after cleanup. + much_older = Timestamp(time() - 2000).internal + older = Timestamp(time() - 1001).internal + newer = Timestamp(time() - 900).internal + scenarios = [[('%s.ts' % older, False, False)], + + # fresh tombstone is preserved + [('%s.ts' % newer, '.ts', True)], + + # isolated .durable is cleaned up immediately + [('%s.durable' % newer, False, False)], + + # ...even when other older files are in dir + [('%s.durable' % older, False, False), + ('%s.ts' % much_older, False, False)], + + # isolated .data files are cleaned up when stale + [('%s#2.data' % older, False, False), + ('%s#4.data' % older, False, False)], + + # ...even when there is an older durable fileset + [('%s#2.data' % older, False, False), + ('%s#4.data' % older, False, False), + ('%s#2.data' % much_older, '.data', True), + ('%s#4.data' % much_older, False, True), + ('%s.durable' % much_older, '.durable', True)], + + # ... but preserved if still fresh + [('%s#2.data' % newer, False, True), + ('%s#4.data' % newer, False, True)], + + # ... and we could have a mixture of fresh and stale .data + [('%s#2.data' % newer, False, True), + ('%s#4.data' % older, False, False)], + + # TODO these remaining scenarios exhibit different + # behavior than the legacy replication DiskFileManager + # behavior... + + # tombstone reclaimed despite newer non-durable data + [('%s#2.data' % newer, False, True), + ('%s#4.data' % older, False, False), + ('%s.ts' % much_older, '.ts', False)], + + # tombstone reclaimed despite newer non-durable data + [('%s.ts' % older, '.ts', False), + ('%s.durable' % much_older, False, False)], + + # tombstone reclaimed despite junk file + [('junk', False, True), + ('%s.ts' % much_older, '.ts', False)], + ] + + self._test_hash_cleanup_listdir_files(scenarios, POLICIES.default, + reclaim_age=1000) + + def test_get_ondisk_files_with_stray_meta(self): + # get_ondisk_files does not tolerate a stray .meta file + scenarios = [['0000000007.00000.meta'], + + ['0000000007.00000.meta', + '0000000006.00000.durable'], + + ['0000000007.00000.meta', + '0000000006.00000#1.data'], + + ['0000000007.00000.meta', + '0000000006.00000.durable', + '0000000005.00000#1.data'] + ] + for files in scenarios: + class_under_test = self._get_diskfile(POLICIES.default) + self.assertRaises(DiskFileNotExist, class_under_test.open) + + def test_parse_on_disk_filename(self): + mgr = self.df_router[POLICIES.default] + for ts in (Timestamp('1234567890.00001'), + Timestamp('1234567890.00001', offset=17)): + for frag in (0, 2, 14): + fname = '%s#%s.data' % (ts.internal, frag) + info = mgr.parse_on_disk_filename(fname) + self.assertEqual(ts, info['timestamp']) + self.assertEqual(frag, info['frag_index']) + self.assertEqual(mgr.make_on_disk_filename(**info), fname) + + for ext in ('.meta', '.durable', '.ts'): + fname = '%s%s' % (ts.internal, ext) + info = mgr.parse_on_disk_filename(fname) + self.assertEqual(ts, info['timestamp']) + self.assertEqual(None, info['frag_index']) + self.assertEqual(mgr.make_on_disk_filename(**info), fname) + + def test_parse_on_disk_filename_errors(self): + mgr = self.df_router[POLICIES.default] + for ts in (Timestamp('1234567890.00001'), + Timestamp('1234567890.00001', offset=17)): + fname = '%s.data' % ts.internal + try: + mgr.parse_on_disk_filename(fname) + msg = 'Expected DiskFileError for filename %s' % fname + self.fail(msg) + except DiskFileError: + pass + + expected = { + '': 'bad', + 'foo': 'bad', + '1.314': 'bad', + 1.314: 'bad', + -2: 'negative', + '-2': 'negative', + None: 'bad', + 'None': 'bad', + } + + for frag, msg in expected.items(): + fname = '%s#%s.data' % (ts.internal, frag) + try: + mgr.parse_on_disk_filename(fname) + except DiskFileError as e: + self.assertTrue(msg in str(e).lower()) + else: + msg = 'Expected DiskFileError for filename %s' % fname + self.fail(msg) + + def test_make_on_disk_filename(self): + mgr = self.df_router[POLICIES.default] + for ts in (Timestamp('1234567890.00001'), + Timestamp('1234567890.00001', offset=17)): + for frag in (0, '0', 2, '2', 14, '14'): + expected = '%s#%s.data' % (ts.internal, frag) + actual = mgr.make_on_disk_filename( + ts, '.data', frag_index=frag) + self.assertEqual(expected, actual) + parsed = mgr.parse_on_disk_filename(actual) + self.assertEqual(parsed, { + 'timestamp': ts, + 'frag_index': int(frag), + 'ext': '.data', + }) + # these functions are inverse + self.assertEqual( + mgr.make_on_disk_filename(**parsed), + expected) + + for ext in ('.meta', '.durable', '.ts'): + expected = '%s%s' % (ts.internal, ext) + # frag index should not be required + actual = mgr.make_on_disk_filename(ts, ext) + self.assertEqual(expected, actual) + # frag index should be ignored + actual = mgr.make_on_disk_filename( + ts, ext, frag_index=frag) + self.assertEqual(expected, actual) + parsed = mgr.parse_on_disk_filename(actual) + self.assertEqual(parsed, { + 'timestamp': ts, + 'frag_index': None, + 'ext': ext, + }) + # these functions are inverse + self.assertEqual( + mgr.make_on_disk_filename(**parsed), + expected) + + actual = mgr.make_on_disk_filename(ts) + self.assertEqual(ts, actual) + + def test_make_on_disk_filename_with_bad_frag_index(self): + mgr = self.df_router[POLICIES.default] + ts = Timestamp('1234567890.00001') + try: + # .data requires a frag_index kwarg + mgr.make_on_disk_filename(ts, '.data') + self.fail('Expected DiskFileError for missing frag_index') + except DiskFileError: + pass + + for frag in (None, 'foo', '1.314', 1.314, -2, '-2'): + try: + mgr.make_on_disk_filename(ts, '.data', frag_index=frag) + self.fail('Expected DiskFileError for frag_index %s' % frag) + except DiskFileError: + pass + for ext in ('.meta', '.durable', '.ts'): + expected = '%s%s' % (ts.internal, ext) + # bad frag index should be ignored + actual = mgr.make_on_disk_filename(ts, ext, frag_index=frag) + self.assertEqual(expected, actual) + + def test_is_obsolete(self): + mgr = self.df_router[POLICIES.default] + for ts in (Timestamp('1234567890.00001'), + Timestamp('1234567890.00001', offset=17)): + for ts2 in (Timestamp('1234567890.99999'), + Timestamp('1234567890.99999', offset=17), + ts): + f_2 = mgr.make_on_disk_filename(ts, '.durable') + for fi in (0, 2): + for ext in ('.data', '.meta', '.durable', '.ts'): + f_1 = mgr.make_on_disk_filename( + ts2, ext, frag_index=fi) + self.assertFalse(mgr.is_obsolete(f_1, f_2), + '%s should not be obsolete w.r.t. %s' + % (f_1, f_2)) + + for ts2 in (Timestamp('1234567890.00000'), + Timestamp('1234500000.00000', offset=0), + Timestamp('1234500000.00000', offset=17)): + f_2 = mgr.make_on_disk_filename(ts, '.durable') + for fi in (0, 2): + for ext in ('.data', '.meta', '.durable', '.ts'): + f_1 = mgr.make_on_disk_filename( + ts2, ext, frag_index=fi) + self.assertTrue(mgr.is_obsolete(f_1, f_2), + '%s should not be w.r.t. %s' + % (f_1, f_2)) + + def test_yield_hashes(self): + old_ts = '1383180000.12345' + fresh_ts = Timestamp(time() - 10).internal + fresher_ts = Timestamp(time() - 1).internal + suffix_map = { + 'abc': { + '9373a92d072897b136b3fc06595b4abc': [ + fresh_ts + '.ts'], + }, + '456': { + '9373a92d072897b136b3fc06595b0456': [ + old_ts + '#2.data', + old_ts + '.durable'], + '9373a92d072897b136b3fc06595b7456': [ + fresh_ts + '.ts', + fresher_ts + '#2.data', + fresher_ts + '.durable'], + }, + 'def': {}, + } + expected = { + '9373a92d072897b136b3fc06595b4abc': fresh_ts, + '9373a92d072897b136b3fc06595b0456': old_ts, + '9373a92d072897b136b3fc06595b7456': fresher_ts, + } + self._check_yield_hashes(POLICIES.default, suffix_map, expected, + frag_index=2) + + def test_yield_hashes_yields_meta_timestamp(self): + ts_iter = (Timestamp(t) for t in itertools.count(int(time()))) + ts1 = next(ts_iter) + ts2 = next(ts_iter) + ts3 = next(ts_iter) + suffix_map = { + 'abc': { + '9373a92d072897b136b3fc06595b4abc': [ + ts1.internal + '.ts', + ts2.internal + '.meta'], + }, + '456': { + '9373a92d072897b136b3fc06595b0456': [ + ts1.internal + '#2.data', + ts1.internal + '.durable', + ts2.internal + '.meta', + ts3.internal + '.meta'], + '9373a92d072897b136b3fc06595b7456': [ + ts1.internal + '#2.data', + ts1.internal + '.durable', + ts2.internal + '.meta'], + }, + } + expected = { + # TODO: differs from repl DiskFileManager which *will* + # return meta timestamp when only meta and ts on disk + '9373a92d072897b136b3fc06595b4abc': ts1, + '9373a92d072897b136b3fc06595b0456': ts3, + '9373a92d072897b136b3fc06595b7456': ts2, + } + self._check_yield_hashes(POLICIES.default, suffix_map, expected) + + # but meta timestamp is not returned if specified frag index + # is not found + expected = { + # TODO: differs from repl DiskFileManager which *will* + # return meta timestamp when only meta and ts on disk + '9373a92d072897b136b3fc06595b4abc': ts1, + '9373a92d072897b136b3fc06595b0456': ts3, + '9373a92d072897b136b3fc06595b7456': ts2, + } + self._check_yield_hashes(POLICIES.default, suffix_map, expected, + frag_index=3) + + def test_yield_hashes_suffix_filter(self): + # test again with limited suffixes + old_ts = '1383180000.12345' + fresh_ts = Timestamp(time() - 10).internal + fresher_ts = Timestamp(time() - 1).internal + suffix_map = { + 'abc': { + '9373a92d072897b136b3fc06595b4abc': [ + fresh_ts + '.ts'], + }, + '456': { + '9373a92d072897b136b3fc06595b0456': [ + old_ts + '#2.data', + old_ts + '.durable'], + '9373a92d072897b136b3fc06595b7456': [ + fresh_ts + '.ts', + fresher_ts + '#2.data', + fresher_ts + '.durable'], + }, + 'def': {}, + } + expected = { + '9373a92d072897b136b3fc06595b0456': old_ts, + '9373a92d072897b136b3fc06595b7456': fresher_ts, + } + self._check_yield_hashes(POLICIES.default, suffix_map, expected, + suffixes=['456'], frag_index=2) + + def test_yield_hashes_skips_missing_durable(self): + ts_iter = (Timestamp(t) for t in itertools.count(int(time()))) + ts1 = next(ts_iter) + suffix_map = { + '456': { + '9373a92d072897b136b3fc06595b0456': [ + ts1.internal + '#2.data', + ts1.internal + '.durable'], + '9373a92d072897b136b3fc06595b7456': [ + ts1.internal + '#2.data'], + }, + } + expected = { + '9373a92d072897b136b3fc06595b0456': ts1, + } + self._check_yield_hashes(POLICIES.default, suffix_map, expected, + frag_index=2) + + # if we add a durable it shows up + suffix_map['456']['9373a92d072897b136b3fc06595b7456'].append( + ts1.internal + '.durable') + expected = { + '9373a92d072897b136b3fc06595b0456': ts1, + '9373a92d072897b136b3fc06595b7456': ts1, + } + self._check_yield_hashes(POLICIES.default, suffix_map, expected, + frag_index=2) + + def test_yield_hashes_skips_data_without_durable(self): + ts_iter = (Timestamp(t) for t in itertools.count(int(time()))) + ts1 = next(ts_iter) + ts2 = next(ts_iter) + ts3 = next(ts_iter) + suffix_map = { + '456': { + '9373a92d072897b136b3fc06595b0456': [ + ts1.internal + '#2.data', + ts1.internal + '.durable', + ts2.internal + '#2.data', + ts3.internal + '#2.data'], + }, + } + expected = { + '9373a92d072897b136b3fc06595b0456': ts1, + } + self._check_yield_hashes(POLICIES.default, suffix_map, expected, + frag_index=None) + self._check_yield_hashes(POLICIES.default, suffix_map, expected, + frag_index=2) + + # if we add a durable then newer data shows up + suffix_map['456']['9373a92d072897b136b3fc06595b0456'].append( + ts2.internal + '.durable') + expected = { + '9373a92d072897b136b3fc06595b0456': ts2, + } + self._check_yield_hashes(POLICIES.default, suffix_map, expected, + frag_index=None) + self._check_yield_hashes(POLICIES.default, suffix_map, expected, + frag_index=2) + + def test_yield_hashes_ignores_bad_ondisk_filesets(self): + # this differs from DiskFileManager.yield_hashes which will fail + # when encountering a bad on-disk file set + ts_iter = (Timestamp(t) for t in itertools.count(int(time()))) + ts1 = next(ts_iter) + ts2 = next(ts_iter) + suffix_map = { + '456': { + '9373a92d072897b136b3fc06595b0456': [ + ts1.internal + '#2.data', + ts1.internal + '.durable'], + '9373a92d072897b136b3fc06595b7456': [ + ts1.internal + '.data'], + '9373a92d072897b136b3fc06595b8456': [ + 'junk_file'], + '9373a92d072897b136b3fc06595b9456': [ + ts1.internal + '.data', + ts2.internal + '.meta'], + '9373a92d072897b136b3fc06595ba456': [ + ts1.internal + '.meta'], + '9373a92d072897b136b3fc06595bb456': [ + ts1.internal + '.meta', + ts2.internal + '.meta'], + }, + } + expected = { + '9373a92d072897b136b3fc06595b0456': ts1, + '9373a92d072897b136b3fc06595ba456': ts1, + '9373a92d072897b136b3fc06595bb456': ts2, + } + self._check_yield_hashes(POLICIES.default, suffix_map, expected, + frag_index=2) + + def test_yield_hashes_filters_frag_index(self): + ts_iter = (Timestamp(t) for t in itertools.count(int(time()))) + ts1 = next(ts_iter) + ts2 = next(ts_iter) + ts3 = next(ts_iter) + suffix_map = { + '27e': { + '1111111111111111111111111111127e': [ + ts1.internal + '#2.data', + ts1.internal + '#3.data', + ts1.internal + '.durable', + ], + '2222222222222222222222222222227e': [ + ts1.internal + '#2.data', + ts1.internal + '.durable', + ts2.internal + '#2.data', + ts2.internal + '.durable', + ], + }, + 'd41': { + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaad41': [ + ts1.internal + '#3.data', + ts1.internal + '.durable', + ], + }, + '00b': { + '3333333333333333333333333333300b': [ + ts1.internal + '#2.data', + ts2.internal + '#2.data', + ts3.internal + '#2.data', + ts3.internal + '.durable', + ], + }, + } + expected = { + '1111111111111111111111111111127e': ts1, + '2222222222222222222222222222227e': ts2, + '3333333333333333333333333333300b': ts3, + } + self._check_yield_hashes(POLICIES.default, suffix_map, expected, + frag_index=2) + + def test_get_diskfile_from_hash_frag_index_filter(self): + df = self._get_diskfile(POLICIES.default) + hash_ = os.path.basename(df._datadir) + self.assertRaises(DiskFileNotExist, + self.df_mgr.get_diskfile_from_hash, + self.existing_device1, '0', hash_, + POLICIES.default) # sanity + frag_index = 7 + timestamp = Timestamp(time()) + for frag_index in (4, 7): + with df.create() as writer: + data = 'test_data' + writer.write(data) + metadata = { + 'ETag': md5(data).hexdigest(), + 'X-Timestamp': timestamp.internal, + 'Content-Length': len(data), + 'X-Object-Sysmeta-Ec-Frag-Index': str(frag_index), + } + writer.put(metadata) + writer.commit(timestamp) + + df4 = self.df_mgr.get_diskfile_from_hash( + self.existing_device1, '0', hash_, POLICIES.default, frag_index=4) + self.assertEqual(df4._frag_index, 4) + self.assertEqual( + df4.read_metadata()['X-Object-Sysmeta-Ec-Frag-Index'], '4') + df7 = self.df_mgr.get_diskfile_from_hash( + self.existing_device1, '0', hash_, POLICIES.default, frag_index=7) + self.assertEqual(df7._frag_index, 7) + self.assertEqual( + df7.read_metadata()['X-Object-Sysmeta-Ec-Frag-Index'], '7') + + +class DiskFileMixin(BaseDiskFileTestMixin): + + # set mgr_cls on subclasses + mgr_cls = None def setUp(self): """Set up for testing swift.obj.diskfile""" @@ -978,12 +1766,22 @@ class TestDiskFile(unittest.TestCase): self.existing_device = 'sda1' for policy in POLICIES: mkdirs(os.path.join(self.testdir, self.existing_device, - get_tmp_dir(policy.idx))) + diskfile.get_tmp_dir(policy))) self._orig_tpool_exc = tpool.execute tpool.execute = lambda f, *args, **kwargs: f(*args, **kwargs) self.conf = dict(devices=self.testdir, mount_check='false', keep_cache_size=2 * 1024, mb_per_sync=1) - self.df_mgr = diskfile.DiskFileManager(self.conf, FakeLogger()) + self.logger = debug_logger('test-' + self.__class__.__name__) + self.df_mgr = self.mgr_cls(self.conf, self.logger) + self.df_router = diskfile.DiskFileRouter(self.conf, self.logger) + self._ts_iter = (Timestamp(t) for t in + itertools.count(int(time()))) + + def ts(self): + """ + Timestamps - forever. + """ + return next(self._ts_iter) def tearDown(self): """Tear down for testing swift.obj.diskfile""" @@ -995,11 +1793,11 @@ class TestDiskFile(unittest.TestCase): mkdirs(df._datadir) if timestamp is None: timestamp = time() - timestamp = Timestamp(timestamp).internal + timestamp = Timestamp(timestamp) if not metadata: metadata = {} if 'X-Timestamp' not in metadata: - metadata['X-Timestamp'] = Timestamp(timestamp).internal + metadata['X-Timestamp'] = timestamp.internal if 'ETag' not in metadata: etag = md5() etag.update(data) @@ -1008,17 +1806,24 @@ class TestDiskFile(unittest.TestCase): metadata['name'] = '/a/c/o' if 'Content-Length' not in metadata: metadata['Content-Length'] = str(len(data)) - data_file = os.path.join(df._datadir, timestamp + ext) + filename = timestamp.internal + ext + if ext == '.data' and df.policy.policy_type == EC_POLICY: + filename = '%s#%s.data' % (timestamp.internal, df._frag_index) + data_file = os.path.join(df._datadir, filename) with open(data_file, 'wb') as f: f.write(data) xattr.setxattr(f.fileno(), diskfile.METADATA_KEY, pickle.dumps(metadata, diskfile.PICKLE_PROTOCOL)) def _simple_get_diskfile(self, partition='0', account='a', container='c', - obj='o', policy_idx=0): - return self.df_mgr.get_diskfile(self.existing_device, - partition, account, container, obj, - policy_idx) + obj='o', policy=None, frag_index=None): + policy = policy or POLICIES.default + df_mgr = self.df_router[policy] + if policy.policy_type == EC_POLICY and frag_index is None: + frag_index = 2 + return df_mgr.get_diskfile(self.existing_device, partition, + account, container, obj, + policy=policy, frag_index=frag_index) def _create_test_file(self, data, timestamp=None, metadata=None, account='a', container='c', obj='o'): @@ -1027,12 +1832,62 @@ class TestDiskFile(unittest.TestCase): metadata.setdefault('name', '/%s/%s/%s' % (account, container, obj)) df = self._simple_get_diskfile(account=account, container=container, obj=obj) - self._create_ondisk_file(df, data, timestamp, metadata) - df = self._simple_get_diskfile(account=account, container=container, - obj=obj) + if timestamp is None: + timestamp = time() + timestamp = Timestamp(timestamp) + with df.create() as writer: + new_metadata = { + 'ETag': md5(data).hexdigest(), + 'X-Timestamp': timestamp.internal, + 'Content-Length': len(data), + } + new_metadata.update(metadata) + writer.write(data) + writer.put(new_metadata) + writer.commit(timestamp) df.open() return df + def test_get_dev_path(self): + self.df_mgr.devices = '/srv' + device = 'sda1' + dev_path = os.path.join(self.df_mgr.devices, device) + + mount_check = None + self.df_mgr.mount_check = True + with mock.patch('swift.obj.diskfile.check_mount', + mock.MagicMock(return_value=False)): + self.assertEqual(self.df_mgr.get_dev_path(device, mount_check), + None) + with mock.patch('swift.obj.diskfile.check_mount', + mock.MagicMock(return_value=True)): + self.assertEqual(self.df_mgr.get_dev_path(device, mount_check), + dev_path) + + self.df_mgr.mount_check = False + with mock.patch('swift.obj.diskfile.check_dir', + mock.MagicMock(return_value=False)): + self.assertEqual(self.df_mgr.get_dev_path(device, mount_check), + None) + with mock.patch('swift.obj.diskfile.check_dir', + mock.MagicMock(return_value=True)): + self.assertEqual(self.df_mgr.get_dev_path(device, mount_check), + dev_path) + + mount_check = True + with mock.patch('swift.obj.diskfile.check_mount', + mock.MagicMock(return_value=False)): + self.assertEqual(self.df_mgr.get_dev_path(device, mount_check), + None) + with mock.patch('swift.obj.diskfile.check_mount', + mock.MagicMock(return_value=True)): + self.assertEqual(self.df_mgr.get_dev_path(device, mount_check), + dev_path) + + mount_check = False + self.assertEqual(self.df_mgr.get_dev_path(device, mount_check), + dev_path) + def test_open_not_exist(self): df = self._simple_get_diskfile() self.assertRaises(DiskFileNotExist, df.open) @@ -1050,15 +1905,17 @@ class TestDiskFile(unittest.TestCase): self.fail("Unexpected swift exception raised: %r" % err) def test_get_metadata(self): - df = self._create_test_file('1234567890', timestamp=42) + timestamp = self.ts().internal + df = self._create_test_file('1234567890', timestamp=timestamp) md = df.get_metadata() - self.assertEqual(md['X-Timestamp'], Timestamp(42).internal) + self.assertEqual(md['X-Timestamp'], timestamp) def test_read_metadata(self): - self._create_test_file('1234567890', timestamp=42) + timestamp = self.ts().internal + self._create_test_file('1234567890', timestamp=timestamp) df = self._simple_get_diskfile() md = df.read_metadata() - self.assertEqual(md['X-Timestamp'], Timestamp(42).internal) + self.assertEqual(md['X-Timestamp'], timestamp) def test_read_metadata_no_xattr(self): def mock_getxattr(*args, **kargs): @@ -1086,15 +1943,16 @@ class TestDiskFile(unittest.TestCase): self.fail("Expected DiskFileNotOpen exception") def test_disk_file_default_disallowed_metadata(self): - # build an object with some meta (ts 41) + # build an object with some meta (at t0+1s) orig_metadata = {'X-Object-Meta-Key1': 'Value1', 'Content-Type': 'text/garbage'} - df = self._get_open_disk_file(ts=41, extra_metadata=orig_metadata) + df = self._get_open_disk_file(ts=self.ts().internal, + extra_metadata=orig_metadata) with df.open(): self.assertEquals('1024', df._metadata['Content-Length']) - # write some new metadata (fast POST, don't send orig meta, ts 42) + # write some new metadata (fast POST, don't send orig meta, at t0+1) df = self._simple_get_diskfile() - df.write_metadata({'X-Timestamp': Timestamp(42).internal, + df.write_metadata({'X-Timestamp': self.ts().internal, 'X-Object-Meta-Key2': 'Value2'}) df = self._simple_get_diskfile() with df.open(): @@ -1106,15 +1964,16 @@ class TestDiskFile(unittest.TestCase): self.assertEquals('Value2', df._metadata['X-Object-Meta-Key2']) def test_disk_file_preserves_sysmeta(self): - # build an object with some meta (ts 41) + # build an object with some meta (at t0) orig_metadata = {'X-Object-Sysmeta-Key1': 'Value1', 'Content-Type': 'text/garbage'} - df = self._get_open_disk_file(ts=41, extra_metadata=orig_metadata) + df = self._get_open_disk_file(ts=self.ts().internal, + extra_metadata=orig_metadata) with df.open(): self.assertEquals('1024', df._metadata['Content-Length']) - # write some new metadata (fast POST, don't send orig meta, ts 42) + # write some new metadata (fast POST, don't send orig meta, at t0+1s) df = self._simple_get_diskfile() - df.write_metadata({'X-Timestamp': Timestamp(42).internal, + df.write_metadata({'X-Timestamp': self.ts().internal, 'X-Object-Sysmeta-Key1': 'Value2', 'X-Object-Meta-Key3': 'Value3'}) df = self._simple_get_diskfile() @@ -1268,34 +2127,38 @@ class TestDiskFile(unittest.TestCase): def test_disk_file_mkstemp_creates_dir(self): for policy in POLICIES: tmpdir = os.path.join(self.testdir, self.existing_device, - get_tmp_dir(policy.idx)) + diskfile.get_tmp_dir(policy)) os.rmdir(tmpdir) - df = self._simple_get_diskfile(policy_idx=policy.idx) + df = self._simple_get_diskfile(policy=policy) with df.create(): self.assert_(os.path.exists(tmpdir)) def _get_open_disk_file(self, invalid_type=None, obj_name='o', fsize=1024, csize=8, mark_deleted=False, prealloc=False, - ts=None, mount_check=False, extra_metadata=None): + ts=None, mount_check=False, extra_metadata=None, + policy=None, frag_index=None): '''returns a DiskFile''' - df = self._simple_get_diskfile(obj=obj_name) + policy = policy or POLICIES.legacy + df = self._simple_get_diskfile(obj=obj_name, policy=policy, + frag_index=frag_index) data = '0' * fsize etag = md5() if ts: - timestamp = ts + timestamp = Timestamp(ts) else: - timestamp = Timestamp(time()).internal + timestamp = Timestamp(time()) if prealloc: prealloc_size = fsize else: prealloc_size = None + with df.create(size=prealloc_size) as writer: upload_size = writer.write(data) etag.update(data) etag = etag.hexdigest() metadata = { 'ETag': etag, - 'X-Timestamp': timestamp, + 'X-Timestamp': timestamp.internal, 'Content-Length': str(upload_size), } metadata.update(extra_metadata or {}) @@ -1318,6 +2181,7 @@ class TestDiskFile(unittest.TestCase): elif invalid_type == 'Bad-X-Delete-At': metadata['X-Delete-At'] = 'bad integer' diskfile.write_metadata(writer._fd, metadata) + writer.commit(timestamp) if mark_deleted: df.delete(timestamp) @@ -1348,9 +2212,16 @@ class TestDiskFile(unittest.TestCase): self.conf['disk_chunk_size'] = csize self.conf['mount_check'] = mount_check - self.df_mgr = diskfile.DiskFileManager(self.conf, FakeLogger()) - df = self._simple_get_diskfile(obj=obj_name) + self.df_mgr = self.mgr_cls(self.conf, self.logger) + self.df_router = diskfile.DiskFileRouter(self.conf, self.logger) + + # actual on disk frag_index may have been set by metadata + frag_index = metadata.get('X-Object-Sysmeta-Ec-Frag-Index', + frag_index) + df = self._simple_get_diskfile(obj=obj_name, policy=policy, + frag_index=frag_index) df.open() + if invalid_type == 'Zero-Byte': fp = open(df._data_file, 'w') fp.close() @@ -1576,7 +2447,7 @@ class TestDiskFile(unittest.TestCase): pass df = self.df_mgr.get_diskfile(self.existing_device, '0', 'abc', '123', - 'xyz') + 'xyz', policy=POLICIES.legacy) self.assertRaises(DiskFileQuarantined, df.open) # make sure the right thing got quarantined; the suffix dir should not @@ -1586,7 +2457,7 @@ class TestDiskFile(unittest.TestCase): def test_create_prealloc(self): df = self.df_mgr.get_diskfile(self.existing_device, '0', 'abc', '123', - 'xyz') + 'xyz', policy=POLICIES.legacy) with mock.patch("swift.obj.diskfile.fallocate") as fa: with df.create(size=200) as writer: used_fd = writer._fd @@ -1594,21 +2465,61 @@ class TestDiskFile(unittest.TestCase): def test_create_prealloc_oserror(self): df = self.df_mgr.get_diskfile(self.existing_device, '0', 'abc', '123', - 'xyz') + 'xyz', policy=POLICIES.legacy) + for e in (errno.ENOSPC, errno.EDQUOT): + with mock.patch("swift.obj.diskfile.fallocate", + mock.MagicMock(side_effect=OSError( + e, os.strerror(e)))): + try: + with df.create(size=200): + pass + except DiskFileNoSpace: + pass + else: + self.fail("Expected exception DiskFileNoSpace") + + # Other OSErrors must not be raised as DiskFileNoSpace with mock.patch("swift.obj.diskfile.fallocate", mock.MagicMock(side_effect=OSError( errno.EACCES, os.strerror(errno.EACCES)))): try: with df.create(size=200): pass - except DiskFileNoSpace: + except OSError: pass else: - self.fail("Expected exception DiskFileNoSpace") + self.fail("Expected exception OSError") + + def test_create_mkstemp_no_space(self): + df = self.df_mgr.get_diskfile(self.existing_device, '0', 'abc', '123', + 'xyz', policy=POLICIES.legacy) + for e in (errno.ENOSPC, errno.EDQUOT): + with mock.patch("swift.obj.diskfile.mkstemp", + mock.MagicMock(side_effect=OSError( + e, os.strerror(e)))): + try: + with df.create(size=200): + pass + except DiskFileNoSpace: + pass + else: + self.fail("Expected exception DiskFileNoSpace") + + # Other OSErrors must not be raised as DiskFileNoSpace + with mock.patch("swift.obj.diskfile.mkstemp", + mock.MagicMock(side_effect=OSError( + errno.EACCES, os.strerror(errno.EACCES)))): + try: + with df.create(size=200): + pass + except OSError: + pass + else: + self.fail("Expected exception OSError") def test_create_close_oserror(self): df = self.df_mgr.get_diskfile(self.existing_device, '0', 'abc', '123', - 'xyz') + 'xyz', policy=POLICIES.legacy) with mock.patch("swift.obj.diskfile.os.close", mock.MagicMock(side_effect=OSError( errno.EACCES, os.strerror(errno.EACCES)))): @@ -1622,11 +2533,12 @@ class TestDiskFile(unittest.TestCase): def test_write_metadata(self): df = self._create_test_file('1234567890') + file_count = len(os.listdir(df._datadir)) timestamp = Timestamp(time()).internal metadata = {'X-Timestamp': timestamp, 'X-Object-Meta-test': 'data'} df.write_metadata(metadata) dl = os.listdir(df._datadir) - self.assertEquals(len(dl), 2) + self.assertEquals(len(dl), file_count + 1) exp_name = '%s.meta' % timestamp self.assertTrue(exp_name in set(dl)) @@ -1664,14 +2576,135 @@ class TestDiskFile(unittest.TestCase): DiskFileNoSpace, diskfile.write_metadata, 'n/a', metadata) + def _create_diskfile_dir(self, timestamp, policy): + timestamp = Timestamp(timestamp) + df = self._simple_get_diskfile(account='a', container='c', + obj='o_%s' % policy, + policy=policy) + + with df.create() as writer: + metadata = { + 'ETag': 'bogus_etag', + 'X-Timestamp': timestamp.internal, + 'Content-Length': '0', + } + if policy.policy_type == EC_POLICY: + metadata['X-Object-Sysmeta-Ec-Frag-Index'] = \ + df._frag_index or 7 + writer.put(metadata) + writer.commit(timestamp) + return writer._datadir + + def test_commit(self): + for policy in POLICIES: + # create first fileset as starting state + timestamp = Timestamp(time()).internal + datadir = self._create_diskfile_dir(timestamp, policy) + dl = os.listdir(datadir) + expected = ['%s.data' % timestamp] + if policy.policy_type == EC_POLICY: + expected = ['%s#2.data' % timestamp, + '%s.durable' % timestamp] + self.assertEquals(len(dl), len(expected), + 'Unexpected dir listing %s' % dl) + self.assertEqual(sorted(expected), sorted(dl)) + + def test_write_cleanup(self): + for policy in POLICIES: + # create first fileset as starting state + timestamp_1 = Timestamp(time()).internal + datadir_1 = self._create_diskfile_dir(timestamp_1, policy) + # second write should clean up first fileset + timestamp_2 = Timestamp(time() + 1).internal + datadir_2 = self._create_diskfile_dir(timestamp_2, policy) + # sanity check + self.assertEqual(datadir_1, datadir_2) + dl = os.listdir(datadir_2) + expected = ['%s.data' % timestamp_2] + if policy.policy_type == EC_POLICY: + expected = ['%s#2.data' % timestamp_2, + '%s.durable' % timestamp_2] + self.assertEquals(len(dl), len(expected), + 'Unexpected dir listing %s' % dl) + self.assertEqual(sorted(expected), sorted(dl)) + + def test_commit_fsync(self): + for policy in POLICIES: + mock_fsync = mock.MagicMock() + df = self._simple_get_diskfile(account='a', container='c', + obj='o', policy=policy) + + timestamp = Timestamp(time()) + with df.create() as writer: + metadata = { + 'ETag': 'bogus_etag', + 'X-Timestamp': timestamp.internal, + 'Content-Length': '0', + } + writer.put(metadata) + with mock.patch('swift.obj.diskfile.fsync', mock_fsync): + writer.commit(timestamp) + expected = { + EC_POLICY: 1, + REPL_POLICY: 0, + }[policy.policy_type] + self.assertEqual(expected, mock_fsync.call_count) + if policy.policy_type == EC_POLICY: + durable_file = '%s.durable' % timestamp.internal + self.assertTrue(durable_file in str(mock_fsync.call_args[0])) + + def test_commit_ignores_hash_cleanup_listdir_error(self): + for policy in POLICIES: + # Check OSError from hash_cleanup_listdir is caught and ignored + mock_hcl = mock.MagicMock(side_effect=OSError) + df = self._simple_get_diskfile(account='a', container='c', + obj='o_hcl_error', policy=policy) + + timestamp = Timestamp(time()) + with df.create() as writer: + metadata = { + 'ETag': 'bogus_etag', + 'X-Timestamp': timestamp.internal, + 'Content-Length': '0', + } + writer.put(metadata) + with mock.patch(self._manager_mock( + 'hash_cleanup_listdir', df), mock_hcl): + writer.commit(timestamp) + expected = { + EC_POLICY: 1, + REPL_POLICY: 0, + }[policy.policy_type] + self.assertEqual(expected, mock_hcl.call_count) + expected = ['%s.data' % timestamp.internal] + if policy.policy_type == EC_POLICY: + expected = ['%s#2.data' % timestamp.internal, + '%s.durable' % timestamp.internal] + dl = os.listdir(df._datadir) + self.assertEquals(len(dl), len(expected), + 'Unexpected dir listing %s' % dl) + self.assertEqual(sorted(expected), sorted(dl)) + def test_delete(self): - df = self._get_open_disk_file() - ts = time() - df.delete(ts) - exp_name = '%s.ts' % Timestamp(ts).internal - dl = os.listdir(df._datadir) - self.assertEquals(len(dl), 1) - self.assertTrue(exp_name in set(dl)) + for policy in POLICIES: + if policy.policy_type == EC_POLICY: + metadata = {'X-Object-Sysmeta-Ec-Frag-Index': '1'} + fi = 1 + else: + metadata = {} + fi = None + df = self._get_open_disk_file(policy=policy, frag_index=fi, + extra_metadata=metadata) + + ts = Timestamp(time()) + df.delete(ts) + exp_name = '%s.ts' % ts.internal + dl = os.listdir(df._datadir) + self.assertEquals(len(dl), 1) + self.assertTrue(exp_name in set(dl), + 'Expected file %s missing in %s' % (exp_name, dl)) + # cleanup before next policy + os.unlink(os.path.join(df._datadir, exp_name)) def test_open_deleted(self): df = self._get_open_disk_file() @@ -1708,7 +2741,8 @@ class TestDiskFile(unittest.TestCase): 'blah blah', account='three', container='blind', obj='mice')._datadir df = self.df_mgr.get_diskfile_from_audit_location( - diskfile.AuditLocation(hashdir, self.existing_device, '0')) + diskfile.AuditLocation(hashdir, self.existing_device, '0', + policy=POLICIES.default)) df.open() self.assertEqual(df._name, '/three/blind/mice') @@ -1716,14 +2750,16 @@ class TestDiskFile(unittest.TestCase): hashdir = self._create_test_file( 'blah blah', account='this', container='is', obj='right')._datadir - - datafile = os.path.join(hashdir, os.listdir(hashdir)[0]) + datafilename = [f for f in os.listdir(hashdir) + if f.endswith('.data')][0] + datafile = os.path.join(hashdir, datafilename) meta = diskfile.read_metadata(datafile) meta['name'] = '/this/is/wrong' diskfile.write_metadata(datafile, meta) df = self.df_mgr.get_diskfile_from_audit_location( - diskfile.AuditLocation(hashdir, self.existing_device, '0')) + diskfile.AuditLocation(hashdir, self.existing_device, '0', + policy=POLICIES.default)) self.assertRaises(DiskFileQuarantined, df.open) def test_close_error(self): @@ -1738,7 +2774,10 @@ class TestDiskFile(unittest.TestCase): pass # close is called at the end of the iterator self.assertEquals(reader._fp, None) - self.assertEquals(len(df._logger.log_dict['error']), 1) + error_lines = df._logger.get_lines_for_level('error') + self.assertEqual(len(error_lines), 1) + self.assertTrue('close failure' in error_lines[0]) + self.assertTrue('Bad' in error_lines[0]) def test_mount_checking(self): @@ -1789,6 +2828,9 @@ class TestDiskFile(unittest.TestCase): self._create_ondisk_file(df, '', ext='.meta', timestamp=9) self._create_ondisk_file(df, 'B', ext='.data', timestamp=8) self._create_ondisk_file(df, 'A', ext='.data', timestamp=7) + if df.policy.policy_type == EC_POLICY: + self._create_ondisk_file(df, '', ext='.durable', timestamp=8) + self._create_ondisk_file(df, '', ext='.durable', timestamp=7) self._create_ondisk_file(df, '', ext='.ts', timestamp=6) self._create_ondisk_file(df, '', ext='.ts', timestamp=5) df = self._simple_get_diskfile() @@ -1802,6 +2844,9 @@ class TestDiskFile(unittest.TestCase): df = self._simple_get_diskfile() self._create_ondisk_file(df, 'B', ext='.data', timestamp=10) self._create_ondisk_file(df, 'A', ext='.data', timestamp=9) + if df.policy.policy_type == EC_POLICY: + self._create_ondisk_file(df, '', ext='.durable', timestamp=10) + self._create_ondisk_file(df, '', ext='.durable', timestamp=9) self._create_ondisk_file(df, '', ext='.ts', timestamp=8) self._create_ondisk_file(df, '', ext='.ts', timestamp=7) self._create_ondisk_file(df, '', ext='.meta', timestamp=6) @@ -1818,6 +2863,9 @@ class TestDiskFile(unittest.TestCase): self._create_ondisk_file(df, 'X', ext='.bar', timestamp=11) self._create_ondisk_file(df, 'B', ext='.data', timestamp=10) self._create_ondisk_file(df, 'A', ext='.data', timestamp=9) + if df.policy.policy_type == EC_POLICY: + self._create_ondisk_file(df, '', ext='.durable', timestamp=10) + self._create_ondisk_file(df, '', ext='.durable', timestamp=9) self._create_ondisk_file(df, '', ext='.ts', timestamp=8) self._create_ondisk_file(df, '', ext='.ts', timestamp=7) self._create_ondisk_file(df, '', ext='.meta', timestamp=6) @@ -1839,6 +2887,9 @@ class TestDiskFile(unittest.TestCase): self._create_ondisk_file(df, 'X', ext='.bar', timestamp=11) self._create_ondisk_file(df, 'B', ext='.data', timestamp=10) self._create_ondisk_file(df, 'A', ext='.data', timestamp=9) + if df.policy.policy_type == EC_POLICY: + self._create_ondisk_file(df, '', ext='.durable', timestamp=10) + self._create_ondisk_file(df, '', ext='.durable', timestamp=9) self._create_ondisk_file(df, '', ext='.ts', timestamp=8) self._create_ondisk_file(df, '', ext='.ts', timestamp=7) self._create_ondisk_file(df, '', ext='.meta', timestamp=6) @@ -1860,300 +2911,6 @@ class TestDiskFile(unittest.TestCase): log_lines = df._logger.get_lines_for_level('error') self.assert_('a very special error' in log_lines[-1]) - def test_get_diskfile_from_hash_dev_path_fail(self): - self.df_mgr.get_dev_path = mock.MagicMock(return_value=None) - with nested( - mock.patch('swift.obj.diskfile.DiskFile'), - mock.patch('swift.obj.diskfile.hash_cleanup_listdir'), - mock.patch('swift.obj.diskfile.read_metadata')) as \ - (dfclass, hclistdir, readmeta): - hclistdir.return_value = ['1381679759.90941.data'] - readmeta.return_value = {'name': '/a/c/o'} - self.assertRaises( - DiskFileDeviceUnavailable, - self.df_mgr.get_diskfile_from_hash, - 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', 0) - - def test_get_diskfile_from_hash_not_dir(self): - self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') - with nested( - mock.patch('swift.obj.diskfile.DiskFile'), - mock.patch('swift.obj.diskfile.hash_cleanup_listdir'), - mock.patch('swift.obj.diskfile.read_metadata'), - mock.patch('swift.obj.diskfile.quarantine_renamer')) as \ - (dfclass, hclistdir, readmeta, quarantine_renamer): - osexc = OSError() - osexc.errno = errno.ENOTDIR - hclistdir.side_effect = osexc - readmeta.return_value = {'name': '/a/c/o'} - self.assertRaises( - DiskFileNotExist, - self.df_mgr.get_diskfile_from_hash, - 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', 0) - quarantine_renamer.assert_called_once_with( - '/srv/dev/', - '/srv/dev/objects/9/900/9a7175077c01a23ade5956b8a2bba900') - - def test_get_diskfile_from_hash_no_dir(self): - self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') - with nested( - mock.patch('swift.obj.diskfile.DiskFile'), - mock.patch('swift.obj.diskfile.hash_cleanup_listdir'), - mock.patch('swift.obj.diskfile.read_metadata')) as \ - (dfclass, hclistdir, readmeta): - osexc = OSError() - osexc.errno = errno.ENOENT - hclistdir.side_effect = osexc - readmeta.return_value = {'name': '/a/c/o'} - self.assertRaises( - DiskFileNotExist, - self.df_mgr.get_diskfile_from_hash, - 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', 0) - - def test_get_diskfile_from_hash_other_oserror(self): - self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') - with nested( - mock.patch('swift.obj.diskfile.DiskFile'), - mock.patch('swift.obj.diskfile.hash_cleanup_listdir'), - mock.patch('swift.obj.diskfile.read_metadata')) as \ - (dfclass, hclistdir, readmeta): - osexc = OSError() - hclistdir.side_effect = osexc - readmeta.return_value = {'name': '/a/c/o'} - self.assertRaises( - OSError, - self.df_mgr.get_diskfile_from_hash, - 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', 0) - - def test_get_diskfile_from_hash_no_actual_files(self): - self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') - with nested( - mock.patch('swift.obj.diskfile.DiskFile'), - mock.patch('swift.obj.diskfile.hash_cleanup_listdir'), - mock.patch('swift.obj.diskfile.read_metadata')) as \ - (dfclass, hclistdir, readmeta): - hclistdir.return_value = [] - readmeta.return_value = {'name': '/a/c/o'} - self.assertRaises( - DiskFileNotExist, - self.df_mgr.get_diskfile_from_hash, - 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', 0) - - def test_get_diskfile_from_hash_read_metadata_problem(self): - self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') - with nested( - mock.patch('swift.obj.diskfile.DiskFile'), - mock.patch('swift.obj.diskfile.hash_cleanup_listdir'), - mock.patch('swift.obj.diskfile.read_metadata')) as \ - (dfclass, hclistdir, readmeta): - hclistdir.return_value = ['1381679759.90941.data'] - readmeta.side_effect = EOFError() - self.assertRaises( - DiskFileNotExist, - self.df_mgr.get_diskfile_from_hash, - 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', 0) - - def test_get_diskfile_from_hash_no_meta_name(self): - self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') - with nested( - mock.patch('swift.obj.diskfile.DiskFile'), - mock.patch('swift.obj.diskfile.hash_cleanup_listdir'), - mock.patch('swift.obj.diskfile.read_metadata')) as \ - (dfclass, hclistdir, readmeta): - hclistdir.return_value = ['1381679759.90941.data'] - readmeta.return_value = {} - try: - self.df_mgr.get_diskfile_from_hash( - 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', 0) - except DiskFileNotExist as err: - exc = err - self.assertEqual(str(exc), '') - - def test_get_diskfile_from_hash_bad_meta_name(self): - self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') - with nested( - mock.patch('swift.obj.diskfile.DiskFile'), - mock.patch('swift.obj.diskfile.hash_cleanup_listdir'), - mock.patch('swift.obj.diskfile.read_metadata')) as \ - (dfclass, hclistdir, readmeta): - hclistdir.return_value = ['1381679759.90941.data'] - readmeta.return_value = {'name': 'bad'} - try: - self.df_mgr.get_diskfile_from_hash( - 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', 0) - except DiskFileNotExist as err: - exc = err - self.assertEqual(str(exc), '') - - def test_get_diskfile_from_hash(self): - self.df_mgr.get_dev_path = mock.MagicMock(return_value='/srv/dev/') - with nested( - mock.patch('swift.obj.diskfile.DiskFile'), - mock.patch('swift.obj.diskfile.hash_cleanup_listdir'), - mock.patch('swift.obj.diskfile.read_metadata')) as \ - (dfclass, hclistdir, readmeta): - hclistdir.return_value = ['1381679759.90941.data'] - readmeta.return_value = {'name': '/a/c/o'} - self.df_mgr.get_diskfile_from_hash( - 'dev', '9', '9a7175077c01a23ade5956b8a2bba900', 0) - dfclass.assert_called_once_with( - self.df_mgr, '/srv/dev/', self.df_mgr.threadpools['dev'], '9', - 'a', 'c', 'o', policy_idx=0) - hclistdir.assert_called_once_with( - '/srv/dev/objects/9/900/9a7175077c01a23ade5956b8a2bba900', - 604800) - readmeta.assert_called_once_with( - '/srv/dev/objects/9/900/9a7175077c01a23ade5956b8a2bba900/' - '1381679759.90941.data') - - def test_listdir_enoent(self): - oserror = OSError() - oserror.errno = errno.ENOENT - self.df_mgr.logger.error = mock.MagicMock() - with mock.patch('os.listdir', side_effect=oserror): - self.assertEqual(self.df_mgr._listdir('path'), []) - self.assertEqual(self.df_mgr.logger.error.mock_calls, []) - - def test_listdir_other_oserror(self): - oserror = OSError() - self.df_mgr.logger.error = mock.MagicMock() - with mock.patch('os.listdir', side_effect=oserror): - self.assertEqual(self.df_mgr._listdir('path'), []) - self.df_mgr.logger.error.assert_called_once_with( - 'ERROR: Skipping %r due to error with listdir attempt: %s', - 'path', oserror) - - def test_listdir(self): - self.df_mgr.logger.error = mock.MagicMock() - with mock.patch('os.listdir', return_value=['abc', 'def']): - self.assertEqual(self.df_mgr._listdir('path'), ['abc', 'def']) - self.assertEqual(self.df_mgr.logger.error.mock_calls, []) - - def test_yield_suffixes_dev_path_fail(self): - self.df_mgr.get_dev_path = mock.MagicMock(return_value=None) - exc = None - try: - list(self.df_mgr.yield_suffixes('dev', '9', 0)) - except DiskFileDeviceUnavailable as err: - exc = err - self.assertEqual(str(exc), '') - - def test_yield_suffixes(self): - self.df_mgr._listdir = mock.MagicMock(return_value=[ - 'abc', 'def', 'ghi', 'abcd', '012']) - self.assertEqual( - list(self.df_mgr.yield_suffixes('dev', '9', 0)), - [(self.testdir + '/dev/objects/9/abc', 'abc'), - (self.testdir + '/dev/objects/9/def', 'def'), - (self.testdir + '/dev/objects/9/012', '012')]) - - def test_yield_hashes_dev_path_fail(self): - self.df_mgr.get_dev_path = mock.MagicMock(return_value=None) - exc = None - try: - list(self.df_mgr.yield_hashes('dev', '9', 0)) - except DiskFileDeviceUnavailable as err: - exc = err - self.assertEqual(str(exc), '') - - def test_yield_hashes_empty(self): - def _listdir(path): - return [] - - with mock.patch('os.listdir', _listdir): - self.assertEqual(list(self.df_mgr.yield_hashes('dev', '9', 0)), []) - - def test_yield_hashes_empty_suffixes(self): - def _listdir(path): - return [] - - with mock.patch('os.listdir', _listdir): - self.assertEqual( - list(self.df_mgr.yield_hashes('dev', '9', 0, - suffixes=['456'])), []) - - def test_yield_hashes(self): - fresh_ts = Timestamp(time() - 10).internal - fresher_ts = Timestamp(time() - 1).internal - - def _listdir(path): - if path.endswith('/dev/objects/9'): - return ['abc', '456', 'def'] - elif path.endswith('/dev/objects/9/abc'): - return ['9373a92d072897b136b3fc06595b4abc'] - elif path.endswith( - '/dev/objects/9/abc/9373a92d072897b136b3fc06595b4abc'): - return [fresh_ts + '.ts'] - elif path.endswith('/dev/objects/9/456'): - return ['9373a92d072897b136b3fc06595b0456', - '9373a92d072897b136b3fc06595b7456'] - elif path.endswith( - '/dev/objects/9/456/9373a92d072897b136b3fc06595b0456'): - return ['1383180000.12345.data'] - elif path.endswith( - '/dev/objects/9/456/9373a92d072897b136b3fc06595b7456'): - return [fresh_ts + '.ts', - fresher_ts + '.data'] - elif path.endswith('/dev/objects/9/def'): - return [] - else: - raise Exception('Unexpected listdir of %r' % path) - - with nested( - mock.patch('os.listdir', _listdir), - mock.patch('os.unlink')): - self.assertEqual( - list(self.df_mgr.yield_hashes('dev', '9', 0)), - [(self.testdir + - '/dev/objects/9/abc/9373a92d072897b136b3fc06595b4abc', - '9373a92d072897b136b3fc06595b4abc', fresh_ts), - (self.testdir + - '/dev/objects/9/456/9373a92d072897b136b3fc06595b0456', - '9373a92d072897b136b3fc06595b0456', '1383180000.12345'), - (self.testdir + - '/dev/objects/9/456/9373a92d072897b136b3fc06595b7456', - '9373a92d072897b136b3fc06595b7456', fresher_ts)]) - - def test_yield_hashes_suffixes(self): - fresh_ts = Timestamp(time() - 10).internal - fresher_ts = Timestamp(time() - 1).internal - - def _listdir(path): - if path.endswith('/dev/objects/9'): - return ['abc', '456', 'def'] - elif path.endswith('/dev/objects/9/abc'): - return ['9373a92d072897b136b3fc06595b4abc'] - elif path.endswith( - '/dev/objects/9/abc/9373a92d072897b136b3fc06595b4abc'): - return [fresh_ts + '.ts'] - elif path.endswith('/dev/objects/9/456'): - return ['9373a92d072897b136b3fc06595b0456', - '9373a92d072897b136b3fc06595b7456'] - elif path.endswith( - '/dev/objects/9/456/9373a92d072897b136b3fc06595b0456'): - return ['1383180000.12345.data'] - elif path.endswith( - '/dev/objects/9/456/9373a92d072897b136b3fc06595b7456'): - return [fresh_ts + '.ts', - fresher_ts + '.data'] - elif path.endswith('/dev/objects/9/def'): - return [] - else: - raise Exception('Unexpected listdir of %r' % path) - - with nested( - mock.patch('os.listdir', _listdir), - mock.patch('os.unlink')): - self.assertEqual( - list(self.df_mgr.yield_hashes( - 'dev', '9', 0, suffixes=['456'])), - [(self.testdir + - '/dev/objects/9/456/9373a92d072897b136b3fc06595b0456', - '9373a92d072897b136b3fc06595b0456', '1383180000.12345'), - (self.testdir + - '/dev/objects/9/456/9373a92d072897b136b3fc06595b7456', - '9373a92d072897b136b3fc06595b7456', fresher_ts)]) - def test_diskfile_names(self): df = self._simple_get_diskfile() self.assertEqual(df.account, 'a') @@ -2219,10 +2976,11 @@ class TestDiskFile(unittest.TestCase): self.assertEqual(str(exc), '') def test_diskfile_timestamp(self): - self._get_open_disk_file(ts='1383181759.12345') + ts = Timestamp(time()) + self._get_open_disk_file(ts=ts.internal) df = self._simple_get_diskfile() with df.open(): - self.assertEqual(df.timestamp, '1383181759.12345') + self.assertEqual(df.timestamp, ts.internal) def test_error_in_hash_cleanup_listdir(self): @@ -2230,16 +2988,16 @@ class TestDiskFile(unittest.TestCase): raise OSError() df = self._get_open_disk_file() + file_count = len(os.listdir(df._datadir)) ts = time() - with mock.patch("swift.obj.diskfile.hash_cleanup_listdir", - mock_hcl): + with mock.patch(self._manager_mock('hash_cleanup_listdir'), mock_hcl): try: df.delete(ts) except OSError: self.fail("OSError raised when it should have been swallowed") exp_name = '%s.ts' % str(Timestamp(ts).internal) dl = os.listdir(df._datadir) - self.assertEquals(len(dl), 2) + self.assertEquals(len(dl), file_count + 1) self.assertTrue(exp_name in set(dl)) def _system_can_zero_copy(self): @@ -2260,7 +3018,6 @@ class TestDiskFile(unittest.TestCase): self.conf['splice'] = 'on' self.conf['keep_cache_size'] = 16384 self.conf['disk_chunk_size'] = 4096 - self.df_mgr = diskfile.DiskFileManager(self.conf, FakeLogger()) df = self._get_open_disk_file(fsize=16385) reader = df.reader() @@ -2274,7 +3031,7 @@ class TestDiskFile(unittest.TestCase): def test_zero_copy_turns_off_when_md5_sockets_not_supported(self): if not self._system_can_zero_copy(): raise SkipTest("zero-copy support is missing") - + df_mgr = self.df_router[POLICIES.default] self.conf['splice'] = 'on' with mock.patch('swift.obj.diskfile.get_md5_socket') as mock_md5sock: mock_md5sock.side_effect = IOError( @@ -2283,7 +3040,7 @@ class TestDiskFile(unittest.TestCase): reader = df.reader() self.assertFalse(reader.can_zero_copy_send()) - log_lines = self.df_mgr.logger.get_lines_for_level('warning') + log_lines = df_mgr.logger.get_lines_for_level('warning') self.assert_('MD5 sockets' in log_lines[-1]) def test_tee_to_md5_pipe_length_mismatch(self): @@ -2380,7 +3137,7 @@ class TestDiskFile(unittest.TestCase): def test_create_unlink_cleanup_DiskFileNoSpace(self): # Test cleanup when DiskFileNoSpace() is raised. df = self.df_mgr.get_diskfile(self.existing_device, '0', 'abc', '123', - 'xyz') + 'xyz', policy=POLICIES.legacy) _m_fallocate = mock.MagicMock(side_effect=OSError(errno.ENOSPC, os.strerror(errno.ENOSPC))) _m_unlink = mock.Mock() @@ -2395,7 +3152,7 @@ class TestDiskFile(unittest.TestCase): self.fail("Expected exception DiskFileNoSpace") self.assertTrue(_m_fallocate.called) self.assertTrue(_m_unlink.called) - self.assert_(len(self.df_mgr.logger.log_dict['exception']) == 0) + self.assertTrue('error' not in self.logger.all_log_lines()) def test_create_unlink_cleanup_renamer_fails(self): # Test cleanup when renamer fails @@ -2422,12 +3179,12 @@ class TestDiskFile(unittest.TestCase): self.assertFalse(writer.put_succeeded) self.assertTrue(_m_renamer.called) self.assertTrue(_m_unlink.called) - self.assert_(len(self.df_mgr.logger.log_dict['exception']) == 0) + self.assertTrue('error' not in self.logger.all_log_lines()) def test_create_unlink_cleanup_logging(self): # Test logging of os.unlink() failures. df = self.df_mgr.get_diskfile(self.existing_device, '0', 'abc', '123', - 'xyz') + 'xyz', policy=POLICIES.legacy) _m_fallocate = mock.MagicMock(side_effect=OSError(errno.ENOSPC, os.strerror(errno.ENOSPC))) _m_unlink = mock.MagicMock(side_effect=OSError(errno.ENOENT, @@ -2443,8 +3200,1633 @@ class TestDiskFile(unittest.TestCase): self.fail("Expected exception DiskFileNoSpace") self.assertTrue(_m_fallocate.called) self.assertTrue(_m_unlink.called) - self.assert_(self.df_mgr.logger.log_dict['exception'][0][0][0]. - startswith("Error removing tempfile:")) + error_lines = self.logger.get_lines_for_level('error') + for line in error_lines: + self.assertTrue(line.startswith("Error removing tempfile:")) + + +@patch_policies(test_policies) +class TestDiskFile(DiskFileMixin, unittest.TestCase): + + mgr_cls = diskfile.DiskFileManager + + +@patch_policies(with_ec_default=True) +class TestECDiskFile(DiskFileMixin, unittest.TestCase): + + mgr_cls = diskfile.ECDiskFileManager + + def test_commit_raises_DiskFileErrors(self): + scenarios = ((errno.ENOSPC, DiskFileNoSpace), + (errno.EDQUOT, DiskFileNoSpace), + (errno.ENOTDIR, DiskFileError), + (errno.EPERM, DiskFileError)) + + # Check IOErrors from open() is handled + for err_number, expected_exception in scenarios: + io_error = IOError() + io_error.errno = err_number + mock_open = mock.MagicMock(side_effect=io_error) + df = self._simple_get_diskfile(account='a', container='c', + obj='o_%s' % err_number, + policy=POLICIES.default) + timestamp = Timestamp(time()) + with df.create() as writer: + metadata = { + 'ETag': 'bogus_etag', + 'X-Timestamp': timestamp.internal, + 'Content-Length': '0', + } + writer.put(metadata) + with mock.patch('__builtin__.open', mock_open): + self.assertRaises(expected_exception, + writer.commit, + timestamp) + dl = os.listdir(df._datadir) + self.assertEqual(1, len(dl), dl) + rmtree(df._datadir) + + # Check OSError from fsync() is handled + mock_fsync = mock.MagicMock(side_effect=OSError) + df = self._simple_get_diskfile(account='a', container='c', + obj='o_fsync_error') + + timestamp = Timestamp(time()) + with df.create() as writer: + metadata = { + 'ETag': 'bogus_etag', + 'X-Timestamp': timestamp.internal, + 'Content-Length': '0', + } + writer.put(metadata) + with mock.patch('swift.obj.diskfile.fsync', mock_fsync): + self.assertRaises(DiskFileError, + writer.commit, timestamp) + + def test_data_file_has_frag_index(self): + policy = POLICIES.default + for good_value in (0, '0', 2, '2', 14, '14'): + # frag_index set by constructor arg + ts = self.ts().internal + expected = ['%s#%s.data' % (ts, good_value), '%s.durable' % ts] + df = self._get_open_disk_file(ts=ts, policy=policy, + frag_index=good_value) + self.assertEqual(expected, sorted(os.listdir(df._datadir))) + # frag index should be added to object sysmeta + actual = df.get_metadata().get('X-Object-Sysmeta-Ec-Frag-Index') + self.assertEqual(int(good_value), int(actual)) + + # metadata value overrides the constructor arg + ts = self.ts().internal + expected = ['%s#%s.data' % (ts, good_value), '%s.durable' % ts] + meta = {'X-Object-Sysmeta-Ec-Frag-Index': good_value} + df = self._get_open_disk_file(ts=ts, policy=policy, + frag_index='99', + extra_metadata=meta) + self.assertEqual(expected, sorted(os.listdir(df._datadir))) + actual = df.get_metadata().get('X-Object-Sysmeta-Ec-Frag-Index') + self.assertEqual(int(good_value), int(actual)) + + # metadata value alone is sufficient + ts = self.ts().internal + expected = ['%s#%s.data' % (ts, good_value), '%s.durable' % ts] + meta = {'X-Object-Sysmeta-Ec-Frag-Index': good_value} + df = self._get_open_disk_file(ts=ts, policy=policy, + frag_index=None, + extra_metadata=meta) + self.assertEqual(expected, sorted(os.listdir(df._datadir))) + actual = df.get_metadata().get('X-Object-Sysmeta-Ec-Frag-Index') + self.assertEqual(int(good_value), int(actual)) + + def test_sysmeta_frag_index_is_immutable(self): + # the X-Object-Sysmeta-Ec-Frag-Index should *only* be set when + # the .data file is written. + policy = POLICIES.default + orig_frag_index = 14 + # frag_index set by constructor arg + ts = self.ts().internal + expected = ['%s#%s.data' % (ts, orig_frag_index), '%s.durable' % ts] + df = self._get_open_disk_file(ts=ts, policy=policy, obj_name='my_obj', + frag_index=orig_frag_index) + self.assertEqual(expected, sorted(os.listdir(df._datadir))) + # frag index should be added to object sysmeta + actual = df.get_metadata().get('X-Object-Sysmeta-Ec-Frag-Index') + self.assertEqual(int(orig_frag_index), int(actual)) + + # open the same diskfile with no frag_index passed to constructor + df = self.df_router[policy].get_diskfile( + self.existing_device, 0, 'a', 'c', 'my_obj', policy=policy, + frag_index=None) + df.open() + actual = df.get_metadata().get('X-Object-Sysmeta-Ec-Frag-Index') + self.assertEqual(int(orig_frag_index), int(actual)) + + # write metadata to a meta file + ts = self.ts().internal + metadata = {'X-Timestamp': ts, + 'X-Object-Meta-Fruit': 'kiwi'} + df.write_metadata(metadata) + # sanity check we did write a meta file + expected.append('%s.meta' % ts) + actual_files = sorted(os.listdir(df._datadir)) + self.assertEqual(expected, actual_files) + + # open the same diskfile, check frag index is unchanged + df = self.df_router[policy].get_diskfile( + self.existing_device, 0, 'a', 'c', 'my_obj', policy=policy, + frag_index=None) + df.open() + # sanity check we have read the meta file + self.assertEqual(ts, df.get_metadata().get('X-Timestamp')) + self.assertEqual('kiwi', df.get_metadata().get('X-Object-Meta-Fruit')) + # check frag index sysmeta is unchanged + actual = df.get_metadata().get('X-Object-Sysmeta-Ec-Frag-Index') + self.assertEqual(int(orig_frag_index), int(actual)) + + # attempt to overwrite frag index sysmeta + ts = self.ts().internal + metadata = {'X-Timestamp': ts, + 'X-Object-Sysmeta-Ec-Frag-Index': 99, + 'X-Object-Meta-Fruit': 'apple'} + df.write_metadata(metadata) + + # open the same diskfile, check frag index is unchanged + df = self.df_router[policy].get_diskfile( + self.existing_device, 0, 'a', 'c', 'my_obj', policy=policy, + frag_index=None) + df.open() + # sanity check we have read the meta file + self.assertEqual(ts, df.get_metadata().get('X-Timestamp')) + self.assertEqual('apple', df.get_metadata().get('X-Object-Meta-Fruit')) + actual = df.get_metadata().get('X-Object-Sysmeta-Ec-Frag-Index') + self.assertEqual(int(orig_frag_index), int(actual)) + + def test_data_file_errors_bad_frag_index(self): + policy = POLICIES.default + df_mgr = self.df_router[policy] + for bad_value in ('foo', '-2', -2, '3.14', 3.14): + # check that bad frag_index set by constructor arg raises error + # as soon as diskfile is constructed, before data is written + self.assertRaises(DiskFileError, self._simple_get_diskfile, + policy=policy, frag_index=bad_value) + + # bad frag_index set by metadata value + # (drive-by check that it is ok for constructor arg to be None) + df = df_mgr.get_diskfile(self.existing_device, '0', 'a', 'c', 'o', + policy=policy, frag_index=None) + ts = self.ts() + meta = {'X-Object-Sysmeta-Ec-Frag-Index': bad_value, + 'X-Timestamp': ts.internal, + 'Content-Length': 0, + 'Etag': EMPTY_ETAG, + 'Content-Type': 'plain/text'} + with df.create() as writer: + try: + writer.put(meta) + self.fail('Expected DiskFileError for frag_index %s' + % bad_value) + except DiskFileError: + pass + + # bad frag_index set by metadata value overrides ok constructor arg + df = df_mgr.get_diskfile(self.existing_device, '0', 'a', 'c', 'o', + policy=policy, frag_index=2) + ts = self.ts() + meta = {'X-Object-Sysmeta-Ec-Frag-Index': bad_value, + 'X-Timestamp': ts.internal, + 'Content-Length': 0, + 'Etag': EMPTY_ETAG, + 'Content-Type': 'plain/text'} + with df.create() as writer: + try: + writer.put(meta) + self.fail('Expected DiskFileError for frag_index %s' + % bad_value) + except DiskFileError: + pass + + def test_purge_one_fragment_index(self): + ts = self.ts() + for frag_index in (1, 2): + df = self._simple_get_diskfile(frag_index=frag_index) + with df.create() as writer: + data = 'test data' + writer.write(data) + metadata = { + 'ETag': md5(data).hexdigest(), + 'X-Timestamp': ts.internal, + 'Content-Length': len(data), + } + writer.put(metadata) + writer.commit(ts) + + # sanity + self.assertEqual(sorted(os.listdir(df._datadir)), [ + ts.internal + '#1.data', + ts.internal + '#2.data', + ts.internal + '.durable', + ]) + df.purge(ts, 2) + self.assertEqual(sorted(os.listdir(df._datadir)), [ + ts.internal + '#1.data', + ts.internal + '.durable', + ]) + + def test_purge_last_fragment_index(self): + ts = self.ts() + frag_index = 0 + df = self._simple_get_diskfile(frag_index=frag_index) + with df.create() as writer: + data = 'test data' + writer.write(data) + metadata = { + 'ETag': md5(data).hexdigest(), + 'X-Timestamp': ts.internal, + 'Content-Length': len(data), + } + writer.put(metadata) + writer.commit(ts) + + # sanity + self.assertEqual(sorted(os.listdir(df._datadir)), [ + ts.internal + '#0.data', + ts.internal + '.durable', + ]) + df.purge(ts, 0) + self.assertEqual(sorted(os.listdir(df._datadir)), [ + ts.internal + '.durable', + ]) + + def test_purge_non_existant_fragment_index(self): + ts = self.ts() + frag_index = 7 + df = self._simple_get_diskfile(frag_index=frag_index) + with df.create() as writer: + data = 'test data' + writer.write(data) + metadata = { + 'ETag': md5(data).hexdigest(), + 'X-Timestamp': ts.internal, + 'Content-Length': len(data), + } + writer.put(metadata) + writer.commit(ts) + + # sanity + self.assertEqual(sorted(os.listdir(df._datadir)), [ + ts.internal + '#7.data', + ts.internal + '.durable', + ]) + df.purge(ts, 3) + # no effect + self.assertEqual(sorted(os.listdir(df._datadir)), [ + ts.internal + '#7.data', + ts.internal + '.durable', + ]) + + def test_purge_old_timestamp_frag_index(self): + old_ts = self.ts() + ts = self.ts() + frag_index = 1 + df = self._simple_get_diskfile(frag_index=frag_index) + with df.create() as writer: + data = 'test data' + writer.write(data) + metadata = { + 'ETag': md5(data).hexdigest(), + 'X-Timestamp': ts.internal, + 'Content-Length': len(data), + } + writer.put(metadata) + writer.commit(ts) + + # sanity + self.assertEqual(sorted(os.listdir(df._datadir)), [ + ts.internal + '#1.data', + ts.internal + '.durable', + ]) + df.purge(old_ts, 1) + # no effect + self.assertEqual(sorted(os.listdir(df._datadir)), [ + ts.internal + '#1.data', + ts.internal + '.durable', + ]) + + def test_purge_tombstone(self): + ts = self.ts() + df = self._simple_get_diskfile(frag_index=3) + df.delete(ts) + + # sanity + self.assertEqual(sorted(os.listdir(df._datadir)), [ + ts.internal + '.ts', + ]) + df.purge(ts, 3) + self.assertEqual(sorted(os.listdir(df._datadir)), []) + + def test_purge_old_tombstone(self): + old_ts = self.ts() + ts = self.ts() + df = self._simple_get_diskfile(frag_index=5) + df.delete(ts) + + # sanity + self.assertEqual(sorted(os.listdir(df._datadir)), [ + ts.internal + '.ts', + ]) + df.purge(old_ts, 5) + # no effect + self.assertEqual(sorted(os.listdir(df._datadir)), [ + ts.internal + '.ts', + ]) + + def test_purge_already_removed(self): + df = self._simple_get_diskfile(frag_index=6) + + df.purge(self.ts(), 6) # no errors + + # sanity + os.makedirs(df._datadir) + self.assertEqual(sorted(os.listdir(df._datadir)), []) + df.purge(self.ts(), 6) + # no effect + self.assertEqual(sorted(os.listdir(df._datadir)), []) + + def test_open_most_recent_durable(self): + policy = POLICIES.default + df_mgr = self.df_router[policy] + + df = df_mgr.get_diskfile(self.existing_device, '0', + 'a', 'c', 'o', policy=policy) + + ts = self.ts() + with df.create() as writer: + data = 'test data' + writer.write(data) + metadata = { + 'ETag': md5(data).hexdigest(), + 'X-Timestamp': ts.internal, + 'Content-Length': len(data), + 'X-Object-Sysmeta-Ec-Frag-Index': 3, + } + writer.put(metadata) + writer.commit(ts) + + # add some .meta stuff + extra_meta = { + 'X-Object-Meta-Foo': 'Bar', + 'X-Timestamp': self.ts().internal, + } + df = df_mgr.get_diskfile(self.existing_device, '0', + 'a', 'c', 'o', policy=policy) + df.write_metadata(extra_meta) + + # sanity + df = df_mgr.get_diskfile(self.existing_device, '0', + 'a', 'c', 'o', policy=policy) + metadata.update(extra_meta) + self.assertEqual(metadata, df.read_metadata()) + + # add a newer datafile + df = df_mgr.get_diskfile(self.existing_device, '0', + 'a', 'c', 'o', policy=policy) + ts = self.ts() + with df.create() as writer: + data = 'test data' + writer.write(data) + new_metadata = { + 'ETag': md5(data).hexdigest(), + 'X-Timestamp': ts.internal, + 'Content-Length': len(data), + 'X-Object-Sysmeta-Ec-Frag-Index': 3, + } + writer.put(new_metadata) + # N.B. don't make it durable + + # and we still get the old metadata (same as if no .data!) + df = df_mgr.get_diskfile(self.existing_device, '0', + 'a', 'c', 'o', policy=policy) + self.assertEqual(metadata, df.read_metadata()) + + def test_open_most_recent_missing_durable(self): + policy = POLICIES.default + df_mgr = self.df_router[policy] + + df = df_mgr.get_diskfile(self.existing_device, '0', + 'a', 'c', 'o', policy=policy) + + self.assertRaises(DiskFileNotExist, df.read_metadata) + + # now create a datafile missing durable + ts = self.ts() + with df.create() as writer: + data = 'test data' + writer.write(data) + new_metadata = { + 'ETag': md5(data).hexdigest(), + 'X-Timestamp': ts.internal, + 'Content-Length': len(data), + 'X-Object-Sysmeta-Ec-Frag-Index': 3, + } + writer.put(new_metadata) + # N.B. don't make it durable + + # add some .meta stuff + extra_meta = { + 'X-Object-Meta-Foo': 'Bar', + 'X-Timestamp': self.ts().internal, + } + df = df_mgr.get_diskfile(self.existing_device, '0', + 'a', 'c', 'o', policy=policy) + df.write_metadata(extra_meta) + + # we still get the DiskFileNotExist (same as if no .data!) + df = df_mgr.get_diskfile(self.existing_device, '0', + 'a', 'c', 'o', policy=policy, + frag_index=3) + self.assertRaises(DiskFileNotExist, df.read_metadata) + + # sanity, withtout the frag_index kwarg + df = df_mgr.get_diskfile(self.existing_device, '0', + 'a', 'c', 'o', policy=policy) + self.assertRaises(DiskFileNotExist, df.read_metadata) + + +@patch_policies(with_ec_default=True) +class TestSuffixHashes(unittest.TestCase): + """ + This tests all things related to hashing suffixes and therefore + there's also few test methods for hash_cleanup_listdir as well + (because it's used by hash_suffix). + + The public interface to suffix hashing is on the Manager:: + + * hash_cleanup_listdir(hsh_path) + * get_hashes(device, partition, suffixes, policy) + * invalidate_hash(suffix_dir) + + The Manager.get_hashes method (used by the REPLICATION verb) + calls Manager._get_hashes (which may be an alias to the module + method get_hashes), which calls hash_suffix, which calls + hash_cleanup_listdir. + + Outside of that, hash_cleanup_listdir and invalidate_hash are + used mostly after writing new files via PUT or DELETE. + + Test methods are organized by:: + + * hash_cleanup_listdir tests - behaviors + * hash_cleanup_listdir tests - error handling + * invalidate_hash tests - behavior + * invalidate_hash tests - error handling + * get_hashes tests - hash_suffix behaviors + * get_hashes tests - hash_suffix error handling + * get_hashes tests - behaviors + * get_hashes tests - error handling + + """ + + def setUp(self): + self.testdir = tempfile.mkdtemp() + self.logger = debug_logger('suffix-hash-test') + self.devices = os.path.join(self.testdir, 'node') + os.mkdir(self.devices) + self.existing_device = 'sda1' + os.mkdir(os.path.join(self.devices, self.existing_device)) + self.conf = { + 'swift_dir': self.testdir, + 'devices': self.devices, + 'mount_check': False, + } + self.df_router = diskfile.DiskFileRouter(self.conf, self.logger) + self._ts_iter = (Timestamp(t) for t in + itertools.count(int(time()))) + self.policy = None + + def ts(self): + """ + Timestamps - forever. + """ + return next(self._ts_iter) + + def fname_to_ts_hash(self, fname): + """ + EC datafiles are only hashed by their timestamp + """ + return md5(fname.split('#', 1)[0]).hexdigest() + + def tearDown(self): + rmtree(self.testdir, ignore_errors=1) + + def iter_policies(self): + for policy in POLICIES: + self.policy = policy + yield policy + + def assertEqual(self, *args): + try: + unittest.TestCase.assertEqual(self, *args) + except AssertionError as err: + if not self.policy: + raise + policy_trailer = '\n\n... for policy %r' % self.policy + raise AssertionError(str(err) + policy_trailer) + + def _datafilename(self, timestamp, policy, frag_index=None): + if frag_index is None: + frag_index = randint(0, 9) + filename = timestamp.internal + if policy.policy_type == EC_POLICY: + filename += '#%d' % frag_index + filename += '.data' + return filename + + def check_hash_cleanup_listdir(self, policy, input_files, output_files): + orig_unlink = os.unlink + file_list = list(input_files) + + def mock_listdir(path): + return list(file_list) + + def mock_unlink(path): + # timestamp 1 is a special tag to pretend a file disappeared + # between the listdir and unlink. + if '/0000000001.00000.' in path: + # Using actual os.unlink for a non-existent name to reproduce + # exactly what OSError it raises in order to prove that + # common.utils.remove_file is squelching the error - but any + # OSError would do. + orig_unlink(uuid.uuid4().hex) + file_list.remove(os.path.basename(path)) + + df_mgr = self.df_router[policy] + with unit_mock({'os.listdir': mock_listdir, 'os.unlink': mock_unlink}): + if isinstance(output_files, Exception): + path = os.path.join(self.testdir, 'does-not-matter') + self.assertRaises(output_files.__class__, + df_mgr.hash_cleanup_listdir, path) + return + files = df_mgr.hash_cleanup_listdir('/whatever') + self.assertEquals(files, output_files) + + # hash_cleanup_listdir tests - behaviors + + def test_hash_cleanup_listdir_purge_data_newer_ts(self): + for policy in self.iter_policies(): + # purge .data if there's a newer .ts + file1 = self._datafilename(self.ts(), policy) + file2 = self.ts().internal + '.ts' + file_list = [file1, file2] + self.check_hash_cleanup_listdir(policy, file_list, [file2]) + + def test_hash_cleanup_listdir_purge_expired_ts(self): + for policy in self.iter_policies(): + # purge older .ts files if there's a newer .data + file1 = self.ts().internal + '.ts' + file2 = self.ts().internal + '.ts' + timestamp = self.ts() + file3 = self._datafilename(timestamp, policy) + file_list = [file1, file2, file3] + expected = { + # no durable datafile means you can't get rid of the + # latest tombstone even if datafile is newer + EC_POLICY: [file3, file2], + REPL_POLICY: [file3], + }[policy.policy_type] + self.check_hash_cleanup_listdir(policy, file_list, expected) + + def test_hash_cleanup_listdir_purge_ts_newer_data(self): + for policy in self.iter_policies(): + # purge .ts if there's a newer .data + file1 = self.ts().internal + '.ts' + timestamp = self.ts() + file2 = self._datafilename(timestamp, policy) + file_list = [file1, file2] + if policy.policy_type == EC_POLICY: + durable_file = timestamp.internal + '.durable' + file_list.append(durable_file) + expected = { + EC_POLICY: [durable_file, file2], + REPL_POLICY: [file2], + }[policy.policy_type] + self.check_hash_cleanup_listdir(policy, file_list, expected) + + def test_hash_cleanup_listdir_purge_older_ts(self): + for policy in self.iter_policies(): + file1 = self.ts().internal + '.ts' + file2 = self.ts().internal + '.ts' + file3 = self._datafilename(self.ts(), policy) + file4 = self.ts().internal + '.meta' + expected = { + # no durable means we can only throw out things before + # the latest tombstone + EC_POLICY: [file4, file3, file2], + # keep .meta and .data and purge all .ts files + REPL_POLICY: [file4, file3], + }[policy.policy_type] + file_list = [file1, file2, file3, file4] + self.check_hash_cleanup_listdir(policy, file_list, expected) + + def test_hash_cleanup_listdir_keep_meta_data_purge_ts(self): + for policy in self.iter_policies(): + file1 = self.ts().internal + '.ts' + file2 = self.ts().internal + '.ts' + timestamp = self.ts() + file3 = self._datafilename(timestamp, policy) + file_list = [file1, file2, file3] + if policy.policy_type == EC_POLICY: + durable_filename = timestamp.internal + '.durable' + file_list.append(durable_filename) + file4 = self.ts().internal + '.meta' + file_list.append(file4) + # keep .meta and .data if meta newer than data and purge .ts + expected = { + EC_POLICY: [file4, durable_filename, file3], + REPL_POLICY: [file4, file3], + }[policy.policy_type] + self.check_hash_cleanup_listdir(policy, file_list, expected) + + def test_hash_cleanup_listdir_keep_one_ts(self): + for policy in self.iter_policies(): + file1, file2, file3 = [self.ts().internal + '.ts' + for i in range(3)] + file_list = [file1, file2, file3] + # keep only latest of multiple .ts files + self.check_hash_cleanup_listdir(policy, file_list, [file3]) + + def test_hash_cleanup_listdir_multi_data_file(self): + for policy in self.iter_policies(): + file1 = self._datafilename(self.ts(), policy, 1) + file2 = self._datafilename(self.ts(), policy, 2) + file3 = self._datafilename(self.ts(), policy, 3) + expected = { + # keep all non-durable datafiles + EC_POLICY: [file3, file2, file1], + # keep only latest of multiple .data files + REPL_POLICY: [file3] + }[policy.policy_type] + file_list = [file1, file2, file3] + self.check_hash_cleanup_listdir(policy, file_list, expected) + + def test_hash_cleanup_listdir_keeps_one_datafile(self): + for policy in self.iter_policies(): + timestamps = [self.ts() for i in range(3)] + file1 = self._datafilename(timestamps[0], policy, 1) + file2 = self._datafilename(timestamps[1], policy, 2) + file3 = self._datafilename(timestamps[2], policy, 3) + file_list = [file1, file2, file3] + if policy.policy_type == EC_POLICY: + for t in timestamps: + file_list.append(t.internal + '.durable') + latest_durable = file_list[-1] + expected = { + # keep latest durable and datafile + EC_POLICY: [latest_durable, file3], + # keep only latest of multiple .data files + REPL_POLICY: [file3] + }[policy.policy_type] + self.check_hash_cleanup_listdir(policy, file_list, expected) + + def test_hash_cleanup_listdir_keep_one_meta(self): + for policy in self.iter_policies(): + # keep only latest of multiple .meta files + t_data = self.ts() + file1 = self._datafilename(t_data, policy) + file2, file3 = [self.ts().internal + '.meta' for i in range(2)] + file_list = [file1, file2, file3] + if policy.policy_type == EC_POLICY: + durable_file = t_data.internal + '.durable' + file_list.append(durable_file) + expected = { + EC_POLICY: [file3, durable_file, file1], + REPL_POLICY: [file3, file1] + }[policy.policy_type] + self.check_hash_cleanup_listdir(policy, file_list, expected) + + def test_hash_cleanup_listdir_only_meta(self): + for policy in self.iter_policies(): + file1, file2 = [self.ts().internal + '.meta' for i in range(2)] + file_list = [file1, file2] + if policy.policy_type == EC_POLICY: + # EC policy does tolerate only .meta's in dir when cleaning up + expected = [file2] + else: + # the get_ondisk_files contract validation doesn't allow a + # directory with only .meta files + expected = AssertionError() + self.check_hash_cleanup_listdir(policy, file_list, expected) + + def test_hash_cleanup_listdir_ignore_orphaned_ts(self): + for policy in self.iter_policies(): + # A more recent orphaned .meta file will prevent old .ts files + # from being cleaned up otherwise + file1, file2 = [self.ts().internal + '.ts' for i in range(2)] + file3 = self.ts().internal + '.meta' + file_list = [file1, file2, file3] + self.check_hash_cleanup_listdir(policy, file_list, [file3, file2]) + + def test_hash_cleanup_listdir_purge_old_data_only(self): + for policy in self.iter_policies(): + # Oldest .data will be purge, .meta and .ts won't be touched + file1 = self._datafilename(self.ts(), policy) + file2 = self.ts().internal + '.ts' + file3 = self.ts().internal + '.meta' + file_list = [file1, file2, file3] + self.check_hash_cleanup_listdir(policy, file_list, [file3, file2]) + + def test_hash_cleanup_listdir_purge_old_ts(self): + for policy in self.iter_policies(): + # A single old .ts file will be removed + old_float = time() - (diskfile.ONE_WEEK + 1) + file1 = Timestamp(old_float).internal + '.ts' + file_list = [file1] + self.check_hash_cleanup_listdir(policy, file_list, []) + + def test_hash_cleanup_listdir_meta_keeps_old_ts(self): + for policy in self.iter_policies(): + old_float = time() - (diskfile.ONE_WEEK + 1) + file1 = Timestamp(old_float).internal + '.ts' + file2 = Timestamp(time() + 2).internal + '.meta' + file_list = [file1, file2] + if policy.policy_type == EC_POLICY: + # EC will clean up old .ts despite a .meta + expected = [file2] + else: + # An orphaned .meta will not clean up a very old .ts + expected = [file2, file1] + self.check_hash_cleanup_listdir(policy, file_list, expected) + + def test_hash_cleanup_listdir_keep_single_old_data(self): + for policy in self.iter_policies(): + old_float = time() - (diskfile.ONE_WEEK + 1) + file1 = self._datafilename(Timestamp(old_float), policy) + file_list = [file1] + if policy.policy_type == EC_POLICY: + # for EC an isolated old .data file is removed, its useless + # without a .durable + expected = [] + else: + # A single old .data file will not be removed + expected = file_list + self.check_hash_cleanup_listdir(policy, file_list, expected) + + def test_hash_cleanup_listdir_drops_isolated_durable(self): + for policy in self.iter_policies(): + if policy.policy_type == EC_POLICY: + file1 = Timestamp(time()).internal + '.durable' + file_list = [file1] + self.check_hash_cleanup_listdir(policy, file_list, []) + + def test_hash_cleanup_listdir_keep_single_old_meta(self): + for policy in self.iter_policies(): + # A single old .meta file will not be removed + old_float = time() - (diskfile.ONE_WEEK + 1) + file1 = Timestamp(old_float).internal + '.meta' + file_list = [file1] + self.check_hash_cleanup_listdir(policy, file_list, [file1]) + + # hash_cleanup_listdir tests - error handling + + def test_hash_cleanup_listdir_hsh_path_enoent(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + # common.utils.listdir *completely* mutes ENOENT + path = os.path.join(self.testdir, 'does-not-exist') + self.assertEqual(df_mgr.hash_cleanup_listdir(path), []) + + def test_hash_cleanup_listdir_hsh_path_other_oserror(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + with mock.patch('os.listdir') as mock_listdir: + mock_listdir.side_effect = OSError('kaboom!') + # but it will raise other OSErrors + path = os.path.join(self.testdir, 'does-not-matter') + self.assertRaises(OSError, df_mgr.hash_cleanup_listdir, + path) + + def test_hash_cleanup_listdir_reclaim_tombstone_remove_file_error(self): + for policy in self.iter_policies(): + # Timestamp 1 makes the check routine pretend the file + # disappeared after listdir before unlink. + file1 = '0000000001.00000.ts' + file_list = [file1] + self.check_hash_cleanup_listdir(policy, file_list, []) + + def test_hash_cleanup_listdir_older_remove_file_error(self): + for policy in self.iter_policies(): + # Timestamp 1 makes the check routine pretend the file + # disappeared after listdir before unlink. + file1 = self._datafilename(Timestamp(1), policy) + file2 = '0000000002.00000.ts' + file_list = [file1, file2] + if policy.policy_type == EC_POLICY: + # the .ts gets reclaimed up despite failed .data delete + expected = [] + else: + # the .ts isn't reclaimed because there were two files in dir + expected = [file2] + self.check_hash_cleanup_listdir(policy, file_list, expected) + + # invalidate_hash tests - behavior + + def test_invalidate_hash_file_does_not_exist(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + df = df_mgr.get_diskfile('sda1', '0', 'a', 'c', 'o', + policy=policy) + suffix_dir = os.path.dirname(df._datadir) + part_path = os.path.join(self.devices, 'sda1', + diskfile.get_data_dir(policy), '0') + hashes_file = os.path.join(part_path, diskfile.HASH_FILE) + self.assertFalse(os.path.exists(hashes_file)) # sanity + with mock.patch('swift.obj.diskfile.lock_path') as mock_lock: + df_mgr.invalidate_hash(suffix_dir) + self.assertFalse(mock_lock.called) + # does not create file + self.assertFalse(os.path.exists(hashes_file)) + + def test_invalidate_hash_file_exists(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + # create something to hash + df = df_mgr.get_diskfile('sda1', '0', 'a', 'c', 'o', + policy=policy) + df.delete(self.ts()) + suffix_dir = os.path.dirname(df._datadir) + suffix = os.path.basename(suffix_dir) + hashes = df_mgr.get_hashes('sda1', '0', [], policy) + self.assertTrue(suffix in hashes) # sanity + # sanity check hashes file + part_path = os.path.join(self.devices, 'sda1', + diskfile.get_data_dir(policy), '0') + hashes_file = os.path.join(part_path, diskfile.HASH_FILE) + with open(hashes_file, 'rb') as f: + self.assertEqual(hashes, pickle.load(f)) + # invalidate the hash + with mock.patch('swift.obj.diskfile.lock_path') as mock_lock: + df_mgr.invalidate_hash(suffix_dir) + self.assertTrue(mock_lock.called) + with open(hashes_file, 'rb') as f: + self.assertEqual({suffix: None}, pickle.load(f)) + + # invalidate_hash tests - error handling + + def test_invalidate_hash_bad_pickle(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + # make some valid data + df = df_mgr.get_diskfile('sda1', '0', 'a', 'c', 'o', + policy=policy) + suffix_dir = os.path.dirname(df._datadir) + suffix = os.path.basename(suffix_dir) + df.delete(self.ts()) + # sanity check hashes file + part_path = os.path.join(self.devices, 'sda1', + diskfile.get_data_dir(policy), '0') + hashes_file = os.path.join(part_path, diskfile.HASH_FILE) + self.assertFalse(os.path.exists(hashes_file)) + # write some garbage in hashes file + with open(hashes_file, 'w') as f: + f.write('asdf') + # invalidate_hash silently *NOT* repair invalid data + df_mgr.invalidate_hash(suffix_dir) + with open(hashes_file) as f: + self.assertEqual(f.read(), 'asdf') + # ... but get_hashes will + hashes = df_mgr.get_hashes('sda1', '0', [], policy) + self.assertTrue(suffix in hashes) + + # get_hashes tests - hash_suffix behaviors + + def test_hash_suffix_one_tombstone(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + df = df_mgr.get_diskfile( + 'sda1', '0', 'a', 'c', 'o', policy=policy) + suffix = os.path.basename(os.path.dirname(df._datadir)) + # write a tombstone + timestamp = self.ts() + df.delete(timestamp) + tombstone_hash = md5(timestamp.internal + '.ts').hexdigest() + hashes = df_mgr.get_hashes('sda1', '0', [], policy) + expected = { + REPL_POLICY: {suffix: tombstone_hash}, + EC_POLICY: {suffix: { + # fi is None here because we have a tombstone + None: tombstone_hash}}, + }[policy.policy_type] + self.assertEqual(hashes, expected) + + def test_hash_suffix_one_reclaim_tombstone(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + df = df_mgr.get_diskfile( + 'sda1', '0', 'a', 'c', 'o', policy=policy) + suffix = os.path.basename(os.path.dirname(df._datadir)) + # scale back this tests manager's reclaim age a bit + df_mgr.reclaim_age = 1000 + # write a tombstone that's just a *little* older + old_time = time() - 1001 + timestamp = Timestamp(old_time) + df.delete(timestamp.internal) + tombstone_hash = md5(timestamp.internal + '.ts').hexdigest() + hashes = df_mgr.get_hashes('sda1', '0', [], policy) + expected = { + # repl is broken, it doesn't use self.reclaim_age + REPL_POLICY: tombstone_hash, + EC_POLICY: {}, + }[policy.policy_type] + self.assertEqual(hashes, {suffix: expected}) + + def test_hash_suffix_one_datafile(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + df = df_mgr.get_diskfile( + 'sda1', '0', 'a', 'c', 'o', policy=policy, frag_index=7) + suffix = os.path.basename(os.path.dirname(df._datadir)) + # write a datafile + timestamp = self.ts() + with df.create() as writer: + test_data = 'test file' + writer.write(test_data) + metadata = { + 'X-Timestamp': timestamp.internal, + 'ETag': md5(test_data).hexdigest(), + 'Content-Length': len(test_data), + } + writer.put(metadata) + hashes = df_mgr.get_hashes('sda1', '0', [], policy) + datafile_hash = md5({ + EC_POLICY: timestamp.internal, + REPL_POLICY: timestamp.internal + '.data', + }[policy.policy_type]).hexdigest() + expected = { + REPL_POLICY: {suffix: datafile_hash}, + EC_POLICY: {suffix: { + # because there's no .durable file, we have no hash for + # the None key - only the frag index for the data file + 7: datafile_hash}}, + }[policy.policy_type] + msg = 'expected %r != %r for policy %r' % ( + expected, hashes, policy) + self.assertEqual(hashes, expected, msg) + + def test_hash_suffix_multi_file_ends_in_tombstone(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + df = df_mgr.get_diskfile('sda1', '0', 'a', 'c', 'o', policy=policy, + frag_index=4) + suffix = os.path.basename(os.path.dirname(df._datadir)) + mkdirs(df._datadir) + now = time() + # go behind the scenes and setup a bunch of weird file names + for tdiff in [500, 100, 10, 1]: + for suff in ['.meta', '.data', '.ts']: + timestamp = Timestamp(now - tdiff) + filename = timestamp.internal + if policy.policy_type == EC_POLICY and suff == '.data': + filename += '#%s' % df._frag_index + filename += suff + open(os.path.join(df._datadir, filename), 'w').close() + tombstone_hash = md5(filename).hexdigest() + # call get_hashes and it should clean things up + hashes = df_mgr.get_hashes('sda1', '0', [], policy) + expected = { + REPL_POLICY: {suffix: tombstone_hash}, + EC_POLICY: {suffix: { + # fi is None here because we have a tombstone + None: tombstone_hash}}, + }[policy.policy_type] + self.assertEqual(hashes, expected) + # only the tombstone should be left + found_files = os.listdir(df._datadir) + self.assertEqual(found_files, [filename]) + + def test_hash_suffix_multi_file_ends_in_datafile(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + df = df_mgr.get_diskfile('sda1', '0', 'a', 'c', 'o', policy=policy, + frag_index=4) + suffix = os.path.basename(os.path.dirname(df._datadir)) + mkdirs(df._datadir) + now = time() + timestamp = None + # go behind the scenes and setup a bunch of weird file names + for tdiff in [500, 100, 10, 1]: + suffs = ['.meta', '.data'] + if tdiff > 50: + suffs.append('.ts') + if policy.policy_type == EC_POLICY: + suffs.append('.durable') + for suff in suffs: + timestamp = Timestamp(now - tdiff) + filename = timestamp.internal + if policy.policy_type == EC_POLICY and suff == '.data': + filename += '#%s' % df._frag_index + filename += suff + open(os.path.join(df._datadir, filename), 'w').close() + # call get_hashes and it should clean things up + hashes = df_mgr.get_hashes('sda1', '0', [], policy) + data_filename = timestamp.internal + if policy.policy_type == EC_POLICY: + data_filename += '#%s' % df._frag_index + data_filename += '.data' + metadata_filename = timestamp.internal + '.meta' + durable_filename = timestamp.internal + '.durable' + if policy.policy_type == EC_POLICY: + hasher = md5() + hasher.update(metadata_filename) + hasher.update(durable_filename) + expected = { + suffix: { + # metadata & durable updates are hashed separately + None: hasher.hexdigest(), + 4: self.fname_to_ts_hash(data_filename), + } + } + expected_files = [data_filename, durable_filename, + metadata_filename] + elif policy.policy_type == REPL_POLICY: + hasher = md5() + hasher.update(metadata_filename) + hasher.update(data_filename) + expected = {suffix: hasher.hexdigest()} + expected_files = [data_filename, metadata_filename] + else: + self.fail('unknown policy type %r' % policy.policy_type) + msg = 'expected %r != %r for policy %r' % ( + expected, hashes, policy) + self.assertEqual(hashes, expected, msg) + # only the meta and data should be left + self.assertEqual(sorted(os.listdir(df._datadir)), + sorted(expected_files)) + + def test_hash_suffix_removes_empty_hashdir_and_suffix(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + df = df_mgr.get_diskfile('sda1', '0', 'a', 'c', 'o', + policy=policy, frag_index=2) + os.makedirs(df._datadir) + self.assertTrue(os.path.exists(df._datadir)) # sanity + df_mgr.get_hashes('sda1', '0', [], policy) + suffix_dir = os.path.dirname(df._datadir) + self.assertFalse(os.path.exists(suffix_dir)) + + def test_hash_suffix_removes_empty_hashdirs_in_valid_suffix(self): + paths, suffix = find_paths_with_matching_suffixes(needed_matches=3, + needed_suffixes=0) + matching_paths = paths.pop(suffix) + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + df = df_mgr.get_diskfile('sda1', '0', *matching_paths[0], + policy=policy, frag_index=2) + # create a real, valid hsh_path + df.delete(Timestamp(time())) + # and a couple of empty hsh_paths + empty_hsh_paths = [] + for path in matching_paths[1:]: + fake_df = df_mgr.get_diskfile('sda1', '0', *path, + policy=policy) + os.makedirs(fake_df._datadir) + empty_hsh_paths.append(fake_df._datadir) + for hsh_path in empty_hsh_paths: + self.assertTrue(os.path.exists(hsh_path)) # sanity + # get_hashes will cleanup empty hsh_path and leave valid one + hashes = df_mgr.get_hashes('sda1', '0', [], policy) + self.assertTrue(suffix in hashes) + self.assertTrue(os.path.exists(df._datadir)) + for hsh_path in empty_hsh_paths: + self.assertFalse(os.path.exists(hsh_path)) + + # get_hashes tests - hash_suffix error handling + + def test_hash_suffix_listdir_enotdir(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + suffix = '123' + suffix_path = os.path.join(self.devices, 'sda1', + diskfile.get_data_dir(policy), '0', + suffix) + os.makedirs(suffix_path) + self.assertTrue(os.path.exists(suffix_path)) # sanity + hashes = df_mgr.get_hashes('sda1', '0', [suffix], policy) + # suffix dir cleaned up by get_hashes + self.assertFalse(os.path.exists(suffix_path)) + expected = { + EC_POLICY: {'123': {}}, + REPL_POLICY: {'123': EMPTY_ETAG}, + }[policy.policy_type] + msg = 'expected %r != %r for policy %r' % (expected, hashes, + policy) + self.assertEqual(hashes, expected, msg) + + # now make the suffix path a file + open(suffix_path, 'w').close() + hashes = df_mgr.get_hashes('sda1', '0', [suffix], policy) + expected = {} + msg = 'expected %r != %r for policy %r' % (expected, hashes, + policy) + self.assertEqual(hashes, expected, msg) + + def test_hash_suffix_listdir_enoent(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + orig_listdir = os.listdir + listdir_calls = [] + + def mock_listdir(path): + success = False + try: + rv = orig_listdir(path) + success = True + return rv + finally: + listdir_calls.append((path, success)) + + with mock.patch('swift.obj.diskfile.os.listdir', + mock_listdir): + # recalc always forces hash_suffix even if the suffix + # does not exist! + df_mgr.get_hashes('sda1', '0', ['123'], policy) + + part_path = os.path.join(self.devices, 'sda1', + diskfile.get_data_dir(policy), '0') + + self.assertEqual(listdir_calls, [ + # part path gets created automatically + (part_path, True), + # this one blows up + (os.path.join(part_path, '123'), False), + ]) + + def test_hash_suffix_hash_cleanup_listdir_enotdir_quarantined(self): + for policy in self.iter_policies(): + df = self.df_router[policy].get_diskfile( + self.existing_device, '0', 'a', 'c', 'o', policy=policy) + # make the suffix directory + suffix_path = os.path.dirname(df._datadir) + os.makedirs(suffix_path) + suffix = os.path.basename(suffix_path) + + # make the df hash path a file + open(df._datadir, 'wb').close() + df_mgr = self.df_router[policy] + hashes = df_mgr.get_hashes(self.existing_device, '0', [suffix], + policy) + expected = { + REPL_POLICY: {suffix: EMPTY_ETAG}, + EC_POLICY: {suffix: {}}, + }[policy.policy_type] + self.assertEqual(hashes, expected) + # and hash path is quarantined + self.assertFalse(os.path.exists(df._datadir)) + # each device a quarantined directory + quarantine_base = os.path.join(self.devices, + self.existing_device, 'quarantined') + # the quarantine path is... + quarantine_path = os.path.join( + quarantine_base, # quarantine root + diskfile.get_data_dir(policy), # per-policy data dir + suffix, # first dir from which quarantined file was removed + os.path.basename(df._datadir) # name of quarantined file + ) + self.assertTrue(os.path.exists(quarantine_path)) + + def test_hash_suffix_hash_cleanup_listdir_other_oserror(self): + for policy in self.iter_policies(): + timestamp = self.ts() + df_mgr = self.df_router[policy] + df = df_mgr.get_diskfile(self.existing_device, '0', 'a', 'c', + 'o', policy=policy, + frag_index=7) + suffix = os.path.basename(os.path.dirname(df._datadir)) + with df.create() as writer: + test_data = 'test_data' + writer.write(test_data) + metadata = { + 'X-Timestamp': timestamp.internal, + 'ETag': md5(test_data).hexdigest(), + 'Content-Length': len(test_data), + } + writer.put(metadata) + + orig_os_listdir = os.listdir + listdir_calls = [] + + part_path = os.path.join(self.devices, self.existing_device, + diskfile.get_data_dir(policy), '0') + suffix_path = os.path.join(part_path, suffix) + datadir_path = os.path.join(suffix_path, hash_path('a', 'c', 'o')) + + def mock_os_listdir(path): + listdir_calls.append(path) + if path == datadir_path: + # we want the part and suffix listdir calls to pass and + # make the hash_cleanup_listdir raise an exception + raise OSError(errno.EACCES, os.strerror(errno.EACCES)) + return orig_os_listdir(path) + + with mock.patch('os.listdir', mock_os_listdir): + hashes = df_mgr.get_hashes(self.existing_device, '0', [], + policy) + + self.assertEqual(listdir_calls, [ + part_path, + suffix_path, + datadir_path, + ]) + expected = {suffix: None} + msg = 'expected %r != %r for policy %r' % ( + expected, hashes, policy) + self.assertEqual(hashes, expected, msg) + + def test_hash_suffix_rmdir_hsh_path_oserror(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + # make an empty hsh_path to be removed + df = df_mgr.get_diskfile(self.existing_device, '0', 'a', 'c', + 'o', policy=policy) + os.makedirs(df._datadir) + suffix = os.path.basename(os.path.dirname(df._datadir)) + with mock.patch('os.rmdir', side_effect=OSError()): + hashes = df_mgr.get_hashes(self.existing_device, '0', [], + policy) + expected = { + EC_POLICY: {}, + REPL_POLICY: md5().hexdigest(), + }[policy.policy_type] + self.assertEqual(hashes, {suffix: expected}) + self.assertTrue(os.path.exists(df._datadir)) + + def test_hash_suffix_rmdir_suffix_oserror(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + # make an empty hsh_path to be removed + df = df_mgr.get_diskfile(self.existing_device, '0', 'a', 'c', + 'o', policy=policy) + os.makedirs(df._datadir) + suffix_path = os.path.dirname(df._datadir) + suffix = os.path.basename(suffix_path) + + captured_paths = [] + + def mock_rmdir(path): + captured_paths.append(path) + if path == suffix_path: + raise OSError('kaboom!') + + with mock.patch('os.rmdir', mock_rmdir): + hashes = df_mgr.get_hashes(self.existing_device, '0', [], + policy) + expected = { + EC_POLICY: {}, + REPL_POLICY: md5().hexdigest(), + }[policy.policy_type] + self.assertEqual(hashes, {suffix: expected}) + self.assertTrue(os.path.exists(suffix_path)) + self.assertEqual([ + df._datadir, + suffix_path, + ], captured_paths) + + # get_hashes tests - behaviors + + def test_get_hashes_creates_partition_and_pkl(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + hashes = df_mgr.get_hashes(self.existing_device, '0', [], + policy) + self.assertEqual(hashes, {}) + part_path = os.path.join( + self.devices, 'sda1', diskfile.get_data_dir(policy), '0') + self.assertTrue(os.path.exists(part_path)) + hashes_file = os.path.join(part_path, + diskfile.HASH_FILE) + self.assertTrue(os.path.exists(hashes_file)) + + # and double check the hashes + new_hashes = df_mgr.get_hashes(self.existing_device, '0', [], + policy) + self.assertEqual(hashes, new_hashes) + + def test_get_hashes_new_pkl_finds_new_suffix_dirs(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + part_path = os.path.join( + self.devices, self.existing_device, + diskfile.get_data_dir(policy), '0') + hashes_file = os.path.join(part_path, + diskfile.HASH_FILE) + # add something to find + df = df_mgr.get_diskfile(self.existing_device, '0', 'a', 'c', + 'o', policy=policy, frag_index=4) + timestamp = self.ts() + df.delete(timestamp) + suffix = os.path.basename(os.path.dirname(df._datadir)) + # get_hashes will find the untracked suffix dir + self.assertFalse(os.path.exists(hashes_file)) # sanity + hashes = df_mgr.get_hashes(self.existing_device, '0', [], policy) + self.assertTrue(suffix in hashes) + # ... and create a hashes pickle for it + self.assertTrue(os.path.exists(hashes_file)) + + def test_get_hashes_old_pickle_does_not_find_new_suffix_dirs(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + # create a empty stale pickle + part_path = os.path.join( + self.devices, 'sda1', diskfile.get_data_dir(policy), '0') + hashes_file = os.path.join(part_path, + diskfile.HASH_FILE) + hashes = df_mgr.get_hashes(self.existing_device, '0', [], policy) + self.assertEqual(hashes, {}) + self.assertTrue(os.path.exists(hashes_file)) # sanity + # add something to find + df = df_mgr.get_diskfile(self.existing_device, '0', 'a', 'c', 'o', + policy=policy, frag_index=4) + os.makedirs(df._datadir) + filename = Timestamp(time()).internal + '.ts' + open(os.path.join(df._datadir, filename), 'w').close() + suffix = os.path.basename(os.path.dirname(df._datadir)) + # but get_hashes has no reason to find it (because we didn't + # call invalidate_hash) + new_hashes = df_mgr.get_hashes(self.existing_device, '0', [], + policy) + self.assertEqual(new_hashes, hashes) + # ... unless remote end asks for a recalc + hashes = df_mgr.get_hashes(self.existing_device, '0', [suffix], + policy) + self.assertTrue(suffix in hashes) + + def test_get_hashes_does_not_rehash_known_suffix_dirs(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + df = df_mgr.get_diskfile(self.existing_device, '0', 'a', 'c', + 'o', policy=policy, frag_index=4) + suffix = os.path.basename(os.path.dirname(df._datadir)) + timestamp = self.ts() + df.delete(timestamp) + # create the baseline hashes file + hashes = df_mgr.get_hashes(self.existing_device, '0', [], policy) + self.assertTrue(suffix in hashes) + # now change the contents of the suffix w/o calling + # invalidate_hash + rmtree(df._datadir) + suffix_path = os.path.dirname(df._datadir) + self.assertTrue(os.path.exists(suffix_path)) # sanity + new_hashes = df_mgr.get_hashes(self.existing_device, '0', [], + policy) + # ... and get_hashes is none the wiser + self.assertEqual(new_hashes, hashes) + + # ... unless remote end asks for a recalc + hashes = df_mgr.get_hashes(self.existing_device, '0', [suffix], + policy) + self.assertNotEqual(new_hashes, hashes) + # and the empty suffix path is removed + self.assertFalse(os.path.exists(suffix_path)) + # ... but is hashed as "empty" + expected = { + EC_POLICY: {}, + REPL_POLICY: md5().hexdigest(), + }[policy.policy_type] + self.assertEqual({suffix: expected}, hashes) + + def test_get_hashes_multi_file_multi_suffix(self): + paths, suffix = find_paths_with_matching_suffixes(needed_matches=2, + needed_suffixes=3) + matching_paths = paths.pop(suffix) + matching_paths.sort(key=lambda path: hash_path(*path)) + other_paths = [] + for suffix, paths in paths.items(): + other_paths.append(paths[0]) + if len(other_paths) >= 2: + break + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + # first we'll make a tombstone + df = df_mgr.get_diskfile(self.existing_device, '0', + *other_paths[0], policy=policy, + frag_index=4) + timestamp = self.ts() + df.delete(timestamp) + tombstone_hash = md5(timestamp.internal + '.ts').hexdigest() + tombstone_suffix = os.path.basename(os.path.dirname(df._datadir)) + # second file in another suffix has a .datafile + df = df_mgr.get_diskfile(self.existing_device, '0', + *other_paths[1], policy=policy, + frag_index=5) + timestamp = self.ts() + with df.create() as writer: + test_data = 'test_file' + writer.write(test_data) + metadata = { + 'X-Timestamp': timestamp.internal, + 'ETag': md5(test_data).hexdigest(), + 'Content-Length': len(test_data), + } + writer.put(metadata) + writer.commit(timestamp) + datafile_name = timestamp.internal + if policy.policy_type == EC_POLICY: + datafile_name += '#%d' % df._frag_index + datafile_name += '.data' + durable_hash = md5(timestamp.internal + '.durable').hexdigest() + datafile_suffix = os.path.basename(os.path.dirname(df._datadir)) + # in the *third* suffix - two datafiles for different hashes + df = df_mgr.get_diskfile(self.existing_device, '0', + *matching_paths[0], policy=policy, + frag_index=6) + matching_suffix = os.path.basename(os.path.dirname(df._datadir)) + timestamp = self.ts() + with df.create() as writer: + test_data = 'test_file' + writer.write(test_data) + metadata = { + 'X-Timestamp': timestamp.internal, + 'ETag': md5(test_data).hexdigest(), + 'Content-Length': len(test_data), + } + writer.put(metadata) + writer.commit(timestamp) + # we'll keep track of file names for hash calculations + filename = timestamp.internal + if policy.policy_type == EC_POLICY: + filename += '#%d' % df._frag_index + filename += '.data' + filenames = { + 'data': { + 6: filename + }, + 'durable': [timestamp.internal + '.durable'], + } + df = df_mgr.get_diskfile(self.existing_device, '0', + *matching_paths[1], policy=policy, + frag_index=7) + self.assertEqual(os.path.basename(os.path.dirname(df._datadir)), + matching_suffix) # sanity + timestamp = self.ts() + with df.create() as writer: + test_data = 'test_file' + writer.write(test_data) + metadata = { + 'X-Timestamp': timestamp.internal, + 'ETag': md5(test_data).hexdigest(), + 'Content-Length': len(test_data), + } + writer.put(metadata) + writer.commit(timestamp) + filename = timestamp.internal + if policy.policy_type == EC_POLICY: + filename += '#%d' % df._frag_index + filename += '.data' + filenames['data'][7] = filename + filenames['durable'].append(timestamp.internal + '.durable') + # now make up the expected suffixes! + if policy.policy_type == EC_POLICY: + hasher = md5() + for filename in filenames['durable']: + hasher.update(filename) + expected = { + tombstone_suffix: { + None: tombstone_hash, + }, + datafile_suffix: { + None: durable_hash, + 5: self.fname_to_ts_hash(datafile_name), + }, + matching_suffix: { + None: hasher.hexdigest(), + 6: self.fname_to_ts_hash(filenames['data'][6]), + 7: self.fname_to_ts_hash(filenames['data'][7]), + }, + } + elif policy.policy_type == REPL_POLICY: + hasher = md5() + for filename in filenames['data'].values(): + hasher.update(filename) + expected = { + tombstone_suffix: tombstone_hash, + datafile_suffix: md5(datafile_name).hexdigest(), + matching_suffix: hasher.hexdigest(), + } + else: + self.fail('unknown policy type %r' % policy.policy_type) + hashes = df_mgr.get_hashes('sda1', '0', [], policy) + self.assertEqual(hashes, expected) + + # get_hashes tests - error handling + + def test_get_hashes_bad_dev(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + df_mgr.mount_check = True + with mock.patch('swift.obj.diskfile.check_mount', + mock.MagicMock(side_effect=[False])): + self.assertRaises( + DiskFileDeviceUnavailable, + df_mgr.get_hashes, self.existing_device, '0', ['123'], + policy) + + def test_get_hashes_zero_bytes_pickle(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + part_path = os.path.join(self.devices, self.existing_device, + diskfile.get_data_dir(policy), '0') + os.makedirs(part_path) + # create a pre-existing zero-byte file + open(os.path.join(part_path, diskfile.HASH_FILE), 'w').close() + hashes = df_mgr.get_hashes(self.existing_device, '0', [], + policy) + self.assertEqual(hashes, {}) + + def test_get_hashes_hash_suffix_enotdir(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + # create a real suffix dir + df = df_mgr.get_diskfile(self.existing_device, '0', 'a', 'c', + 'o', policy=policy, frag_index=3) + df.delete(Timestamp(time())) + suffix = os.path.basename(os.path.dirname(df._datadir)) + # touch a bad suffix dir + part_dir = os.path.join(self.devices, self.existing_device, + diskfile.get_data_dir(policy), '0') + open(os.path.join(part_dir, 'bad'), 'w').close() + hashes = df_mgr.get_hashes(self.existing_device, '0', [], policy) + self.assertTrue(suffix in hashes) + self.assertFalse('bad' in hashes) + + def test_get_hashes_hash_suffix_other_oserror(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + suffix = '123' + suffix_path = os.path.join(self.devices, self.existing_device, + diskfile.get_data_dir(policy), '0', + suffix) + os.makedirs(suffix_path) + self.assertTrue(os.path.exists(suffix_path)) # sanity + hashes = df_mgr.get_hashes(self.existing_device, '0', [suffix], + policy) + expected = { + EC_POLICY: {'123': {}}, + REPL_POLICY: {'123': EMPTY_ETAG}, + }[policy.policy_type] + msg = 'expected %r != %r for policy %r' % (expected, hashes, + policy) + self.assertEqual(hashes, expected, msg) + + # this OSError does *not* raise PathNotDir, and is allowed to leak + # from hash_suffix into get_hashes + mocked_os_listdir = mock.Mock( + side_effect=OSError(errno.EACCES, os.strerror(errno.EACCES))) + with mock.patch("os.listdir", mocked_os_listdir): + with mock.patch('swift.obj.diskfile.logging') as mock_logging: + hashes = df_mgr.get_hashes('sda1', '0', [suffix], policy) + self.assertEqual(mock_logging.method_calls, + [mock.call.exception('Error hashing suffix')]) + # recalc always causes a suffix to get reset to None; the listdir + # error prevents the suffix from being rehashed + expected = {'123': None} + msg = 'expected %r != %r for policy %r' % (expected, hashes, + policy) + self.assertEqual(hashes, expected, msg) + + def test_get_hashes_modified_recursive_retry(self): + for policy in self.iter_policies(): + df_mgr = self.df_router[policy] + # first create an empty pickle + df_mgr.get_hashes(self.existing_device, '0', [], policy) + hashes_file = os.path.join( + self.devices, self.existing_device, + diskfile.get_data_dir(policy), '0', diskfile.HASH_FILE) + mtime = os.path.getmtime(hashes_file) + non_local = {'mtime': mtime} + + calls = [] + + def mock_getmtime(filename): + t = non_local['mtime'] + if len(calls) <= 3: + # this will make the *next* call get a slightly + # newer mtime than the last + non_local['mtime'] += 1 + # track exactly the value for every return + calls.append(t) + return t + with mock.patch('swift.obj.diskfile.getmtime', + mock_getmtime): + df_mgr.get_hashes(self.existing_device, '0', ['123'], + policy) + + self.assertEqual(calls, [ + mtime + 0, # read + mtime + 1, # modified + mtime + 2, # read + mtime + 3, # modifed + mtime + 4, # read + mtime + 4, # not modifed + ]) + if __name__ == '__main__': unittest.main() diff --git a/test/unit/obj/test_expirer.py b/test/unit/obj/test_expirer.py index 7c174f251c..ca815d358c 100644 --- a/test/unit/obj/test_expirer.py +++ b/test/unit/obj/test_expirer.py @@ -16,7 +16,7 @@ import urllib from time import time from unittest import main, TestCase -from test.unit import FakeLogger, FakeRing, mocked_http_conn +from test.unit import FakeRing, mocked_http_conn, debug_logger from copy import deepcopy from tempfile import mkdtemp from shutil import rmtree @@ -53,7 +53,8 @@ class TestObjectExpirer(TestCase): internal_client.sleep = not_sleep self.rcache = mkdtemp() - self.logger = FakeLogger() + self.conf = {'recon_cache_path': self.rcache} + self.logger = debug_logger('test-recon') def tearDown(self): rmtree(self.rcache) @@ -167,7 +168,7 @@ class TestObjectExpirer(TestCase): '2': set('5-five 6-six'.split()), '3': set(u'7-seven\u2661'.split()), } - x = ObjectExpirer({}) + x = ObjectExpirer(self.conf) x.swift = InternalClient(containers) deleted_objects = {} @@ -233,31 +234,32 @@ class TestObjectExpirer(TestCase): x = expirer.ObjectExpirer({}, logger=self.logger) x.report() - self.assertEqual(x.logger.log_dict['info'], []) + self.assertEqual(x.logger.get_lines_for_level('info'), []) x.logger._clear() x.report(final=True) - self.assertTrue('completed' in x.logger.log_dict['info'][-1][0][0], - x.logger.log_dict['info']) - self.assertTrue('so far' not in x.logger.log_dict['info'][-1][0][0], - x.logger.log_dict['info']) + self.assertTrue( + 'completed' in str(x.logger.get_lines_for_level('info'))) + self.assertTrue( + 'so far' not in str(x.logger.get_lines_for_level('info'))) x.logger._clear() x.report_last_time = time() - x.report_interval x.report() - self.assertTrue('completed' not in x.logger.log_dict['info'][-1][0][0], - x.logger.log_dict['info']) - self.assertTrue('so far' in x.logger.log_dict['info'][-1][0][0], - x.logger.log_dict['info']) + self.assertTrue( + 'completed' not in str(x.logger.get_lines_for_level('info'))) + self.assertTrue( + 'so far' in str(x.logger.get_lines_for_level('info'))) def test_run_once_nothing_to_do(self): - x = expirer.ObjectExpirer({}, logger=self.logger) + x = expirer.ObjectExpirer(self.conf, logger=self.logger) x.swift = 'throw error because a string does not have needed methods' x.run_once() - self.assertEqual(x.logger.log_dict['exception'], - [(("Unhandled exception",), {}, - "'str' object has no attribute " - "'get_account_info'")]) + self.assertEqual(x.logger.get_lines_for_level('error'), + ["Unhandled exception: "]) + log_args, log_kwargs = x.logger.log_dict['error'][0] + self.assertEqual(str(log_kwargs['exc_info'][1]), + "'str' object has no attribute 'get_account_info'") def test_run_once_calls_report(self): class InternalClient(object): @@ -267,14 +269,14 @@ class TestObjectExpirer(TestCase): def iter_containers(*a, **kw): return [] - x = expirer.ObjectExpirer({}, logger=self.logger) + x = expirer.ObjectExpirer(self.conf, logger=self.logger) x.swift = InternalClient() x.run_once() self.assertEqual( - x.logger.log_dict['info'], - [(('Pass beginning; 1 possible containers; ' - '2 possible objects',), {}), - (('Pass completed in 0s; 0 objects expired',), {})]) + x.logger.get_lines_for_level('info'), [ + 'Pass beginning; 1 possible containers; 2 possible objects', + 'Pass completed in 0s; 0 objects expired', + ]) def test_run_once_unicode_problem(self): class InternalClient(object): @@ -296,7 +298,7 @@ class TestObjectExpirer(TestCase): def delete_container(*a, **kw): pass - x = expirer.ObjectExpirer({}, logger=self.logger) + x = expirer.ObjectExpirer(self.conf, logger=self.logger) x.swift = InternalClient() requests = [] @@ -323,27 +325,28 @@ class TestObjectExpirer(TestCase): def iter_objects(*a, **kw): raise Exception('This should not have been called') - x = expirer.ObjectExpirer({'recon_cache_path': self.rcache}, + x = expirer.ObjectExpirer(self.conf, logger=self.logger) x.swift = InternalClient([{'name': str(int(time() + 86400))}]) x.run_once() - for exccall in x.logger.log_dict['exception']: - self.assertTrue( - 'This should not have been called' not in exccall[0][0]) - self.assertEqual( - x.logger.log_dict['info'], - [(('Pass beginning; 1 possible containers; ' - '2 possible objects',), {}), - (('Pass completed in 0s; 0 objects expired',), {})]) + logs = x.logger.all_log_lines() + self.assertEqual(logs['info'], [ + 'Pass beginning; 1 possible containers; 2 possible objects', + 'Pass completed in 0s; 0 objects expired', + ]) + self.assertTrue('error' not in logs) # Reverse test to be sure it still would blow up the way expected. fake_swift = InternalClient([{'name': str(int(time() - 86400))}]) - x = expirer.ObjectExpirer({}, logger=self.logger, swift=fake_swift) + x = expirer.ObjectExpirer(self.conf, logger=self.logger, + swift=fake_swift) x.run_once() self.assertEqual( - x.logger.log_dict['exception'], - [(('Unhandled exception',), {}, - str(Exception('This should not have been called')))]) + x.logger.get_lines_for_level('error'), [ + 'Unhandled exception: ']) + log_args, log_kwargs = x.logger.log_dict['error'][-1] + self.assertEqual(str(log_kwargs['exc_info'][1]), + 'This should not have been called') def test_object_timestamp_break(self): class InternalClient(object): @@ -369,33 +372,27 @@ class TestObjectExpirer(TestCase): fake_swift = InternalClient( [{'name': str(int(time() - 86400))}], [{'name': '%d-actual-obj' % int(time() + 86400)}]) - x = expirer.ObjectExpirer({}, logger=self.logger, swift=fake_swift) + x = expirer.ObjectExpirer(self.conf, logger=self.logger, + swift=fake_swift) x.run_once() - for exccall in x.logger.log_dict['exception']: - self.assertTrue( - 'This should not have been called' not in exccall[0][0]) - self.assertEqual( - x.logger.log_dict['info'], - [(('Pass beginning; 1 possible containers; ' - '2 possible objects',), {}), - (('Pass completed in 0s; 0 objects expired',), {})]) - + self.assertTrue('error' not in x.logger.all_log_lines()) + self.assertEqual(x.logger.get_lines_for_level('info'), [ + 'Pass beginning; 1 possible containers; 2 possible objects', + 'Pass completed in 0s; 0 objects expired', + ]) # Reverse test to be sure it still would blow up the way expected. ts = int(time() - 86400) fake_swift = InternalClient( [{'name': str(int(time() - 86400))}], [{'name': '%d-actual-obj' % ts}]) - x = expirer.ObjectExpirer({}, logger=self.logger, swift=fake_swift) + x = expirer.ObjectExpirer(self.conf, logger=self.logger, + swift=fake_swift) x.delete_actual_object = should_not_be_called x.run_once() - excswhiledeleting = [] - for exccall in x.logger.log_dict['exception']: - if exccall[0][0].startswith('Exception while deleting '): - excswhiledeleting.append(exccall[0][0]) self.assertEqual( - excswhiledeleting, + x.logger.get_lines_for_level('error'), ['Exception while deleting object %d %d-actual-obj ' - 'This should not have been called' % (ts, ts)]) + 'This should not have been called: ' % (ts, ts)]) def test_failed_delete_keeps_entry(self): class InternalClient(object): @@ -428,24 +425,22 @@ class TestObjectExpirer(TestCase): fake_swift = InternalClient( [{'name': str(int(time() - 86400))}], [{'name': '%d-actual-obj' % ts}]) - x = expirer.ObjectExpirer({}, logger=self.logger, swift=fake_swift) + x = expirer.ObjectExpirer(self.conf, logger=self.logger, + swift=fake_swift) x.iter_containers = lambda: [str(int(time() - 86400))] x.delete_actual_object = deliberately_blow_up x.pop_queue = should_not_get_called x.run_once() - excswhiledeleting = [] - for exccall in x.logger.log_dict['exception']: - if exccall[0][0].startswith('Exception while deleting '): - excswhiledeleting.append(exccall[0][0]) + error_lines = x.logger.get_lines_for_level('error') self.assertEqual( - excswhiledeleting, + error_lines, ['Exception while deleting object %d %d-actual-obj ' - 'failed to delete actual object' % (ts, ts)]) + 'failed to delete actual object: ' % (ts, ts)]) self.assertEqual( - x.logger.log_dict['info'], - [(('Pass beginning; 1 possible containers; ' - '2 possible objects',), {}), - (('Pass completed in 0s; 0 objects expired',), {})]) + x.logger.get_lines_for_level('info'), [ + 'Pass beginning; 1 possible containers; 2 possible objects', + 'Pass completed in 0s; 0 objects expired', + ]) # Reverse test to be sure it still would blow up the way expected. ts = int(time() - 86400) @@ -453,18 +448,15 @@ class TestObjectExpirer(TestCase): [{'name': str(int(time() - 86400))}], [{'name': '%d-actual-obj' % ts}]) self.logger._clear() - x = expirer.ObjectExpirer({}, logger=self.logger, swift=fake_swift) + x = expirer.ObjectExpirer(self.conf, logger=self.logger, + swift=fake_swift) x.delete_actual_object = lambda o, t: None x.pop_queue = should_not_get_called x.run_once() - excswhiledeleting = [] - for exccall in x.logger.log_dict['exception']: - if exccall[0][0].startswith('Exception while deleting '): - excswhiledeleting.append(exccall[0][0]) self.assertEqual( - excswhiledeleting, + self.logger.get_lines_for_level('error'), ['Exception while deleting object %d %d-actual-obj This should ' - 'not have been called' % (ts, ts)]) + 'not have been called: ' % (ts, ts)]) def test_success_gets_counted(self): class InternalClient(object): @@ -493,7 +485,8 @@ class TestObjectExpirer(TestCase): fake_swift = InternalClient( [{'name': str(int(time() - 86400))}], [{'name': '%d-acc/c/actual-obj' % int(time() - 86400)}]) - x = expirer.ObjectExpirer({}, logger=self.logger, swift=fake_swift) + x = expirer.ObjectExpirer(self.conf, logger=self.logger, + swift=fake_swift) x.delete_actual_object = lambda o, t: None x.pop_queue = lambda c, o: None self.assertEqual(x.report_objects, 0) @@ -501,10 +494,9 @@ class TestObjectExpirer(TestCase): x.run_once() self.assertEqual(x.report_objects, 1) self.assertEqual( - x.logger.log_dict['info'], - [(('Pass beginning; 1 possible containers; ' - '2 possible objects',), {}), - (('Pass completed in 0s; 1 objects expired',), {})]) + x.logger.get_lines_for_level('info'), + ['Pass beginning; 1 possible containers; 2 possible objects', + 'Pass completed in 0s; 1 objects expired']) def test_delete_actual_object_does_not_get_unicode(self): class InternalClient(object): @@ -539,17 +531,18 @@ class TestObjectExpirer(TestCase): fake_swift = InternalClient( [{'name': str(int(time() - 86400))}], [{'name': u'%d-actual-obj' % int(time() - 86400)}]) - x = expirer.ObjectExpirer({}, logger=self.logger, swift=fake_swift) + x = expirer.ObjectExpirer(self.conf, logger=self.logger, + swift=fake_swift) x.delete_actual_object = delete_actual_object_test_for_unicode x.pop_queue = lambda c, o: None self.assertEqual(x.report_objects, 0) x.run_once() self.assertEqual(x.report_objects, 1) self.assertEqual( - x.logger.log_dict['info'], - [(('Pass beginning; 1 possible containers; ' - '2 possible objects',), {}), - (('Pass completed in 0s; 1 objects expired',), {})]) + x.logger.get_lines_for_level('info'), [ + 'Pass beginning; 1 possible containers; 2 possible objects', + 'Pass completed in 0s; 1 objects expired', + ]) self.assertFalse(got_unicode[0]) def test_failed_delete_continues_on(self): @@ -579,7 +572,7 @@ class TestObjectExpirer(TestCase): def fail_delete_actual_object(actual_obj, timestamp): raise Exception('failed to delete actual object') - x = expirer.ObjectExpirer({}, logger=self.logger) + x = expirer.ObjectExpirer(self.conf, logger=self.logger) cts = int(time() - 86400) ots = int(time() - 86400) @@ -597,28 +590,24 @@ class TestObjectExpirer(TestCase): x.swift = InternalClient(containers, objects) x.delete_actual_object = fail_delete_actual_object x.run_once() - excswhiledeleting = [] - for exccall in x.logger.log_dict['exception']: - if exccall[0][0].startswith('Exception while deleting '): - excswhiledeleting.append(exccall[0][0]) - self.assertEqual(sorted(excswhiledeleting), sorted([ + error_lines = x.logger.get_lines_for_level('error') + self.assertEqual(sorted(error_lines), sorted([ 'Exception while deleting object %d %d-actual-obj failed to ' - 'delete actual object' % (cts, ots), + 'delete actual object: ' % (cts, ots), 'Exception while deleting object %d %d-next-obj failed to ' - 'delete actual object' % (cts, ots), + 'delete actual object: ' % (cts, ots), 'Exception while deleting object %d %d-actual-obj failed to ' - 'delete actual object' % (cts + 1, ots), + 'delete actual object: ' % (cts + 1, ots), 'Exception while deleting object %d %d-next-obj failed to ' - 'delete actual object' % (cts + 1, ots), + 'delete actual object: ' % (cts + 1, ots), 'Exception while deleting container %d failed to delete ' - 'container' % (cts,), + 'container: ' % (cts,), 'Exception while deleting container %d failed to delete ' - 'container' % (cts + 1,)])) - self.assertEqual( - x.logger.log_dict['info'], - [(('Pass beginning; 1 possible containers; ' - '2 possible objects',), {}), - (('Pass completed in 0s; 0 objects expired',), {})]) + 'container: ' % (cts + 1,)])) + self.assertEqual(x.logger.get_lines_for_level('info'), [ + 'Pass beginning; 1 possible containers; 2 possible objects', + 'Pass completed in 0s; 0 objects expired', + ]) def test_run_forever_initial_sleep_random(self): global last_not_sleep @@ -664,9 +653,11 @@ class TestObjectExpirer(TestCase): finally: expirer.sleep = orig_sleep self.assertEqual(str(err), 'exiting exception 2') - self.assertEqual(x.logger.log_dict['exception'], - [(('Unhandled exception',), {}, - 'exception 1')]) + self.assertEqual(x.logger.get_lines_for_level('error'), + ['Unhandled exception: ']) + log_args, log_kwargs = x.logger.log_dict['error'][0] + self.assertEqual(str(log_kwargs['exc_info'][1]), + 'exception 1') def test_delete_actual_object(self): got_env = [None] diff --git a/test/unit/obj/test_reconstructor.py b/test/unit/obj/test_reconstructor.py new file mode 100755 index 0000000000..b7254f4343 --- /dev/null +++ b/test/unit/obj/test_reconstructor.py @@ -0,0 +1,2468 @@ +# Copyright (c) 2010-2012 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import itertools +import unittest +import os +from hashlib import md5 +import mock +import cPickle as pickle +import tempfile +import time +import shutil +import re +import random +from eventlet import Timeout + +from contextlib import closing, nested, contextmanager +from gzip import GzipFile +from shutil import rmtree +from swift.common import utils +from swift.common.exceptions import DiskFileError +from swift.obj import diskfile, reconstructor as object_reconstructor +from swift.common import ring +from swift.common.storage_policy import (StoragePolicy, ECStoragePolicy, + POLICIES, EC_POLICY) +from swift.obj.reconstructor import REVERT + +from test.unit import (patch_policies, debug_logger, mocked_http_conn, + FabricatedRing, make_timestamp_iter) + + +@contextmanager +def mock_ssync_sender(ssync_calls=None, response_callback=None, **kwargs): + def fake_ssync(daemon, node, job, suffixes): + if ssync_calls is not None: + ssync_calls.append( + {'node': node, 'job': job, 'suffixes': suffixes}) + + def fake_call(): + if response_callback: + response = response_callback(node, job, suffixes) + else: + response = True, {} + return response + return fake_call + + with mock.patch('swift.obj.reconstructor.ssync_sender', fake_ssync): + yield fake_ssync + + +def make_ec_archive_bodies(policy, test_body): + segment_size = policy.ec_segment_size + # split up the body into buffers + chunks = [test_body[x:x + segment_size] + for x in range(0, len(test_body), segment_size)] + # encode the buffers into fragment payloads + fragment_payloads = [] + for chunk in chunks: + fragments = policy.pyeclib_driver.encode(chunk) + if not fragments: + break + fragment_payloads.append(fragments) + + # join up the fragment payloads per node + ec_archive_bodies = [''.join(fragments) + for fragments in zip(*fragment_payloads)] + return ec_archive_bodies + + +def _ips(): + return ['127.0.0.1'] +object_reconstructor.whataremyips = _ips + + +def _create_test_rings(path): + testgz = os.path.join(path, 'object.ring.gz') + intended_replica2part2dev_id = [ + [0, 1, 2], + [1, 2, 3], + [2, 3, 0] + ] + + intended_devs = [ + {'id': 0, 'device': 'sda1', 'zone': 0, 'ip': '127.0.0.0', + 'port': 6000}, + {'id': 1, 'device': 'sda1', 'zone': 1, 'ip': '127.0.0.1', + 'port': 6000}, + {'id': 2, 'device': 'sda1', 'zone': 2, 'ip': '127.0.0.2', + 'port': 6000}, + {'id': 3, 'device': 'sda1', 'zone': 4, 'ip': '127.0.0.3', + 'port': 6000} + ] + intended_part_shift = 30 + with closing(GzipFile(testgz, 'wb')) as f: + pickle.dump( + ring.RingData(intended_replica2part2dev_id, + intended_devs, intended_part_shift), + f) + + testgz = os.path.join(path, 'object-1.ring.gz') + with closing(GzipFile(testgz, 'wb')) as f: + pickle.dump( + ring.RingData(intended_replica2part2dev_id, + intended_devs, intended_part_shift), + f) + + +def count_stats(logger, key, metric): + count = 0 + for record in logger.log_dict[key]: + log_args, log_kwargs = record + m = log_args[0] + if re.match(metric, m): + count += 1 + return count + + +@patch_policies([StoragePolicy(0, name='zero', is_default=True), + ECStoragePolicy(1, name='one', ec_type='jerasure_rs_vand', + ec_ndata=2, ec_nparity=1)]) +class TestGlobalSetupObjectReconstructor(unittest.TestCase): + + def setUp(self): + self.testdir = tempfile.mkdtemp() + _create_test_rings(self.testdir) + POLICIES[0].object_ring = ring.Ring(self.testdir, ring_name='object') + POLICIES[1].object_ring = ring.Ring(self.testdir, ring_name='object-1') + utils.HASH_PATH_SUFFIX = 'endcap' + utils.HASH_PATH_PREFIX = '' + self.devices = os.path.join(self.testdir, 'node') + os.makedirs(self.devices) + os.mkdir(os.path.join(self.devices, 'sda1')) + self.objects = os.path.join(self.devices, 'sda1', + diskfile.get_data_dir(POLICIES[0])) + self.objects_1 = os.path.join(self.devices, 'sda1', + diskfile.get_data_dir(POLICIES[1])) + os.mkdir(self.objects) + os.mkdir(self.objects_1) + self.parts = {} + self.parts_1 = {} + self.part_nums = ['0', '1', '2'] + for part in self.part_nums: + self.parts[part] = os.path.join(self.objects, part) + os.mkdir(self.parts[part]) + self.parts_1[part] = os.path.join(self.objects_1, part) + os.mkdir(self.parts_1[part]) + + self.conf = dict( + swift_dir=self.testdir, devices=self.devices, mount_check='false', + timeout='300', stats_interval='1') + self.logger = debug_logger('test-reconstructor') + self.reconstructor = object_reconstructor.ObjectReconstructor( + self.conf, logger=self.logger) + + self.policy = POLICIES[1] + + # most of the reconstructor test methods require that there be + # real objects in place, not just part dirs, so we'll create them + # all here.... + # part 0: 3C1/hash/xxx-1.data <-- job: sync_only - parnters (FI 1) + # /xxx.durable <-- included in earlier job (FI 1) + # 061/hash/xxx-1.data <-- included in earlier job (FI 1) + # /xxx.durable <-- included in earlier job (FI 1) + # /xxx-2.data <-- job: sync_revert to index 2 + + # part 1: 3C1/hash/xxx-0.data <-- job: sync_only - parnters (FI 0) + # /xxx-1.data <-- job: sync_revert to index 1 + # /xxx.durable <-- included in earlier jobs (FI 0, 1) + # 061/hash/xxx-1.data <-- included in earlier job (FI 1) + # /xxx.durable <-- included in earlier job (FI 1) + + # part 2: 3C1/hash/xxx-2.data <-- job: sync_revert to index 2 + # /xxx.durable <-- included in earlier job (FI 2) + # 061/hash/xxx-0.data <-- job: sync_revert to index 0 + # /xxx.durable <-- included in earlier job (FI 0) + + def _create_frag_archives(policy, obj_path, local_id, obj_set): + # we'll create 2 sets of objects in different suffix dirs + # so we cover all the scenarios we want (3 of them) + # 1) part dir with all FI's matching the local node index + # 2) part dir with one local and mix of others + # 3) part dir with no local FI and one or more others + def part_0(set): + if set == 0: + # just the local + return local_id + else: + # onde local and all of another + if obj_num == 0: + return local_id + else: + return (local_id + 1) % 3 + + def part_1(set): + if set == 0: + # one local and all of another + if obj_num == 0: + return local_id + else: + return (local_id + 2) % 3 + else: + # just the local node + return local_id + + def part_2(set): + # this part is a handoff in our config (always) + # so lets do a set with indicies from different nodes + if set == 0: + return (local_id + 1) % 3 + else: + return (local_id + 2) % 3 + + # function dictionary for defining test scenarios base on set # + scenarios = {'0': part_0, + '1': part_1, + '2': part_2} + + def _create_df(obj_num, part_num): + self._create_diskfile( + part=part_num, object_name='o' + str(obj_set), + policy=policy, frag_index=scenarios[part_num](obj_set), + timestamp=utils.Timestamp(t)) + + for part_num in self.part_nums: + # create 3 unique objcets per part, each part + # will then have a unique mix of FIs for the + # possible scenarios + for obj_num in range(0, 3): + _create_df(obj_num, part_num) + + ips = utils.whataremyips() + for policy in [p for p in POLICIES if p.policy_type == EC_POLICY]: + self.ec_policy = policy + self.ec_obj_ring = self.reconstructor.load_object_ring( + self.ec_policy) + data_dir = diskfile.get_data_dir(self.ec_policy) + for local_dev in [dev for dev in self.ec_obj_ring.devs + if dev and dev['replication_ip'] in ips and + dev['replication_port'] == + self.reconstructor.port]: + self.ec_local_dev = local_dev + dev_path = os.path.join(self.reconstructor.devices_dir, + self.ec_local_dev['device']) + self.ec_obj_path = os.path.join(dev_path, data_dir) + + # create a bunch of FA's to test + t = 1421181937.70054 # time.time() + with mock.patch('swift.obj.diskfile.time') as mock_time: + # since (a) we are using a fixed time here to create + # frags which corresponds to all the hardcoded hashes and + # (b) the EC diskfile will delete its .data file right + # after creating if it has expired, use this horrible hack + # to prevent the reclaim happening + mock_time.time.return_value = 0.0 + _create_frag_archives(self.ec_policy, self.ec_obj_path, + self.ec_local_dev['id'], 0) + _create_frag_archives(self.ec_policy, self.ec_obj_path, + self.ec_local_dev['id'], 1) + break + break + + def tearDown(self): + rmtree(self.testdir, ignore_errors=1) + + def _create_diskfile(self, policy=None, part=0, object_name='o', + frag_index=0, timestamp=None, test_data=None): + policy = policy or self.policy + df_mgr = self.reconstructor._df_router[policy] + df = df_mgr.get_diskfile('sda1', part, 'a', 'c', object_name, + policy=policy) + with df.create() as writer: + timestamp = timestamp or utils.Timestamp(time.time()) + test_data = test_data or 'test data' + writer.write(test_data) + metadata = { + 'X-Timestamp': timestamp.internal, + 'Content-Length': len(test_data), + 'Etag': md5(test_data).hexdigest(), + 'X-Object-Sysmeta-Ec-Frag-Index': frag_index, + } + writer.put(metadata) + writer.commit(timestamp) + return df + + def assert_expected_jobs(self, part_num, jobs): + for job in jobs: + del job['path'] + del job['policy'] + if 'local_index' in job: + del job['local_index'] + job['suffixes'].sort() + + expected = [] + # part num 0 + expected.append( + [{ + 'sync_to': [{ + 'index': 2, + 'replication_port': 6000, + 'zone': 2, + 'ip': '127.0.0.2', + 'region': 1, + 'port': 6000, + 'replication_ip': '127.0.0.2', + 'device': 'sda1', + 'id': 2, + }], + 'job_type': object_reconstructor.REVERT, + 'suffixes': ['061'], + 'partition': 0, + 'frag_index': 2, + 'device': 'sda1', + 'local_dev': { + 'replication_port': 6000, + 'zone': 1, + 'ip': '127.0.0.1', + 'region': 1, + 'id': 1, + 'replication_ip': '127.0.0.1', + 'device': 'sda1', 'port': 6000, + }, + 'hashes': { + '061': { + None: '85b02a5283704292a511078a5c483da5', + 2: '0e6e8d48d801dc89fd31904ae3b31229', + 1: '0e6e8d48d801dc89fd31904ae3b31229', + }, + '3c1': { + None: '85b02a5283704292a511078a5c483da5', + 1: '0e6e8d48d801dc89fd31904ae3b31229', + }, + }, + }, { + 'sync_to': [{ + 'index': 0, + 'replication_port': 6000, + 'zone': 0, + 'ip': '127.0.0.0', + 'region': 1, + 'port': 6000, + 'replication_ip': '127.0.0.0', + 'device': 'sda1', 'id': 0, + }, { + 'index': 2, + 'replication_port': 6000, + 'zone': 2, + 'ip': '127.0.0.2', + 'region': 1, + 'port': 6000, + 'replication_ip': '127.0.0.2', + 'device': 'sda1', + 'id': 2, + }], + 'job_type': object_reconstructor.SYNC, + 'sync_diskfile_builder': self.reconstructor.reconstruct_fa, + 'suffixes': ['061', '3c1'], + 'partition': 0, + 'frag_index': 1, + 'device': 'sda1', + 'local_dev': { + 'replication_port': 6000, + 'zone': 1, + 'ip': '127.0.0.1', + 'region': 1, + 'id': 1, + 'replication_ip': '127.0.0.1', + 'device': 'sda1', + 'port': 6000, + }, + 'hashes': + { + '061': { + None: '85b02a5283704292a511078a5c483da5', + 2: '0e6e8d48d801dc89fd31904ae3b31229', + 1: '0e6e8d48d801dc89fd31904ae3b31229' + }, + '3c1': { + None: '85b02a5283704292a511078a5c483da5', + 1: '0e6e8d48d801dc89fd31904ae3b31229', + }, + }, + }] + ) + # part num 1 + expected.append( + [{ + 'sync_to': [{ + 'index': 1, + 'replication_port': 6000, + 'zone': 2, + 'ip': '127.0.0.2', + 'region': 1, + 'port': 6000, + 'replication_ip': '127.0.0.2', + 'device': 'sda1', + 'id': 2, + }], + 'job_type': object_reconstructor.REVERT, + 'suffixes': ['061', '3c1'], + 'partition': 1, + 'frag_index': 1, + 'device': 'sda1', + 'local_dev': { + 'replication_port': 6000, + 'zone': 1, + 'ip': '127.0.0.1', + 'region': 1, + 'id': 1, + 'replication_ip': '127.0.0.1', + 'device': 'sda1', + 'port': 6000, + }, + 'hashes': + { + '061': { + None: '85b02a5283704292a511078a5c483da5', + 1: '0e6e8d48d801dc89fd31904ae3b31229', + }, + '3c1': { + 0: '0e6e8d48d801dc89fd31904ae3b31229', + None: '85b02a5283704292a511078a5c483da5', + 1: '0e6e8d48d801dc89fd31904ae3b31229', + }, + }, + }, { + 'sync_to': [{ + 'index': 2, + 'replication_port': 6000, + 'zone': 4, + 'ip': '127.0.0.3', + 'region': 1, + 'port': 6000, + 'replication_ip': '127.0.0.3', + 'device': 'sda1', 'id': 3, + }, { + 'index': 1, + 'replication_port': 6000, + 'zone': 2, + 'ip': '127.0.0.2', + 'region': 1, + 'port': 6000, + 'replication_ip': '127.0.0.2', + 'device': 'sda1', + 'id': 2, + }], + 'job_type': object_reconstructor.SYNC, + 'sync_diskfile_builder': self.reconstructor.reconstruct_fa, + 'suffixes': ['3c1'], + 'partition': 1, + 'frag_index': 0, + 'device': 'sda1', + 'local_dev': { + 'replication_port': 6000, + 'zone': 1, + 'ip': '127.0.0.1', + 'region': 1, + 'id': 1, + 'replication_ip': '127.0.0.1', + 'device': 'sda1', + 'port': 6000, + }, + 'hashes': { + '061': { + None: '85b02a5283704292a511078a5c483da5', + 1: '0e6e8d48d801dc89fd31904ae3b31229', + }, + '3c1': { + 0: '0e6e8d48d801dc89fd31904ae3b31229', + None: '85b02a5283704292a511078a5c483da5', + 1: '0e6e8d48d801dc89fd31904ae3b31229', + }, + }, + }] + ) + # part num 2 + expected.append( + [{ + 'sync_to': [{ + 'index': 0, + 'replication_port': 6000, + 'zone': 2, + 'ip': '127.0.0.2', + 'region': 1, + 'port': 6000, + 'replication_ip': '127.0.0.2', + 'device': 'sda1', 'id': 2, + }], + 'job_type': object_reconstructor.REVERT, + 'suffixes': ['061'], + 'partition': 2, + 'frag_index': 0, + 'device': 'sda1', + 'local_dev': { + 'replication_port': 6000, + 'zone': 1, + 'ip': '127.0.0.1', + 'region': 1, + 'id': 1, + 'replication_ip': '127.0.0.1', + 'device': 'sda1', + 'port': 6000, + }, + 'hashes': { + '061': { + 0: '0e6e8d48d801dc89fd31904ae3b31229', + None: '85b02a5283704292a511078a5c483da5' + }, + '3c1': { + None: '85b02a5283704292a511078a5c483da5', + 2: '0e6e8d48d801dc89fd31904ae3b31229' + }, + }, + }, { + 'sync_to': [{ + 'index': 2, + 'replication_port': 6000, + 'zone': 0, + 'ip': '127.0.0.0', + 'region': 1, + 'port': 6000, + 'replication_ip': '127.0.0.0', + 'device': 'sda1', + 'id': 0, + }], + 'job_type': object_reconstructor.REVERT, + 'suffixes': ['3c1'], + 'partition': 2, + 'frag_index': 2, + 'device': 'sda1', + 'local_dev': { + 'replication_port': 6000, + 'zone': 1, + 'ip': '127.0.0.1', + 'region': 1, + 'id': 1, + 'replication_ip': '127.0.0.1', + 'device': 'sda1', + 'port': 6000 + }, + 'hashes': { + '061': { + 0: '0e6e8d48d801dc89fd31904ae3b31229', + None: '85b02a5283704292a511078a5c483da5' + }, + '3c1': { + None: '85b02a5283704292a511078a5c483da5', + 2: '0e6e8d48d801dc89fd31904ae3b31229' + }, + }, + }] + ) + + def check_jobs(part_num): + try: + expected_jobs = expected[int(part_num)] + except (IndexError, ValueError): + self.fail('Unknown part number %r' % part_num) + expected_by_part_frag_index = dict( + ((j['partition'], j['frag_index']), j) for j in expected_jobs) + for job in jobs: + job_key = (job['partition'], job['frag_index']) + if job_key in expected_by_part_frag_index: + for k, value in job.items(): + expected_value = \ + expected_by_part_frag_index[job_key][k] + try: + if isinstance(value, list): + value.sort() + expected_value.sort() + self.assertEqual(value, expected_value) + except AssertionError as e: + extra_info = \ + '\n\n... for %r in part num %s job %r' % ( + k, part_num, job_key) + raise AssertionError(str(e) + extra_info) + else: + self.fail( + 'Unexpected job %r for part num %s - ' + 'expected jobs where %r' % ( + job_key, part_num, + expected_by_part_frag_index.keys())) + for expected_job in expected_jobs: + if expected_job in jobs: + jobs.remove(expected_job) + self.assertFalse(jobs) # that should be all of them + check_jobs(part_num) + + def test_run_once(self): + with mocked_http_conn(*[200] * 12, body=pickle.dumps({})): + with mock_ssync_sender(): + self.reconstructor.run_once() + + def test_get_response(self): + part = self.part_nums[0] + node = POLICIES[0].object_ring.get_part_nodes(int(part))[0] + for stat_code in (200, 400): + with mocked_http_conn(stat_code): + resp = self.reconstructor._get_response(node, part, + path='nada', + headers={}, + policy=POLICIES[0]) + if resp: + self.assertEqual(resp.status, 200) + else: + self.assertEqual( + len(self.reconstructor.logger.log_dict['warning']), 1) + + def test_reconstructor_skips_bogus_partition_dirs(self): + # A directory in the wrong place shouldn't crash the reconstructor + rmtree(self.objects_1) + os.mkdir(self.objects_1) + + os.mkdir(os.path.join(self.objects_1, "burrito")) + jobs = [] + for part_info in self.reconstructor.collect_parts(): + jobs += self.reconstructor.build_reconstruction_jobs(part_info) + self.assertEqual(len(jobs), 0) + + def test_check_ring(self): + testring = tempfile.mkdtemp() + _create_test_rings(testring) + obj_ring = ring.Ring(testring, ring_name='object') # noqa + self.assertTrue(self.reconstructor.check_ring(obj_ring)) + orig_check = self.reconstructor.next_check + self.reconstructor.next_check = orig_check - 30 + self.assertTrue(self.reconstructor.check_ring(obj_ring)) + self.reconstructor.next_check = orig_check + orig_ring_time = obj_ring._mtime + obj_ring._mtime = orig_ring_time - 30 + self.assertTrue(self.reconstructor.check_ring(obj_ring)) + self.reconstructor.next_check = orig_check - 30 + self.assertFalse(self.reconstructor.check_ring(obj_ring)) + rmtree(testring, ignore_errors=1) + + def test_build_reconstruction_jobs(self): + self.reconstructor.handoffs_first = False + self.reconstructor._reset_stats() + for part_info in self.reconstructor.collect_parts(): + jobs = self.reconstructor.build_reconstruction_jobs(part_info) + self.assertTrue(jobs[0]['job_type'] in + (object_reconstructor.SYNC, + object_reconstructor.REVERT)) + self.assert_expected_jobs(part_info['partition'], jobs) + + self.reconstructor.handoffs_first = True + self.reconstructor._reset_stats() + for part_info in self.reconstructor.collect_parts(): + jobs = self.reconstructor.build_reconstruction_jobs(part_info) + self.assertTrue(jobs[0]['job_type'] == + object_reconstructor.REVERT) + self.assert_expected_jobs(part_info['partition'], jobs) + + def test_get_partners(self): + # we're going to perform an exhaustive test of every possible + # combination of partitions and nodes in our custom test ring + + # format: [dev_id in question, 'part_num', + # [part_nodes for the given part], left id, right id...] + expected_partners = sorted([ + (0, '0', [0, 1, 2], 2, 1), (0, '2', [2, 3, 0], 3, 2), + (1, '0', [0, 1, 2], 0, 2), (1, '1', [1, 2, 3], 3, 2), + (2, '0', [0, 1, 2], 1, 0), (2, '1', [1, 2, 3], 1, 3), + (2, '2', [2, 3, 0], 0, 3), (3, '1', [1, 2, 3], 2, 1), + (3, '2', [2, 3, 0], 2, 0), (0, '0', [0, 1, 2], 2, 1), + (0, '2', [2, 3, 0], 3, 2), (1, '0', [0, 1, 2], 0, 2), + (1, '1', [1, 2, 3], 3, 2), (2, '0', [0, 1, 2], 1, 0), + (2, '1', [1, 2, 3], 1, 3), (2, '2', [2, 3, 0], 0, 3), + (3, '1', [1, 2, 3], 2, 1), (3, '2', [2, 3, 0], 2, 0), + ]) + + got_partners = [] + for pol in POLICIES: + obj_ring = pol.object_ring + for part_num in self.part_nums: + part_nodes = obj_ring.get_part_nodes(int(part_num)) + primary_ids = [n['id'] for n in part_nodes] + for node in part_nodes: + partners = object_reconstructor._get_partners( + node['index'], part_nodes) + left = partners[0]['id'] + right = partners[1]['id'] + got_partners.append(( + node['id'], part_num, primary_ids, left, right)) + + self.assertEqual(expected_partners, sorted(got_partners)) + + def test_collect_parts(self): + parts = [] + for part_info in self.reconstructor.collect_parts(): + parts.append(part_info['partition']) + self.assertEqual(sorted(parts), [0, 1, 2]) + + def test_collect_parts_mkdirs_error(self): + + def blowup_mkdirs(path): + raise OSError('Ow!') + + with mock.patch.object(object_reconstructor, 'mkdirs', blowup_mkdirs): + rmtree(self.objects_1, ignore_errors=1) + parts = [] + for part_info in self.reconstructor.collect_parts(): + parts.append(part_info['partition']) + error_lines = self.logger.get_lines_for_level('error') + self.assertEqual(len(error_lines), 1) + log_args, log_kwargs = self.logger.log_dict['error'][0] + self.assertEquals(str(log_kwargs['exc_info'][1]), 'Ow!') + + def test_removes_zbf(self): + # After running xfs_repair, a partition directory could become a + # zero-byte file. If this happens, the reconstructor should clean it + # up, log something, and move on to the next partition. + + # Surprise! Partition dir 1 is actually a zero-byte file. + pol_1_part_1_path = os.path.join(self.objects_1, '1') + rmtree(pol_1_part_1_path) + with open(pol_1_part_1_path, 'w'): + pass + self.assertTrue(os.path.isfile(pol_1_part_1_path)) # sanity check + + # since our collect_parts job is a generator, that yields directly + # into build_jobs and then spawns it's safe to do the remove_files + # without making reconstructor startup slow + for part_info in self.reconstructor.collect_parts(): + self.assertNotEqual(pol_1_part_1_path, part_info['part_path']) + self.assertFalse(os.path.exists(pol_1_part_1_path)) + warnings = self.reconstructor.logger.get_lines_for_level('warning') + self.assertEqual(1, len(warnings)) + self.assertTrue('Unexpected entity in data dir:' in warnings[0], + 'Warning not found in %s' % warnings) + + def _make_fake_ssync(self, ssync_calls): + class _fake_ssync(object): + def __init__(self, daemon, node, job, suffixes, **kwargs): + # capture context and generate an available_map of objs + context = {} + context['node'] = node + context['job'] = job + context['suffixes'] = suffixes + self.suffixes = suffixes + self.daemon = daemon + self.job = job + hash_gen = self.daemon._diskfile_mgr.yield_hashes( + self.job['device'], self.job['partition'], + self.job['policy'], self.suffixes, + frag_index=self.job.get('frag_index')) + self.available_map = {} + for path, hash_, ts in hash_gen: + self.available_map[hash_] = ts + context['available_map'] = self.available_map + ssync_calls.append(context) + + def __call__(self, *args, **kwargs): + return True, self.available_map + + return _fake_ssync + + def test_delete_reverted(self): + # verify reconstructor deletes reverted frag indexes after ssync'ing + + def visit_obj_dirs(context): + for suff in context['suffixes']: + suff_dir = os.path.join( + context['job']['path'], suff) + for root, dirs, files in os.walk(suff_dir): + for d in dirs: + dirpath = os.path.join(root, d) + files = os.listdir(dirpath) + yield dirpath, files + + n_files = n_files_after = 0 + + # run reconstructor with delete function mocked out to check calls + ssync_calls = [] + delete_func =\ + 'swift.obj.reconstructor.ObjectReconstructor.delete_reverted_objs' + with mock.patch('swift.obj.reconstructor.ssync_sender', + self._make_fake_ssync(ssync_calls)): + with mocked_http_conn(*[200] * 12, body=pickle.dumps({})): + with mock.patch(delete_func) as mock_delete: + self.reconstructor.reconstruct() + expected_calls = [] + for context in ssync_calls: + if context['job']['job_type'] == REVERT: + for dirpath, files in visit_obj_dirs(context): + # sanity check - expect some files to be in dir, + # may not be for the reverted frag index + self.assertTrue(files) + n_files += len(files) + expected_calls.append(mock.call(context['job'], + context['available_map'], + context['node']['index'])) + mock_delete.assert_has_calls(expected_calls, any_order=True) + + ssync_calls = [] + with mock.patch('swift.obj.reconstructor.ssync_sender', + self._make_fake_ssync(ssync_calls)): + with mocked_http_conn(*[200] * 12, body=pickle.dumps({})): + self.reconstructor.reconstruct() + for context in ssync_calls: + if context['job']['job_type'] == REVERT: + data_file_tail = ('#%s.data' + % context['node']['index']) + for dirpath, files in visit_obj_dirs(context): + n_files_after += len(files) + for filename in files: + self.assertFalse( + filename.endswith(data_file_tail)) + + # sanity check that some files should were deleted + self.assertTrue(n_files > n_files_after) + + def test_get_part_jobs(self): + # yeah, this test code expects a specific setup + self.assertEqual(len(self.part_nums), 3) + + # OK, at this point we should have 4 loaded parts with one + jobs = [] + for partition in os.listdir(self.ec_obj_path): + part_path = os.path.join(self.ec_obj_path, partition) + jobs = self.reconstructor._get_part_jobs( + self.ec_local_dev, part_path, int(partition), self.ec_policy) + self.assert_expected_jobs(partition, jobs) + + def assertStatCount(self, stat_method, stat_prefix, expected_count): + count = count_stats(self.logger, stat_method, stat_prefix) + msg = 'expected %s != %s for %s %s' % ( + expected_count, count, stat_method, stat_prefix) + self.assertEqual(expected_count, count, msg) + + def test_delete_partition(self): + # part 2 is predefined to have all revert jobs + part_path = os.path.join(self.objects_1, '2') + self.assertTrue(os.access(part_path, os.F_OK)) + + ssync_calls = [] + status = [200] * 2 + body = pickle.dumps({}) + with mocked_http_conn(*status, body=body) as request_log: + with mock.patch('swift.obj.reconstructor.ssync_sender', + self._make_fake_ssync(ssync_calls)): + self.reconstructor.reconstruct(override_partitions=[2]) + expected_repliate_calls = set([ + ('127.0.0.0', '/sda1/2/3c1'), + ('127.0.0.2', '/sda1/2/061'), + ]) + found_calls = set((r['ip'], r['path']) + for r in request_log.requests) + self.assertEqual(expected_repliate_calls, found_calls) + + expected_ssync_calls = sorted([ + ('127.0.0.0', REVERT, 2, ['3c1']), + ('127.0.0.2', REVERT, 2, ['061']), + ]) + self.assertEqual(expected_ssync_calls, sorted(( + c['node']['ip'], + c['job']['job_type'], + c['job']['partition'], + c['suffixes'], + ) for c in ssync_calls)) + + expected_stats = { + ('increment', 'partition.delete.count.'): 2, + ('timing_since', 'partition.delete.timing'): 2, + } + for stat_key, expected in expected_stats.items(): + stat_method, stat_prefix = stat_key + self.assertStatCount(stat_method, stat_prefix, expected) + # part 2 should be totally empty + policy = POLICIES[1] + hash_gen = self.reconstructor._df_router[policy].yield_hashes( + 'sda1', '2', policy) + for path, hash_, ts in hash_gen: + self.fail('found %s with %s in %s', (hash_, ts, path)) + # but the partition directory and hashes pkl still exist + self.assertTrue(os.access(part_path, os.F_OK)) + hashes_path = os.path.join(self.objects_1, '2', diskfile.HASH_FILE) + self.assertTrue(os.access(hashes_path, os.F_OK)) + + # ... but on next pass + ssync_calls = [] + with mocked_http_conn() as request_log: + with mock.patch('swift.obj.reconstructor.ssync_sender', + self._make_fake_ssync(ssync_calls)): + self.reconstructor.reconstruct(override_partitions=[2]) + # reconstruct won't generate any replicate or ssync_calls + self.assertFalse(request_log.requests) + self.assertFalse(ssync_calls) + # and the partition will get removed! + self.assertFalse(os.access(part_path, os.F_OK)) + + def test_process_job_all_success(self): + self.reconstructor._reset_stats() + with mock_ssync_sender(): + with mocked_http_conn(*[200] * 12, body=pickle.dumps({})): + found_jobs = [] + for part_info in self.reconstructor.collect_parts(): + jobs = self.reconstructor.build_reconstruction_jobs( + part_info) + found_jobs.extend(jobs) + for job in jobs: + self.logger._clear() + node_count = len(job['sync_to']) + self.reconstructor.process_job(job) + if job['job_type'] == object_reconstructor.REVERT: + self.assertEqual(0, count_stats( + self.logger, 'update_stats', 'suffix.hashes')) + else: + self.assertStatCount('update_stats', + 'suffix.hashes', + node_count) + self.assertEqual(node_count, count_stats( + self.logger, 'update_stats', 'suffix.hashes')) + self.assertEqual(node_count, count_stats( + self.logger, 'update_stats', 'suffix.syncs')) + self.assertFalse('error' in + self.logger.all_log_lines()) + self.assertEqual(self.reconstructor.suffix_sync, 8) + self.assertEqual(self.reconstructor.suffix_count, 8) + self.assertEqual(len(found_jobs), 6) + + def test_process_job_all_insufficient_storage(self): + self.reconstructor._reset_stats() + with mock_ssync_sender(): + with mocked_http_conn(*[507] * 10): + found_jobs = [] + for part_info in self.reconstructor.collect_parts(): + jobs = self.reconstructor.build_reconstruction_jobs( + part_info) + found_jobs.extend(jobs) + for job in jobs: + self.logger._clear() + self.reconstructor.process_job(job) + for line in self.logger.get_lines_for_level('error'): + self.assertTrue('responded as unmounted' in line) + self.assertEqual(0, count_stats( + self.logger, 'update_stats', 'suffix.hashes')) + self.assertEqual(0, count_stats( + self.logger, 'update_stats', 'suffix.syncs')) + self.assertEqual(self.reconstructor.suffix_sync, 0) + self.assertEqual(self.reconstructor.suffix_count, 0) + self.assertEqual(len(found_jobs), 6) + + def test_process_job_all_client_error(self): + self.reconstructor._reset_stats() + with mock_ssync_sender(): + with mocked_http_conn(*[400] * 10): + found_jobs = [] + for part_info in self.reconstructor.collect_parts(): + jobs = self.reconstructor.build_reconstruction_jobs( + part_info) + found_jobs.extend(jobs) + for job in jobs: + self.logger._clear() + self.reconstructor.process_job(job) + for line in self.logger.get_lines_for_level('error'): + self.assertTrue('Invalid response 400' in line) + self.assertEqual(0, count_stats( + self.logger, 'update_stats', 'suffix.hashes')) + self.assertEqual(0, count_stats( + self.logger, 'update_stats', 'suffix.syncs')) + self.assertEqual(self.reconstructor.suffix_sync, 0) + self.assertEqual(self.reconstructor.suffix_count, 0) + self.assertEqual(len(found_jobs), 6) + + def test_process_job_all_timeout(self): + self.reconstructor._reset_stats() + with mock_ssync_sender(): + with nested(mocked_http_conn(*[Timeout()] * 10)): + found_jobs = [] + for part_info in self.reconstructor.collect_parts(): + jobs = self.reconstructor.build_reconstruction_jobs( + part_info) + found_jobs.extend(jobs) + for job in jobs: + self.logger._clear() + self.reconstructor.process_job(job) + for line in self.logger.get_lines_for_level('error'): + self.assertTrue('Timeout (Nones)' in line) + self.assertStatCount( + 'update_stats', 'suffix.hashes', 0) + self.assertStatCount( + 'update_stats', 'suffix.syncs', 0) + self.assertEqual(self.reconstructor.suffix_sync, 0) + self.assertEqual(self.reconstructor.suffix_count, 0) + self.assertEqual(len(found_jobs), 6) + + +@patch_policies(with_ec_default=True) +class TestObjectReconstructor(unittest.TestCase): + + def setUp(self): + self.policy = POLICIES.default + self.testdir = tempfile.mkdtemp() + self.devices = os.path.join(self.testdir, 'devices') + self.local_dev = self.policy.object_ring.devs[0] + self.ip = self.local_dev['replication_ip'] + self.port = self.local_dev['replication_port'] + self.conf = { + 'devices': self.devices, + 'mount_check': False, + 'bind_port': self.port, + } + self.logger = debug_logger('object-reconstructor') + self.reconstructor = object_reconstructor.ObjectReconstructor( + self.conf, logger=self.logger) + self.reconstructor._reset_stats() + # some tests bypass build_reconstruction_jobs and go to process_job + # directly, so you end up with a /0 when you try to show the + # percentage of complete jobs as ratio of the total job count + self.reconstructor.job_count = 1 + self.policy.object_ring.max_more_nodes = \ + self.policy.object_ring.replicas + self.ts_iter = make_timestamp_iter() + + def tearDown(self): + self.reconstructor.stats_line() + shutil.rmtree(self.testdir) + + def ts(self): + return next(self.ts_iter) + + def test_collect_parts_skips_non_ec_policy_and_device(self): + stub_parts = (371, 78, 419, 834) + for policy in POLICIES: + datadir = diskfile.get_data_dir(policy) + for part in stub_parts: + utils.mkdirs(os.path.join( + self.devices, self.local_dev['device'], + datadir, str(part))) + with mock.patch('swift.obj.reconstructor.whataremyips', + return_value=[self.ip]): + part_infos = list(self.reconstructor.collect_parts()) + found_parts = sorted(int(p['partition']) for p in part_infos) + self.assertEqual(found_parts, sorted(stub_parts)) + for part_info in part_infos: + self.assertEqual(part_info['local_dev'], self.local_dev) + self.assertEqual(part_info['policy'], self.policy) + self.assertEqual(part_info['part_path'], + os.path.join(self.devices, + self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(part_info['partition']))) + + def test_collect_parts_multi_device_skips_non_ring_devices(self): + device_parts = { + 'sda': (374,), + 'sdb': (179, 807), + 'sdc': (363, 468, 843), + } + for policy in POLICIES: + datadir = diskfile.get_data_dir(policy) + for dev, parts in device_parts.items(): + for part in parts: + utils.mkdirs(os.path.join( + self.devices, dev, + datadir, str(part))) + + # we're only going to add sda and sdc into the ring + local_devs = ('sda', 'sdc') + stub_ring_devs = [{ + 'device': dev, + 'replication_ip': self.ip, + 'replication_port': self.port + } for dev in local_devs] + with nested(mock.patch('swift.obj.reconstructor.whataremyips', + return_value=[self.ip]), + mock.patch.object(self.policy.object_ring, '_devs', + new=stub_ring_devs)): + part_infos = list(self.reconstructor.collect_parts()) + found_parts = sorted(int(p['partition']) for p in part_infos) + expected_parts = sorted(itertools.chain( + *(device_parts[d] for d in local_devs))) + self.assertEqual(found_parts, expected_parts) + for part_info in part_infos: + self.assertEqual(part_info['policy'], self.policy) + self.assertTrue(part_info['local_dev'] in stub_ring_devs) + dev = part_info['local_dev'] + self.assertEqual(part_info['part_path'], + os.path.join(self.devices, + dev['device'], + diskfile.get_data_dir(self.policy), + str(part_info['partition']))) + + def test_collect_parts_mount_check(self): + # each device has one part in it + local_devs = ('sda', 'sdb') + for i, dev in enumerate(local_devs): + datadir = diskfile.get_data_dir(self.policy) + utils.mkdirs(os.path.join( + self.devices, dev, datadir, str(i))) + stub_ring_devs = [{ + 'device': dev, + 'replication_ip': self.ip, + 'replication_port': self.port + } for dev in local_devs] + with nested(mock.patch('swift.obj.reconstructor.whataremyips', + return_value=[self.ip]), + mock.patch.object(self.policy.object_ring, '_devs', + new=stub_ring_devs)): + part_infos = list(self.reconstructor.collect_parts()) + self.assertEqual(2, len(part_infos)) # sanity + self.assertEqual(set(int(p['partition']) for p in part_infos), + set([0, 1])) + + paths = [] + + def fake_ismount(path): + paths.append(path) + return False + + with nested(mock.patch('swift.obj.reconstructor.whataremyips', + return_value=[self.ip]), + mock.patch.object(self.policy.object_ring, '_devs', + new=stub_ring_devs), + mock.patch('swift.obj.reconstructor.ismount', + fake_ismount)): + part_infos = list(self.reconstructor.collect_parts()) + self.assertEqual(2, len(part_infos)) # sanity, same jobs + self.assertEqual(set(int(p['partition']) for p in part_infos), + set([0, 1])) + + # ... because ismount was not called + self.assertEqual(paths, []) + + # ... now with mount check + self.reconstructor.mount_check = True + with nested(mock.patch('swift.obj.reconstructor.whataremyips', + return_value=[self.ip]), + mock.patch.object(self.policy.object_ring, '_devs', + new=stub_ring_devs), + mock.patch('swift.obj.reconstructor.ismount', + fake_ismount)): + part_infos = list(self.reconstructor.collect_parts()) + self.assertEqual([], part_infos) # sanity, no jobs + + # ... because fake_ismount returned False for both paths + self.assertEqual(set(paths), set([ + os.path.join(self.devices, dev) for dev in local_devs])) + + def fake_ismount(path): + if path.endswith('sda'): + return True + else: + return False + + with nested(mock.patch('swift.obj.reconstructor.whataremyips', + return_value=[self.ip]), + mock.patch.object(self.policy.object_ring, '_devs', + new=stub_ring_devs), + mock.patch('swift.obj.reconstructor.ismount', + fake_ismount)): + part_infos = list(self.reconstructor.collect_parts()) + self.assertEqual(1, len(part_infos)) # only sda picked up (part 0) + self.assertEqual(part_infos[0]['partition'], 0) + + def test_collect_parts_cleans_tmp(self): + local_devs = ('sda', 'sdc') + stub_ring_devs = [{ + 'device': dev, + 'replication_ip': self.ip, + 'replication_port': self.port + } for dev in local_devs] + fake_unlink = mock.MagicMock() + self.reconstructor.reclaim_age = 1000 + now = time.time() + with nested(mock.patch('swift.obj.reconstructor.whataremyips', + return_value=[self.ip]), + mock.patch('swift.obj.reconstructor.time.time', + return_value=now), + mock.patch.object(self.policy.object_ring, '_devs', + new=stub_ring_devs), + mock.patch('swift.obj.reconstructor.unlink_older_than', + fake_unlink)): + self.assertEqual([], list(self.reconstructor.collect_parts())) + # each local device hash unlink_older_than called on it, + # with now - self.reclaim_age + tmpdir = diskfile.get_tmp_dir(self.policy) + expected = now - 1000 + self.assertEqual(fake_unlink.mock_calls, [ + mock.call(os.path.join(self.devices, dev, tmpdir), expected) + for dev in local_devs]) + + def test_collect_parts_creates_datadir(self): + # create just the device path + dev_path = os.path.join(self.devices, self.local_dev['device']) + utils.mkdirs(dev_path) + with mock.patch('swift.obj.reconstructor.whataremyips', + return_value=[self.ip]): + self.assertEqual([], list(self.reconstructor.collect_parts())) + datadir_path = os.path.join(dev_path, + diskfile.get_data_dir(self.policy)) + self.assertTrue(os.path.exists(datadir_path)) + + def test_collect_parts_creates_datadir_error(self): + # create just the device path + datadir_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy)) + utils.mkdirs(os.path.dirname(datadir_path)) + with nested(mock.patch('swift.obj.reconstructor.whataremyips', + return_value=[self.ip]), + mock.patch('swift.obj.reconstructor.mkdirs', + side_effect=OSError('kaboom!'))): + self.assertEqual([], list(self.reconstructor.collect_parts())) + error_lines = self.logger.get_lines_for_level('error') + self.assertEqual(len(error_lines), 1) + line = error_lines[0] + self.assertTrue('Unable to create' in line) + self.assertTrue(datadir_path in line) + + def test_collect_parts_skips_invalid_paths(self): + datadir_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy)) + utils.mkdirs(os.path.dirname(datadir_path)) + with open(datadir_path, 'w') as f: + f.write('junk') + with mock.patch('swift.obj.reconstructor.whataremyips', + return_value=[self.ip]): + self.assertEqual([], list(self.reconstructor.collect_parts())) + self.assertTrue(os.path.exists(datadir_path)) + error_lines = self.logger.get_lines_for_level('error') + self.assertEqual(len(error_lines), 1) + line = error_lines[0] + self.assertTrue('Unable to list partitions' in line) + self.assertTrue(datadir_path in line) + + def test_collect_parts_removes_non_partition_files(self): + # create some junk next to partitions + datadir_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy)) + num_parts = 3 + for part in range(num_parts): + utils.mkdirs(os.path.join(datadir_path, str(part))) + junk_file = os.path.join(datadir_path, 'junk') + with open(junk_file, 'w') as f: + f.write('junk') + with mock.patch('swift.obj.reconstructor.whataremyips', + return_value=[self.ip]): + part_infos = list(self.reconstructor.collect_parts()) + # the file is not included in the part_infos map + self.assertEqual(sorted(p['part_path'] for p in part_infos), + sorted([os.path.join(datadir_path, str(i)) + for i in range(num_parts)])) + # and gets cleaned up + self.assertFalse(os.path.exists(junk_file)) + + def test_collect_parts_overrides(self): + # setup multiple devices, with multiple parts + device_parts = { + 'sda': (374, 843), + 'sdb': (179, 807), + 'sdc': (363, 468, 843), + } + datadir = diskfile.get_data_dir(self.policy) + for dev, parts in device_parts.items(): + for part in parts: + utils.mkdirs(os.path.join( + self.devices, dev, + datadir, str(part))) + + # we're only going to add sda and sdc into the ring + local_devs = ('sda', 'sdc') + stub_ring_devs = [{ + 'device': dev, + 'replication_ip': self.ip, + 'replication_port': self.port + } for dev in local_devs] + + expected = ( + ({}, [ + ('sda', 374), + ('sda', 843), + ('sdc', 363), + ('sdc', 468), + ('sdc', 843), + ]), + ({'override_devices': ['sda', 'sdc']}, [ + ('sda', 374), + ('sda', 843), + ('sdc', 363), + ('sdc', 468), + ('sdc', 843), + ]), + ({'override_devices': ['sdc']}, [ + ('sdc', 363), + ('sdc', 468), + ('sdc', 843), + ]), + ({'override_devices': ['sda']}, [ + ('sda', 374), + ('sda', 843), + ]), + ({'override_devices': ['sdx']}, []), + ({'override_partitions': [374]}, [ + ('sda', 374), + ]), + ({'override_partitions': [843]}, [ + ('sda', 843), + ('sdc', 843), + ]), + ({'override_partitions': [843], 'override_devices': ['sda']}, [ + ('sda', 843), + ]), + ) + with nested(mock.patch('swift.obj.reconstructor.whataremyips', + return_value=[self.ip]), + mock.patch.object(self.policy.object_ring, '_devs', + new=stub_ring_devs)): + for kwargs, expected_parts in expected: + part_infos = list(self.reconstructor.collect_parts(**kwargs)) + expected_paths = set( + os.path.join(self.devices, dev, datadir, str(part)) + for dev, part in expected_parts) + found_paths = set(p['part_path'] for p in part_infos) + msg = 'expected %r != %r for %r' % ( + expected_paths, found_paths, kwargs) + self.assertEqual(expected_paths, found_paths, msg) + + def test_build_jobs_creates_empty_hashes(self): + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), '0') + utils.mkdirs(part_path) + part_info = { + 'local_dev': self.local_dev, + 'policy': self.policy, + 'partition': 0, + 'part_path': part_path, + } + jobs = self.reconstructor.build_reconstruction_jobs(part_info) + self.assertEqual(1, len(jobs)) + job = jobs[0] + self.assertEqual(job['job_type'], object_reconstructor.SYNC) + self.assertEqual(job['frag_index'], 0) + self.assertEqual(job['suffixes'], []) + self.assertEqual(len(job['sync_to']), 2) + self.assertEqual(job['partition'], 0) + self.assertEqual(job['path'], part_path) + self.assertEqual(job['hashes'], {}) + self.assertEqual(job['policy'], self.policy) + self.assertEqual(job['local_dev'], self.local_dev) + self.assertEqual(job['device'], self.local_dev['device']) + hashes_file = os.path.join(part_path, + diskfile.HASH_FILE) + self.assertTrue(os.path.exists(hashes_file)) + suffixes = self.reconstructor._get_hashes( + self.policy, part_path, do_listdir=True) + self.assertEqual(suffixes, {}) + + def test_build_jobs_no_hashes(self): + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), '0') + part_info = { + 'local_dev': self.local_dev, + 'policy': self.policy, + 'partition': 0, + 'part_path': part_path, + } + stub_hashes = {} + with mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes)): + jobs = self.reconstructor.build_reconstruction_jobs(part_info) + self.assertEqual(1, len(jobs)) + job = jobs[0] + self.assertEqual(job['job_type'], object_reconstructor.SYNC) + self.assertEqual(job['frag_index'], 0) + self.assertEqual(job['suffixes'], []) + self.assertEqual(len(job['sync_to']), 2) + self.assertEqual(job['partition'], 0) + self.assertEqual(job['path'], part_path) + self.assertEqual(job['hashes'], {}) + self.assertEqual(job['policy'], self.policy) + self.assertEqual(job['local_dev'], self.local_dev) + self.assertEqual(job['device'], self.local_dev['device']) + + def test_build_jobs_primary(self): + ring = self.policy.object_ring = FabricatedRing() + # find a partition for which we're a primary + for partition in range(2 ** ring.part_power): + part_nodes = ring.get_part_nodes(partition) + try: + frag_index = [n['id'] for n in part_nodes].index( + self.local_dev['id']) + except ValueError: + pass + else: + break + else: + self.fail("the ring doesn't work: %r" % ring._replica2part2dev_id) + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + part_info = { + 'local_dev': self.local_dev, + 'policy': self.policy, + 'partition': partition, + 'part_path': part_path, + } + stub_hashes = { + '123': {frag_index: 'hash', None: 'hash'}, + 'abc': {frag_index: 'hash', None: 'hash'}, + } + with mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes)): + jobs = self.reconstructor.build_reconstruction_jobs(part_info) + self.assertEqual(1, len(jobs)) + job = jobs[0] + self.assertEqual(job['job_type'], object_reconstructor.SYNC) + self.assertEqual(job['frag_index'], frag_index) + self.assertEqual(job['suffixes'], stub_hashes.keys()) + self.assertEqual(set([n['index'] for n in job['sync_to']]), + set([(frag_index + 1) % ring.replicas, + (frag_index - 1) % ring.replicas])) + self.assertEqual(job['partition'], partition) + self.assertEqual(job['path'], part_path) + self.assertEqual(job['hashes'], stub_hashes) + self.assertEqual(job['policy'], self.policy) + self.assertEqual(job['local_dev'], self.local_dev) + self.assertEqual(job['device'], self.local_dev['device']) + + def test_build_jobs_handoff(self): + ring = self.policy.object_ring = FabricatedRing() + # find a partition for which we're a handoff + for partition in range(2 ** ring.part_power): + part_nodes = ring.get_part_nodes(partition) + if self.local_dev['id'] not in [n['id'] for n in part_nodes]: + break + else: + self.fail("the ring doesn't work: %r" % ring._replica2part2dev_id) + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + part_info = { + 'local_dev': self.local_dev, + 'policy': self.policy, + 'partition': partition, + 'part_path': part_path, + } + # since this part doesn't belong on us it doesn't matter what + # frag_index we have + frag_index = random.randint(0, ring.replicas - 1) + stub_hashes = { + '123': {frag_index: 'hash', None: 'hash'}, + 'abc': {None: 'hash'}, + } + with mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes)): + jobs = self.reconstructor.build_reconstruction_jobs(part_info) + self.assertEqual(1, len(jobs)) + job = jobs[0] + self.assertEqual(job['job_type'], object_reconstructor.REVERT) + self.assertEqual(job['frag_index'], frag_index) + self.assertEqual(sorted(job['suffixes']), sorted(stub_hashes.keys())) + self.assertEqual(len(job['sync_to']), 1) + self.assertEqual(job['sync_to'][0]['index'], frag_index) + self.assertEqual(job['path'], part_path) + self.assertEqual(job['partition'], partition) + self.assertEqual(sorted(job['hashes']), sorted(stub_hashes)) + self.assertEqual(job['local_dev'], self.local_dev) + + def test_build_jobs_mixed(self): + ring = self.policy.object_ring = FabricatedRing() + # find a partition for which we're a primary + for partition in range(2 ** ring.part_power): + part_nodes = ring.get_part_nodes(partition) + try: + frag_index = [n['id'] for n in part_nodes].index( + self.local_dev['id']) + except ValueError: + pass + else: + break + else: + self.fail("the ring doesn't work: %r" % ring._replica2part2dev_id) + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + part_info = { + 'local_dev': self.local_dev, + 'policy': self.policy, + 'partition': partition, + 'part_path': part_path, + } + other_frag_index = random.choice([f for f in range(ring.replicas) + if f != frag_index]) + stub_hashes = { + '123': {frag_index: 'hash', None: 'hash'}, + '456': {other_frag_index: 'hash', None: 'hash'}, + 'abc': {None: 'hash'}, + } + with mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes)): + jobs = self.reconstructor.build_reconstruction_jobs(part_info) + self.assertEqual(2, len(jobs)) + sync_jobs, revert_jobs = [], [] + for job in jobs: + self.assertEqual(job['partition'], partition) + self.assertEqual(job['path'], part_path) + self.assertEqual(sorted(job['hashes']), sorted(stub_hashes)) + self.assertEqual(job['policy'], self.policy) + self.assertEqual(job['local_dev'], self.local_dev) + self.assertEqual(job['device'], self.local_dev['device']) + { + object_reconstructor.SYNC: sync_jobs, + object_reconstructor.REVERT: revert_jobs, + }[job['job_type']].append(job) + self.assertEqual(1, len(sync_jobs)) + job = sync_jobs[0] + self.assertEqual(job['frag_index'], frag_index) + self.assertEqual(sorted(job['suffixes']), sorted(['123', 'abc'])) + self.assertEqual(len(job['sync_to']), 2) + self.assertEqual(set([n['index'] for n in job['sync_to']]), + set([(frag_index + 1) % ring.replicas, + (frag_index - 1) % ring.replicas])) + self.assertEqual(1, len(revert_jobs)) + job = revert_jobs[0] + self.assertEqual(job['frag_index'], other_frag_index) + self.assertEqual(job['suffixes'], ['456']) + self.assertEqual(len(job['sync_to']), 1) + self.assertEqual(job['sync_to'][0]['index'], other_frag_index) + + def test_build_jobs_revert_only_tombstones(self): + ring = self.policy.object_ring = FabricatedRing() + # find a partition for which we're a handoff + for partition in range(2 ** ring.part_power): + part_nodes = ring.get_part_nodes(partition) + if self.local_dev['id'] not in [n['id'] for n in part_nodes]: + break + else: + self.fail("the ring doesn't work: %r" % ring._replica2part2dev_id) + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + part_info = { + 'local_dev': self.local_dev, + 'policy': self.policy, + 'partition': partition, + 'part_path': part_path, + } + # we have no fragment index to hint the jobs where they belong + stub_hashes = { + '123': {None: 'hash'}, + 'abc': {None: 'hash'}, + } + with mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes)): + jobs = self.reconstructor.build_reconstruction_jobs(part_info) + self.assertEqual(len(jobs), 1) + job = jobs[0] + expected = { + 'job_type': object_reconstructor.REVERT, + 'frag_index': None, + 'suffixes': stub_hashes.keys(), + 'partition': partition, + 'path': part_path, + 'hashes': stub_hashes, + 'policy': self.policy, + 'local_dev': self.local_dev, + 'device': self.local_dev['device'], + } + self.assertEqual(ring.replica_count, len(job['sync_to'])) + for k, v in expected.items(): + msg = 'expected %s != %s for %s' % ( + v, job[k], k) + self.assertEqual(v, job[k], msg) + + def test_get_suffix_delta(self): + # different + local_suff = {'123': {None: 'abc', 0: 'def'}} + remote_suff = {'456': {None: 'ghi', 0: 'jkl'}} + local_index = 0 + remote_index = 0 + suffs = self.reconstructor.get_suffix_delta(local_suff, + local_index, + remote_suff, + remote_index) + self.assertEqual(suffs, ['123']) + + # now the same + remote_suff = {'123': {None: 'abc', 0: 'def'}} + suffs = self.reconstructor.get_suffix_delta(local_suff, + local_index, + remote_suff, + remote_index) + self.assertEqual(suffs, []) + + # now with a mis-matched None key (missing durable) + remote_suff = {'123': {None: 'ghi', 0: 'def'}} + suffs = self.reconstructor.get_suffix_delta(local_suff, + local_index, + remote_suff, + remote_index) + self.assertEqual(suffs, ['123']) + + # now with bogus local index + local_suff = {'123': {None: 'abc', 99: 'def'}} + remote_suff = {'456': {None: 'ghi', 0: 'jkl'}} + suffs = self.reconstructor.get_suffix_delta(local_suff, + local_index, + remote_suff, + remote_index) + self.assertEqual(suffs, ['123']) + + def test_process_job_primary_in_sync(self): + replicas = self.policy.object_ring.replicas + frag_index = random.randint(0, replicas - 1) + sync_to = [n for n in self.policy.object_ring.devs + if n != self.local_dev][:2] + # setup left and right hashes + stub_hashes = { + '123': {frag_index: 'hash', None: 'hash'}, + 'abc': {frag_index: 'hash', None: 'hash'}, + } + left_index = sync_to[0]['index'] = (frag_index - 1) % replicas + left_hashes = { + '123': {left_index: 'hash', None: 'hash'}, + 'abc': {left_index: 'hash', None: 'hash'}, + } + right_index = sync_to[1]['index'] = (frag_index + 1) % replicas + right_hashes = { + '123': {right_index: 'hash', None: 'hash'}, + 'abc': {right_index: 'hash', None: 'hash'}, + } + partition = 0 + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + job = { + 'job_type': object_reconstructor.SYNC, + 'frag_index': frag_index, + 'suffixes': stub_hashes.keys(), + 'sync_to': sync_to, + 'partition': partition, + 'path': part_path, + 'hashes': stub_hashes, + 'policy': self.policy, + 'local_dev': self.local_dev, + } + + responses = [(200, pickle.dumps(hashes)) for hashes in ( + left_hashes, right_hashes)] + codes, body_iter = zip(*responses) + + ssync_calls = [] + + with nested( + mock_ssync_sender(ssync_calls), + mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes))): + with mocked_http_conn(*codes, body_iter=body_iter) as request_log: + self.reconstructor.process_job(job) + + expected_suffix_calls = set([ + ('10.0.0.1', '/sdb/0'), + ('10.0.0.2', '/sdc/0'), + ]) + self.assertEqual(expected_suffix_calls, + set((r['ip'], r['path']) + for r in request_log.requests)) + + self.assertEqual(len(ssync_calls), 0) + + def test_process_job_primary_not_in_sync(self): + replicas = self.policy.object_ring.replicas + frag_index = random.randint(0, replicas - 1) + sync_to = [n for n in self.policy.object_ring.devs + if n != self.local_dev][:2] + # setup left and right hashes + stub_hashes = { + '123': {frag_index: 'hash', None: 'hash'}, + 'abc': {frag_index: 'hash', None: 'hash'}, + } + sync_to[0]['index'] = (frag_index - 1) % replicas + left_hashes = {} + sync_to[1]['index'] = (frag_index + 1) % replicas + right_hashes = {} + + partition = 0 + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + job = { + 'job_type': object_reconstructor.SYNC, + 'frag_index': frag_index, + 'suffixes': stub_hashes.keys(), + 'sync_to': sync_to, + 'partition': partition, + 'path': part_path, + 'hashes': stub_hashes, + 'policy': self.policy, + 'local_dev': self.local_dev, + } + + responses = [(200, pickle.dumps(hashes)) for hashes in ( + left_hashes, left_hashes, right_hashes, right_hashes)] + codes, body_iter = zip(*responses) + + ssync_calls = [] + with nested( + mock_ssync_sender(ssync_calls), + mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes))): + with mocked_http_conn(*codes, body_iter=body_iter) as request_log: + self.reconstructor.process_job(job) + + expected_suffix_calls = set([ + ('10.0.0.1', '/sdb/0'), + ('10.0.0.1', '/sdb/0/123-abc'), + ('10.0.0.2', '/sdc/0'), + ('10.0.0.2', '/sdc/0/123-abc'), + ]) + self.assertEqual(expected_suffix_calls, + set((r['ip'], r['path']) + for r in request_log.requests)) + + expected_ssync_calls = sorted([ + ('10.0.0.1', 0, set(['123', 'abc'])), + ('10.0.0.2', 0, set(['123', 'abc'])), + ]) + self.assertEqual(expected_ssync_calls, sorted(( + c['node']['ip'], + c['job']['partition'], + set(c['suffixes']), + ) for c in ssync_calls)) + + def test_process_job_sync_missing_durable(self): + replicas = self.policy.object_ring.replicas + frag_index = random.randint(0, replicas - 1) + sync_to = [n for n in self.policy.object_ring.devs + if n != self.local_dev][:2] + # setup left and right hashes + stub_hashes = { + '123': {frag_index: 'hash', None: 'hash'}, + 'abc': {frag_index: 'hash', None: 'hash'}, + } + # left hand side is in sync + left_index = sync_to[0]['index'] = (frag_index - 1) % replicas + left_hashes = { + '123': {left_index: 'hash', None: 'hash'}, + 'abc': {left_index: 'hash', None: 'hash'}, + } + # right hand side has fragment, but no durable (None key is whack) + right_index = sync_to[1]['index'] = (frag_index + 1) % replicas + right_hashes = { + '123': {right_index: 'hash', None: 'hash'}, + 'abc': {right_index: 'hash', None: 'different-because-durable'}, + } + + partition = 0 + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + job = { + 'job_type': object_reconstructor.SYNC, + 'frag_index': frag_index, + 'suffixes': stub_hashes.keys(), + 'sync_to': sync_to, + 'partition': partition, + 'path': part_path, + 'hashes': stub_hashes, + 'policy': self.policy, + 'local_dev': self.local_dev, + } + + responses = [(200, pickle.dumps(hashes)) for hashes in ( + left_hashes, right_hashes, right_hashes)] + codes, body_iter = zip(*responses) + + ssync_calls = [] + with nested( + mock_ssync_sender(ssync_calls), + mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes))): + with mocked_http_conn(*codes, body_iter=body_iter) as request_log: + self.reconstructor.process_job(job) + + expected_suffix_calls = set([ + ('10.0.0.1', '/sdb/0'), + ('10.0.0.2', '/sdc/0'), + ('10.0.0.2', '/sdc/0/abc'), + ]) + self.assertEqual(expected_suffix_calls, + set((r['ip'], r['path']) + for r in request_log.requests)) + + expected_ssync_calls = sorted([ + ('10.0.0.2', 0, ['abc']), + ]) + self.assertEqual(expected_ssync_calls, sorted(( + c['node']['ip'], + c['job']['partition'], + c['suffixes'], + ) for c in ssync_calls)) + + def test_process_job_primary_some_in_sync(self): + replicas = self.policy.object_ring.replicas + frag_index = random.randint(0, replicas - 1) + sync_to = [n for n in self.policy.object_ring.devs + if n != self.local_dev][:2] + # setup left and right hashes + stub_hashes = { + '123': {frag_index: 'hash', None: 'hash'}, + 'abc': {frag_index: 'hash', None: 'hash'}, + } + left_index = sync_to[0]['index'] = (frag_index - 1) % replicas + left_hashes = { + '123': {left_index: 'hashX', None: 'hash'}, + 'abc': {left_index: 'hash', None: 'hash'}, + } + right_index = sync_to[1]['index'] = (frag_index + 1) % replicas + right_hashes = { + '123': {right_index: 'hash', None: 'hash'}, + } + partition = 0 + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + job = { + 'job_type': object_reconstructor.SYNC, + 'frag_index': frag_index, + 'suffixes': stub_hashes.keys(), + 'sync_to': sync_to, + 'partition': partition, + 'path': part_path, + 'hashes': stub_hashes, + 'policy': self.policy, + 'local_dev': self.local_dev, + } + + responses = [(200, pickle.dumps(hashes)) for hashes in ( + left_hashes, left_hashes, right_hashes, right_hashes)] + codes, body_iter = zip(*responses) + + ssync_calls = [] + + with nested( + mock_ssync_sender(ssync_calls), + mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes))): + with mocked_http_conn(*codes, body_iter=body_iter) as request_log: + self.reconstructor.process_job(job) + + expected_suffix_calls = set([ + ('10.0.0.1', '/sdb/0'), + ('10.0.0.1', '/sdb/0/123'), + ('10.0.0.2', '/sdc/0'), + ('10.0.0.2', '/sdc/0/abc'), + ]) + self.assertEqual(expected_suffix_calls, + set((r['ip'], r['path']) + for r in request_log.requests)) + + self.assertEqual(len(ssync_calls), 2) + self.assertEqual(set(c['node']['index'] for c in ssync_calls), + set([left_index, right_index])) + for call in ssync_calls: + if call['node']['index'] == left_index: + self.assertEqual(call['suffixes'], ['123']) + elif call['node']['index'] == right_index: + self.assertEqual(call['suffixes'], ['abc']) + else: + self.fail('unexpected call %r' % call) + + def test_process_job_primary_down(self): + replicas = self.policy.object_ring.replicas + partition = 0 + frag_index = random.randint(0, replicas - 1) + stub_hashes = { + '123': {frag_index: 'hash', None: 'hash'}, + 'abc': {frag_index: 'hash', None: 'hash'}, + } + + part_nodes = self.policy.object_ring.get_part_nodes(partition) + sync_to = part_nodes[:2] + + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + job = { + 'job_type': object_reconstructor.SYNC, + 'frag_index': frag_index, + 'suffixes': stub_hashes.keys(), + 'sync_to': sync_to, + 'partition': partition, + 'path': part_path, + 'hashes': stub_hashes, + 'policy': self.policy, + 'device': self.local_dev['device'], + 'local_dev': self.local_dev, + } + + non_local = {'called': 0} + + def ssync_response_callback(*args): + # in this test, ssync fails on the first (primary sync_to) node + if non_local['called'] >= 1: + return True, {} + non_local['called'] += 1 + return False, {} + + expected_suffix_calls = set() + for node in part_nodes[:3]: + expected_suffix_calls.update([ + (node['replication_ip'], '/%s/0' % node['device']), + (node['replication_ip'], '/%s/0/123-abc' % node['device']), + ]) + + ssync_calls = [] + with nested( + mock_ssync_sender(ssync_calls, + response_callback=ssync_response_callback), + mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes))): + with mocked_http_conn(*[200] * len(expected_suffix_calls), + body=pickle.dumps({})) as request_log: + self.reconstructor.process_job(job) + + found_suffix_calls = set((r['ip'], r['path']) + for r in request_log.requests) + self.assertEqual(expected_suffix_calls, found_suffix_calls) + + expected_ssync_calls = sorted([ + ('10.0.0.0', 0, set(['123', 'abc'])), + ('10.0.0.1', 0, set(['123', 'abc'])), + ('10.0.0.2', 0, set(['123', 'abc'])), + ]) + found_ssync_calls = sorted(( + c['node']['ip'], + c['job']['partition'], + set(c['suffixes']), + ) for c in ssync_calls) + self.assertEqual(expected_ssync_calls, found_ssync_calls) + + def test_process_job_suffix_call_errors(self): + replicas = self.policy.object_ring.replicas + partition = 0 + frag_index = random.randint(0, replicas - 1) + stub_hashes = { + '123': {frag_index: 'hash', None: 'hash'}, + 'abc': {frag_index: 'hash', None: 'hash'}, + } + + part_nodes = self.policy.object_ring.get_part_nodes(partition) + sync_to = part_nodes[:2] + + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + job = { + 'job_type': object_reconstructor.SYNC, + 'frag_index': frag_index, + 'suffixes': stub_hashes.keys(), + 'sync_to': sync_to, + 'partition': partition, + 'path': part_path, + 'hashes': stub_hashes, + 'policy': self.policy, + 'device': self.local_dev['device'], + 'local_dev': self.local_dev, + } + + expected_suffix_calls = set(( + node['replication_ip'], '/%s/0' % node['device'] + ) for node in part_nodes) + + possible_errors = [404, 507, Timeout(), Exception('kaboom!')] + codes = [random.choice(possible_errors) + for r in expected_suffix_calls] + + ssync_calls = [] + with nested( + mock_ssync_sender(ssync_calls), + mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes))): + with mocked_http_conn(*codes) as request_log: + self.reconstructor.process_job(job) + + found_suffix_calls = set((r['ip'], r['path']) + for r in request_log.requests) + self.assertEqual(expected_suffix_calls, found_suffix_calls) + + self.assertFalse(ssync_calls) + + def test_process_job_handoff(self): + replicas = self.policy.object_ring.replicas + frag_index = random.randint(0, replicas - 1) + sync_to = [random.choice([n for n in self.policy.object_ring.devs + if n != self.local_dev])] + sync_to[0]['index'] = frag_index + + stub_hashes = { + '123': {frag_index: 'hash', None: 'hash'}, + 'abc': {frag_index: 'hash', None: 'hash'}, + } + partition = 0 + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + job = { + 'job_type': object_reconstructor.REVERT, + 'frag_index': frag_index, + 'suffixes': stub_hashes.keys(), + 'sync_to': sync_to, + 'partition': partition, + 'path': part_path, + 'hashes': stub_hashes, + 'policy': self.policy, + 'local_dev': self.local_dev, + } + + ssync_calls = [] + with nested( + mock_ssync_sender(ssync_calls), + mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes))): + with mocked_http_conn(200, body=pickle.dumps({})) as request_log: + self.reconstructor.process_job(job) + + expected_suffix_calls = set([ + (sync_to[0]['ip'], '/%s/0/123-abc' % sync_to[0]['device']), + ]) + found_suffix_calls = set((r['ip'], r['path']) + for r in request_log.requests) + self.assertEqual(expected_suffix_calls, found_suffix_calls) + + self.assertEqual(len(ssync_calls), 1) + call = ssync_calls[0] + self.assertEqual(call['node'], sync_to[0]) + self.assertEqual(set(call['suffixes']), set(['123', 'abc'])) + + def test_process_job_revert_to_handoff(self): + replicas = self.policy.object_ring.replicas + frag_index = random.randint(0, replicas - 1) + sync_to = [random.choice([n for n in self.policy.object_ring.devs + if n != self.local_dev])] + sync_to[0]['index'] = frag_index + partition = 0 + handoff = next(self.policy.object_ring.get_more_nodes(partition)) + + stub_hashes = { + '123': {frag_index: 'hash', None: 'hash'}, + 'abc': {frag_index: 'hash', None: 'hash'}, + } + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + job = { + 'job_type': object_reconstructor.REVERT, + 'frag_index': frag_index, + 'suffixes': stub_hashes.keys(), + 'sync_to': sync_to, + 'partition': partition, + 'path': part_path, + 'hashes': stub_hashes, + 'policy': self.policy, + 'local_dev': self.local_dev, + } + + non_local = {'called': 0} + + def ssync_response_callback(*args): + # in this test, ssync fails on the first (primary sync_to) node + if non_local['called'] >= 1: + return True, {} + non_local['called'] += 1 + return False, {} + + expected_suffix_calls = set([ + (node['replication_ip'], '/%s/0/123-abc' % node['device']) + for node in (sync_to[0], handoff) + ]) + + ssync_calls = [] + with nested( + mock_ssync_sender(ssync_calls, + response_callback=ssync_response_callback), + mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes))): + with mocked_http_conn(*[200] * len(expected_suffix_calls), + body=pickle.dumps({})) as request_log: + self.reconstructor.process_job(job) + + found_suffix_calls = set((r['ip'], r['path']) + for r in request_log.requests) + self.assertEqual(expected_suffix_calls, found_suffix_calls) + + self.assertEqual(len(ssync_calls), len(expected_suffix_calls)) + call = ssync_calls[0] + self.assertEqual(call['node'], sync_to[0]) + self.assertEqual(set(call['suffixes']), set(['123', 'abc'])) + + def test_process_job_revert_is_handoff(self): + replicas = self.policy.object_ring.replicas + frag_index = random.randint(0, replicas - 1) + sync_to = [random.choice([n for n in self.policy.object_ring.devs + if n != self.local_dev])] + sync_to[0]['index'] = frag_index + partition = 0 + handoff_nodes = list(self.policy.object_ring.get_more_nodes(partition)) + + stub_hashes = { + '123': {frag_index: 'hash', None: 'hash'}, + 'abc': {frag_index: 'hash', None: 'hash'}, + } + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + job = { + 'job_type': object_reconstructor.REVERT, + 'frag_index': frag_index, + 'suffixes': stub_hashes.keys(), + 'sync_to': sync_to, + 'partition': partition, + 'path': part_path, + 'hashes': stub_hashes, + 'policy': self.policy, + 'local_dev': handoff_nodes[-1], + } + + def ssync_response_callback(*args): + # in this test ssync always fails, until we encounter ourselves in + # the list of possible handoff's to sync to + return False, {} + + expected_suffix_calls = set([ + (sync_to[0]['replication_ip'], + '/%s/0/123-abc' % sync_to[0]['device']) + ] + [ + (node['replication_ip'], '/%s/0/123-abc' % node['device']) + for node in handoff_nodes[:-1] + ]) + + ssync_calls = [] + with nested( + mock_ssync_sender(ssync_calls, + response_callback=ssync_response_callback), + mock.patch('swift.obj.diskfile.ECDiskFileManager._get_hashes', + return_value=(None, stub_hashes))): + with mocked_http_conn(*[200] * len(expected_suffix_calls), + body=pickle.dumps({})) as request_log: + self.reconstructor.process_job(job) + + found_suffix_calls = set((r['ip'], r['path']) + for r in request_log.requests) + self.assertEqual(expected_suffix_calls, found_suffix_calls) + + # this is ssync call to primary (which fails) plus the ssync call to + # all of the handoffs (except the last one - which is the local_dev) + self.assertEqual(len(ssync_calls), len(handoff_nodes)) + call = ssync_calls[0] + self.assertEqual(call['node'], sync_to[0]) + self.assertEqual(set(call['suffixes']), set(['123', 'abc'])) + + def test_process_job_revert_cleanup(self): + replicas = self.policy.object_ring.replicas + frag_index = random.randint(0, replicas - 1) + sync_to = [random.choice([n for n in self.policy.object_ring.devs + if n != self.local_dev])] + sync_to[0]['index'] = frag_index + partition = 0 + + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + os.makedirs(part_path) + df_mgr = self.reconstructor._df_router[self.policy] + df = df_mgr.get_diskfile(self.local_dev['device'], partition, 'a', + 'c', 'data-obj', policy=self.policy) + ts = self.ts() + with df.create() as writer: + test_data = 'test data' + writer.write(test_data) + metadata = { + 'X-Timestamp': ts.internal, + 'Content-Length': len(test_data), + 'Etag': md5(test_data).hexdigest(), + 'X-Object-Sysmeta-Ec-Frag-Index': frag_index, + } + writer.put(metadata) + writer.commit(ts) + + ohash = os.path.basename(df._datadir) + suffix = os.path.basename(os.path.dirname(df._datadir)) + + job = { + 'job_type': object_reconstructor.REVERT, + 'frag_index': frag_index, + 'suffixes': [suffix], + 'sync_to': sync_to, + 'partition': partition, + 'path': part_path, + 'hashes': {}, + 'policy': self.policy, + 'local_dev': self.local_dev, + } + + def ssync_response_callback(*args): + return True, {ohash: ts} + + ssync_calls = [] + with mock_ssync_sender(ssync_calls, + response_callback=ssync_response_callback): + with mocked_http_conn(200, body=pickle.dumps({})) as request_log: + self.reconstructor.process_job(job) + + self.assertEqual([ + (sync_to[0]['replication_ip'], '/%s/0/%s' % ( + sync_to[0]['device'], suffix)), + ], [ + (r['ip'], r['path']) for r in request_log.requests + ]) + # hashpath is still there, but only the durable remains + files = os.listdir(df._datadir) + self.assertEqual(1, len(files)) + self.assertTrue(files[0].endswith('.durable')) + + # and more to the point, the next suffix recalc will clean it up + df_mgr = self.reconstructor._df_router[self.policy] + df_mgr.get_hashes(self.local_dev['device'], str(partition), [], + self.policy) + self.assertFalse(os.access(df._datadir, os.F_OK)) + + def test_process_job_revert_cleanup_tombstone(self): + replicas = self.policy.object_ring.replicas + frag_index = random.randint(0, replicas - 1) + sync_to = [random.choice([n for n in self.policy.object_ring.devs + if n != self.local_dev])] + sync_to[0]['index'] = frag_index + partition = 0 + + part_path = os.path.join(self.devices, self.local_dev['device'], + diskfile.get_data_dir(self.policy), + str(partition)) + os.makedirs(part_path) + df_mgr = self.reconstructor._df_router[self.policy] + df = df_mgr.get_diskfile(self.local_dev['device'], partition, 'a', + 'c', 'data-obj', policy=self.policy) + ts = self.ts() + df.delete(ts) + + ohash = os.path.basename(df._datadir) + suffix = os.path.basename(os.path.dirname(df._datadir)) + + job = { + 'job_type': object_reconstructor.REVERT, + 'frag_index': frag_index, + 'suffixes': [suffix], + 'sync_to': sync_to, + 'partition': partition, + 'path': part_path, + 'hashes': {}, + 'policy': self.policy, + 'local_dev': self.local_dev, + } + + def ssync_response_callback(*args): + return True, {ohash: ts} + + ssync_calls = [] + with mock_ssync_sender(ssync_calls, + response_callback=ssync_response_callback): + with mocked_http_conn(200, body=pickle.dumps({})) as request_log: + self.reconstructor.process_job(job) + + self.assertEqual([ + (sync_to[0]['replication_ip'], '/%s/0/%s' % ( + sync_to[0]['device'], suffix)), + ], [ + (r['ip'], r['path']) for r in request_log.requests + ]) + # hashpath is still there, but it's empty + self.assertEqual([], os.listdir(df._datadir)) + + def test_reconstruct_fa_no_errors(self): + job = { + 'partition': 0, + 'policy': self.policy, + } + part_nodes = self.policy.object_ring.get_part_nodes(0) + node = part_nodes[1] + metadata = { + 'name': '/a/c/o', + 'Content-Length': 0, + 'ETag': 'etag', + } + + test_data = ('rebuild' * self.policy.ec_segment_size)[:-777] + etag = md5(test_data).hexdigest() + ec_archive_bodies = make_ec_archive_bodies(self.policy, test_data) + + broken_body = ec_archive_bodies.pop(1) + + responses = list((200, body) for body in ec_archive_bodies) + headers = {'X-Object-Sysmeta-Ec-Etag': etag} + codes, body_iter = zip(*responses) + with mocked_http_conn(*codes, body_iter=body_iter, headers=headers): + df = self.reconstructor.reconstruct_fa( + job, node, metadata) + fixed_body = ''.join(df.reader()) + self.assertEqual(len(fixed_body), len(broken_body)) + self.assertEqual(md5(fixed_body).hexdigest(), + md5(broken_body).hexdigest()) + + def test_reconstruct_fa_errors_works(self): + job = { + 'partition': 0, + 'policy': self.policy, + } + part_nodes = self.policy.object_ring.get_part_nodes(0) + node = part_nodes[4] + metadata = { + 'name': '/a/c/o', + 'Content-Length': 0, + 'ETag': 'etag', + } + + test_data = ('rebuild' * self.policy.ec_segment_size)[:-777] + etag = md5(test_data).hexdigest() + ec_archive_bodies = make_ec_archive_bodies(self.policy, test_data) + + broken_body = ec_archive_bodies.pop(4) + + base_responses = list((200, body) for body in ec_archive_bodies) + # since we're already missing a fragment a +2 scheme can only support + # one additional failure at a time + for error in (Timeout(), 404, Exception('kaboom!')): + responses = list(base_responses) + error_index = random.randint(0, len(responses) - 1) + responses[error_index] = (error, '') + headers = {'X-Object-Sysmeta-Ec-Etag': etag} + codes, body_iter = zip(*responses) + with mocked_http_conn(*codes, body_iter=body_iter, + headers=headers): + df = self.reconstructor.reconstruct_fa( + job, node, dict(metadata)) + fixed_body = ''.join(df.reader()) + self.assertEqual(len(fixed_body), len(broken_body)) + self.assertEqual(md5(fixed_body).hexdigest(), + md5(broken_body).hexdigest()) + + def test_reconstruct_fa_errors_fails(self): + job = { + 'partition': 0, + 'policy': self.policy, + } + part_nodes = self.policy.object_ring.get_part_nodes(0) + node = part_nodes[1] + policy = self.policy + metadata = { + 'name': '/a/c/o', + 'Content-Length': 0, + 'ETag': 'etag', + } + + possible_errors = [404, Timeout(), Exception('kaboom!')] + codes = [random.choice(possible_errors) for i in + range(policy.object_ring.replicas - 1)] + with mocked_http_conn(*codes): + self.assertRaises(DiskFileError, self.reconstructor.reconstruct_fa, + job, node, metadata) + + def test_reconstruct_fa_with_mixed_old_etag(self): + job = { + 'partition': 0, + 'policy': self.policy, + } + part_nodes = self.policy.object_ring.get_part_nodes(0) + node = part_nodes[1] + metadata = { + 'name': '/a/c/o', + 'Content-Length': 0, + 'ETag': 'etag', + } + + test_data = ('rebuild' * self.policy.ec_segment_size)[:-777] + etag = md5(test_data).hexdigest() + ec_archive_bodies = make_ec_archive_bodies(self.policy, test_data) + + broken_body = ec_archive_bodies.pop(1) + + ts = (utils.Timestamp(t) for t in itertools.count(int(time.time()))) + # bad response + bad_response = (200, '', { + 'X-Object-Sysmeta-Ec-Etag': 'some garbage', + 'X-Backend-Timestamp': next(ts).internal, + }) + + # good responses + headers = { + 'X-Object-Sysmeta-Ec-Etag': etag, + 'X-Backend-Timestamp': next(ts).internal + } + responses = [(200, body, headers) + for body in ec_archive_bodies] + # mixed together + error_index = random.randint(0, len(responses) - 2) + responses[error_index] = bad_response + codes, body_iter, headers = zip(*responses) + with mocked_http_conn(*codes, body_iter=body_iter, headers=headers): + df = self.reconstructor.reconstruct_fa( + job, node, metadata) + fixed_body = ''.join(df.reader()) + self.assertEqual(len(fixed_body), len(broken_body)) + self.assertEqual(md5(fixed_body).hexdigest(), + md5(broken_body).hexdigest()) + + def test_reconstruct_fa_with_mixed_new_etag(self): + job = { + 'partition': 0, + 'policy': self.policy, + } + part_nodes = self.policy.object_ring.get_part_nodes(0) + node = part_nodes[1] + metadata = { + 'name': '/a/c/o', + 'Content-Length': 0, + 'ETag': 'etag', + } + + test_data = ('rebuild' * self.policy.ec_segment_size)[:-777] + etag = md5(test_data).hexdigest() + ec_archive_bodies = make_ec_archive_bodies(self.policy, test_data) + + broken_body = ec_archive_bodies.pop(1) + + ts = (utils.Timestamp(t) for t in itertools.count(int(time.time()))) + # good responses + headers = { + 'X-Object-Sysmeta-Ec-Etag': etag, + 'X-Backend-Timestamp': next(ts).internal + } + responses = [(200, body, headers) + for body in ec_archive_bodies] + codes, body_iter, headers = zip(*responses) + + # sanity check before negative test + with mocked_http_conn(*codes, body_iter=body_iter, headers=headers): + df = self.reconstructor.reconstruct_fa( + job, node, dict(metadata)) + fixed_body = ''.join(df.reader()) + self.assertEqual(len(fixed_body), len(broken_body)) + self.assertEqual(md5(fixed_body).hexdigest(), + md5(broken_body).hexdigest()) + + # one newer etag can spoil the bunch + new_response = (200, '', { + 'X-Object-Sysmeta-Ec-Etag': 'some garbage', + 'X-Backend-Timestamp': next(ts).internal, + }) + new_index = random.randint(0, len(responses) - self.policy.ec_nparity) + responses[new_index] = new_response + codes, body_iter, headers = zip(*responses) + with mocked_http_conn(*codes, body_iter=body_iter, headers=headers): + self.assertRaises(DiskFileError, self.reconstructor.reconstruct_fa, + job, node, dict(metadata)) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/unit/obj/test_replicator.py b/test/unit/obj/test_replicator.py index ab89e49259..f169e52dd8 100644 --- a/test/unit/obj/test_replicator.py +++ b/test/unit/obj/test_replicator.py @@ -27,7 +27,7 @@ from errno import ENOENT, ENOTEMPTY, ENOTDIR from eventlet.green import subprocess from eventlet import Timeout, tpool -from test.unit import FakeLogger, debug_logger, patch_policies +from test.unit import debug_logger, patch_policies from swift.common import utils from swift.common.utils import hash_path, mkdirs, normalize_timestamp, \ storage_directory @@ -173,9 +173,9 @@ class TestObjectReplicator(unittest.TestCase): os.mkdir(self.devices) os.mkdir(os.path.join(self.devices, 'sda')) self.objects = os.path.join(self.devices, 'sda', - diskfile.get_data_dir(0)) + diskfile.get_data_dir(POLICIES[0])) self.objects_1 = os.path.join(self.devices, 'sda', - diskfile.get_data_dir(1)) + diskfile.get_data_dir(POLICIES[1])) os.mkdir(self.objects) os.mkdir(self.objects_1) self.parts = {} @@ -190,7 +190,7 @@ class TestObjectReplicator(unittest.TestCase): swift_dir=self.testdir, devices=self.devices, mount_check='false', timeout='300', stats_interval='1', sync_method='rsync') self.replicator = object_replicator.ObjectReplicator(self.conf) - self.replicator.logger = FakeLogger() + self.logger = self.replicator.logger = debug_logger('test-replicator') self.df_mgr = diskfile.DiskFileManager(self.conf, self.replicator.logger) @@ -205,7 +205,7 @@ class TestObjectReplicator(unittest.TestCase): object_replicator.http_connect = mock_http_connect(200) cur_part = '0' df = self.df_mgr.get_diskfile('sda', cur_part, 'a', 'c', 'o', - policy_idx=0) + policy=POLICIES[0]) mkdirs(df._datadir) f = open(os.path.join(df._datadir, normalize_timestamp(time.time()) + '.data'), @@ -216,7 +216,7 @@ class TestObjectReplicator(unittest.TestCase): data_dir = ohash[-3:] whole_path_from = os.path.join(self.objects, cur_part, data_dir) process_arg_checker = [] - ring = replicator.get_object_ring(0) + ring = replicator.load_object_ring(POLICIES[0]) nodes = [node for node in ring.get_part_nodes(int(cur_part)) if node['ip'] not in _ips()] @@ -239,7 +239,7 @@ class TestObjectReplicator(unittest.TestCase): object_replicator.http_connect = mock_http_connect(200) cur_part = '0' df = self.df_mgr.get_diskfile('sda', cur_part, 'a', 'c', 'o', - policy_idx=1) + policy=POLICIES[1]) mkdirs(df._datadir) f = open(os.path.join(df._datadir, normalize_timestamp(time.time()) + '.data'), @@ -250,7 +250,7 @@ class TestObjectReplicator(unittest.TestCase): data_dir = ohash[-3:] whole_path_from = os.path.join(self.objects_1, cur_part, data_dir) process_arg_checker = [] - ring = replicator.get_object_ring(1) + ring = replicator.load_object_ring(POLICIES[1]) nodes = [node for node in ring.get_part_nodes(int(cur_part)) if node['ip'] not in _ips()] @@ -266,7 +266,7 @@ class TestObjectReplicator(unittest.TestCase): def test_check_ring(self): for pol in POLICIES: - obj_ring = self.replicator.get_object_ring(pol.idx) + obj_ring = self.replicator.load_object_ring(pol) self.assertTrue(self.replicator.check_ring(obj_ring)) orig_check = self.replicator.next_check self.replicator.next_check = orig_check - 30 @@ -280,29 +280,27 @@ class TestObjectReplicator(unittest.TestCase): def test_collect_jobs_mkdirs_error(self): + non_local = {} + def blowup_mkdirs(path): + non_local['path'] = path raise OSError('Ow!') with mock.patch.object(object_replicator, 'mkdirs', blowup_mkdirs): rmtree(self.objects, ignore_errors=1) object_replicator.mkdirs = blowup_mkdirs self.replicator.collect_jobs() - self.assertTrue('exception' in self.replicator.logger.log_dict) - self.assertEquals( - len(self.replicator.logger.log_dict['exception']), 1) - exc_args, exc_kwargs, exc_str = \ - self.replicator.logger.log_dict['exception'][0] - self.assertEquals(len(exc_args), 1) - self.assertTrue(exc_args[0].startswith('ERROR creating ')) - self.assertEquals(exc_kwargs, {}) - self.assertEquals(exc_str, 'Ow!') + self.assertEqual(self.logger.get_lines_for_level('error'), [ + 'ERROR creating %s: ' % non_local['path']]) + log_args, log_kwargs = self.logger.log_dict['error'][0] + self.assertEqual(str(log_kwargs['exc_info'][1]), 'Ow!') def test_collect_jobs(self): jobs = self.replicator.collect_jobs() jobs_to_delete = [j for j in jobs if j['delete']] jobs_by_pol_part = {} for job in jobs: - jobs_by_pol_part[str(job['policy_idx']) + job['partition']] = job + jobs_by_pol_part[str(int(job['policy'])) + job['partition']] = job self.assertEquals(len(jobs_to_delete), 2) self.assertTrue('1', jobs_to_delete[0]['partition']) self.assertEquals( @@ -383,19 +381,19 @@ class TestObjectReplicator(unittest.TestCase): self.assertFalse(os.path.exists(pol_0_part_1_path)) self.assertFalse(os.path.exists(pol_1_part_1_path)) - - logged_warnings = sorted(self.replicator.logger.log_dict['warning']) - self.assertEquals( - (('Removing partition directory which was a file: %s', - pol_1_part_1_path), {}), logged_warnings[0]) - self.assertEquals( - (('Removing partition directory which was a file: %s', - pol_0_part_1_path), {}), logged_warnings[1]) + self.assertEqual( + sorted(self.logger.get_lines_for_level('warning')), [ + ('Removing partition directory which was a file: %s' + % pol_1_part_1_path), + ('Removing partition directory which was a file: %s' + % pol_0_part_1_path), + ]) def test_delete_partition(self): with mock.patch('swift.obj.replicator.http_connect', mock_http_connect(200)): - df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o') + df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o', + policy=POLICIES.legacy) mkdirs(df._datadir) f = open(os.path.join(df._datadir, normalize_timestamp(time.time()) + '.data'), @@ -407,7 +405,7 @@ class TestObjectReplicator(unittest.TestCase): whole_path_from = os.path.join(self.objects, '1', data_dir) part_path = os.path.join(self.objects, '1') self.assertTrue(os.access(part_path, os.F_OK)) - ring = self.replicator.get_object_ring(0) + ring = self.replicator.load_object_ring(POLICIES[0]) nodes = [node for node in ring.get_part_nodes(1) if node['ip'] not in _ips()] @@ -424,7 +422,8 @@ class TestObjectReplicator(unittest.TestCase): self.replicator.conf.pop('sync_method') with mock.patch('swift.obj.replicator.http_connect', mock_http_connect(200)): - df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o') + df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o', + policy=POLICIES.legacy) mkdirs(df._datadir) f = open(os.path.join(df._datadir, normalize_timestamp(time.time()) + '.data'), @@ -436,7 +435,7 @@ class TestObjectReplicator(unittest.TestCase): whole_path_from = os.path.join(self.objects, '1', data_dir) part_path = os.path.join(self.objects, '1') self.assertTrue(os.access(part_path, os.F_OK)) - ring = self.replicator.get_object_ring(0) + ring = self.replicator.load_object_ring(POLICIES[0]) nodes = [node for node in ring.get_part_nodes(1) if node['ip'] not in _ips()] @@ -473,10 +472,11 @@ class TestObjectReplicator(unittest.TestCase): with mock.patch('swift.obj.replicator.http_connect', mock_http_connect(200)): - df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o') + df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o', + policy=POLICIES.legacy) mkdirs(df._datadir) - f = open(os.path.join(df._datadir, - normalize_timestamp(time.time()) + '.data'), + ts = normalize_timestamp(time.time()) + f = open(os.path.join(df._datadir, ts + '.data'), 'wb') f.write('1234567890') f.close() @@ -487,7 +487,7 @@ class TestObjectReplicator(unittest.TestCase): self.assertTrue(os.access(part_path, os.F_OK)) def _fake_ssync(node, job, suffixes, **kwargs): - return True, set([ohash]) + return True, {ohash: ts} self.replicator.sync_method = _fake_ssync self.replicator.replicate() @@ -499,7 +499,7 @@ class TestObjectReplicator(unittest.TestCase): with mock.patch('swift.obj.replicator.http_connect', mock_http_connect(200)): df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o', - policy_idx=1) + policy=POLICIES[1]) mkdirs(df._datadir) f = open(os.path.join(df._datadir, normalize_timestamp(time.time()) + '.data'), @@ -511,7 +511,7 @@ class TestObjectReplicator(unittest.TestCase): whole_path_from = os.path.join(self.objects_1, '1', data_dir) part_path = os.path.join(self.objects_1, '1') self.assertTrue(os.access(part_path, os.F_OK)) - ring = self.replicator.get_object_ring(1) + ring = self.replicator.load_object_ring(POLICIES[1]) nodes = [node for node in ring.get_part_nodes(1) if node['ip'] not in _ips()] @@ -527,7 +527,8 @@ class TestObjectReplicator(unittest.TestCase): def test_delete_partition_with_failures(self): with mock.patch('swift.obj.replicator.http_connect', mock_http_connect(200)): - df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o') + df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o', + policy=POLICIES.legacy) mkdirs(df._datadir) f = open(os.path.join(df._datadir, normalize_timestamp(time.time()) + '.data'), @@ -539,7 +540,7 @@ class TestObjectReplicator(unittest.TestCase): whole_path_from = os.path.join(self.objects, '1', data_dir) part_path = os.path.join(self.objects, '1') self.assertTrue(os.access(part_path, os.F_OK)) - ring = self.replicator.get_object_ring(0) + ring = self.replicator.load_object_ring(POLICIES[0]) nodes = [node for node in ring.get_part_nodes(1) if node['ip'] not in _ips()] @@ -562,7 +563,8 @@ class TestObjectReplicator(unittest.TestCase): with mock.patch('swift.obj.replicator.http_connect', mock_http_connect(200)): self.replicator.handoff_delete = 2 - df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o') + df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o', + policy=POLICIES.legacy) mkdirs(df._datadir) f = open(os.path.join(df._datadir, normalize_timestamp(time.time()) + '.data'), @@ -574,7 +576,7 @@ class TestObjectReplicator(unittest.TestCase): whole_path_from = os.path.join(self.objects, '1', data_dir) part_path = os.path.join(self.objects, '1') self.assertTrue(os.access(part_path, os.F_OK)) - ring = self.replicator.get_object_ring(0) + ring = self.replicator.load_object_ring(POLICIES[0]) nodes = [node for node in ring.get_part_nodes(1) if node['ip'] not in _ips()] @@ -596,7 +598,8 @@ class TestObjectReplicator(unittest.TestCase): with mock.patch('swift.obj.replicator.http_connect', mock_http_connect(200)): self.replicator.handoff_delete = 2 - df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o') + df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o', + policy=POLICIES.legacy) mkdirs(df._datadir) f = open(os.path.join(df._datadir, normalize_timestamp(time.time()) + '.data'), @@ -608,7 +611,7 @@ class TestObjectReplicator(unittest.TestCase): whole_path_from = os.path.join(self.objects, '1', data_dir) part_path = os.path.join(self.objects, '1') self.assertTrue(os.access(part_path, os.F_OK)) - ring = self.replicator.get_object_ring(0) + ring = self.replicator.load_object_ring(POLICIES[0]) nodes = [node for node in ring.get_part_nodes(1) if node['ip'] not in _ips()] @@ -630,7 +633,8 @@ class TestObjectReplicator(unittest.TestCase): def test_delete_partition_with_handoff_delete_fail_in_other_region(self): with mock.patch('swift.obj.replicator.http_connect', mock_http_connect(200)): - df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o') + df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o', + policy=POLICIES.legacy) mkdirs(df._datadir) f = open(os.path.join(df._datadir, normalize_timestamp(time.time()) + '.data'), @@ -642,7 +646,7 @@ class TestObjectReplicator(unittest.TestCase): whole_path_from = os.path.join(self.objects, '1', data_dir) part_path = os.path.join(self.objects, '1') self.assertTrue(os.access(part_path, os.F_OK)) - ring = self.replicator.get_object_ring(0) + ring = self.replicator.load_object_ring(POLICIES[0]) nodes = [node for node in ring.get_part_nodes(1) if node['ip'] not in _ips()] @@ -662,7 +666,8 @@ class TestObjectReplicator(unittest.TestCase): self.assertTrue(os.access(part_path, os.F_OK)) def test_delete_partition_override_params(self): - df = self.df_mgr.get_diskfile('sda', '0', 'a', 'c', 'o') + df = self.df_mgr.get_diskfile('sda', '0', 'a', 'c', 'o', + policy=POLICIES.legacy) mkdirs(df._datadir) part_path = os.path.join(self.objects, '1') self.assertTrue(os.access(part_path, os.F_OK)) @@ -675,9 +680,10 @@ class TestObjectReplicator(unittest.TestCase): self.assertFalse(os.access(part_path, os.F_OK)) def test_delete_policy_override_params(self): - df0 = self.df_mgr.get_diskfile('sda', '99', 'a', 'c', 'o') + df0 = self.df_mgr.get_diskfile('sda', '99', 'a', 'c', 'o', + policy=POLICIES.legacy) df1 = self.df_mgr.get_diskfile('sda', '99', 'a', 'c', 'o', - policy_idx=1) + policy=POLICIES[1]) mkdirs(df0._datadir) mkdirs(df1._datadir) @@ -698,10 +704,11 @@ class TestObjectReplicator(unittest.TestCase): def test_delete_partition_ssync(self): with mock.patch('swift.obj.replicator.http_connect', mock_http_connect(200)): - df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o') + df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o', + policy=POLICIES.legacy) mkdirs(df._datadir) - f = open(os.path.join(df._datadir, - normalize_timestamp(time.time()) + '.data'), + ts = normalize_timestamp(time.time()) + f = open(os.path.join(df._datadir, ts + '.data'), 'wb') f.write('0') f.close() @@ -716,14 +723,14 @@ class TestObjectReplicator(unittest.TestCase): def _fake_ssync(node, job, suffixes, **kwargs): success = True - ret_val = [whole_path_from] + ret_val = {ohash: ts} if self.call_nums == 2: # ssync should return (True, []) only when the second # candidate node has not get the replica yet. success = False - ret_val = [] + ret_val = {} self.call_nums += 1 - return success, set(ret_val) + return success, ret_val self.replicator.sync_method = _fake_ssync self.replicator.replicate() @@ -746,11 +753,11 @@ class TestObjectReplicator(unittest.TestCase): def test_delete_partition_ssync_with_sync_failure(self): with mock.patch('swift.obj.replicator.http_connect', mock_http_connect(200)): - df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o') + df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o', + policy=POLICIES.legacy) + ts = normalize_timestamp(time.time()) mkdirs(df._datadir) - f = open(os.path.join(df._datadir, - normalize_timestamp(time.time()) + '.data'), - 'wb') + f = open(os.path.join(df._datadir, ts + '.data'), 'wb') f.write('0') f.close() ohash = hash_path('a', 'c', 'o') @@ -763,14 +770,14 @@ class TestObjectReplicator(unittest.TestCase): def _fake_ssync(node, job, suffixes, **kwags): success = False - ret_val = [] + ret_val = {} if self.call_nums == 2: # ssync should return (True, []) only when the second # candidate node has not get the replica yet. success = True - ret_val = [whole_path_from] + ret_val = {ohash: ts} self.call_nums += 1 - return success, set(ret_val) + return success, ret_val self.replicator.sync_method = _fake_ssync self.replicator.replicate() @@ -794,11 +801,11 @@ class TestObjectReplicator(unittest.TestCase): self.replicator.logger = debug_logger('test-replicator') with mock.patch('swift.obj.replicator.http_connect', mock_http_connect(200)): - df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o') + df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o', + policy=POLICIES.legacy) mkdirs(df._datadir) - f = open(os.path.join(df._datadir, - normalize_timestamp(time.time()) + '.data'), - 'wb') + ts = normalize_timestamp(time.time()) + f = open(os.path.join(df._datadir, ts + '.data'), 'wb') f.write('0') f.close() ohash = hash_path('a', 'c', 'o') @@ -809,16 +816,16 @@ class TestObjectReplicator(unittest.TestCase): self.call_nums = 0 self.conf['sync_method'] = 'ssync' - in_sync_objs = [] + in_sync_objs = {} def _fake_ssync(node, job, suffixes, remote_check_objs=None): self.call_nums += 1 if remote_check_objs is None: # sync job - ret_val = [whole_path_from] + ret_val = {ohash: ts} else: ret_val = in_sync_objs - return True, set(ret_val) + return True, ret_val self.replicator.sync_method = _fake_ssync self.replicator.replicate() @@ -833,12 +840,13 @@ class TestObjectReplicator(unittest.TestCase): def test_delete_partition_ssync_with_cleanup_failure(self): with mock.patch('swift.obj.replicator.http_connect', mock_http_connect(200)): - self.replicator.logger = mock_logger = mock.MagicMock() - df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o') + self.replicator.logger = mock_logger = \ + debug_logger('test-replicator') + df = self.df_mgr.get_diskfile('sda', '1', 'a', 'c', 'o', + policy=POLICIES.legacy) mkdirs(df._datadir) - f = open(os.path.join(df._datadir, - normalize_timestamp(time.time()) + '.data'), - 'wb') + ts = normalize_timestamp(time.time()) + f = open(os.path.join(df._datadir, ts + '.data'), 'wb') f.write('0') f.close() ohash = hash_path('a', 'c', 'o') @@ -852,14 +860,14 @@ class TestObjectReplicator(unittest.TestCase): def _fake_ssync(node, job, suffixes, **kwargs): success = True - ret_val = [whole_path_from] + ret_val = {ohash: ts} if self.call_nums == 2: # ssync should return (True, []) only when the second # candidate node has not get the replica yet. success = False - ret_val = [] + ret_val = {} self.call_nums += 1 - return success, set(ret_val) + return success, ret_val rmdir_func = os.rmdir @@ -886,7 +894,7 @@ class TestObjectReplicator(unittest.TestCase): with mock.patch('os.rmdir', raise_exception_rmdir(OSError, ENOENT)): self.replicator.replicate() - self.assertEquals(mock_logger.exception.call_count, 0) + self.assertFalse(mock_logger.get_lines_for_level('error')) self.assertFalse(os.access(whole_path_from, os.F_OK)) self.assertTrue(os.access(suffix_dir_path, os.F_OK)) self.assertTrue(os.access(part_path, os.F_OK)) @@ -895,7 +903,7 @@ class TestObjectReplicator(unittest.TestCase): with mock.patch('os.rmdir', raise_exception_rmdir(OSError, ENOTEMPTY)): self.replicator.replicate() - self.assertEquals(mock_logger.exception.call_count, 0) + self.assertFalse(mock_logger.get_lines_for_level('error')) self.assertFalse(os.access(whole_path_from, os.F_OK)) self.assertTrue(os.access(suffix_dir_path, os.F_OK)) self.assertTrue(os.access(part_path, os.F_OK)) @@ -904,7 +912,7 @@ class TestObjectReplicator(unittest.TestCase): with mock.patch('os.rmdir', raise_exception_rmdir(OSError, ENOTDIR)): self.replicator.replicate() - self.assertEquals(mock_logger.exception.call_count, 1) + self.assertEqual(len(mock_logger.get_lines_for_level('error')), 1) self.assertFalse(os.access(whole_path_from, os.F_OK)) self.assertTrue(os.access(suffix_dir_path, os.F_OK)) self.assertTrue(os.access(part_path, os.F_OK)) @@ -929,7 +937,8 @@ class TestObjectReplicator(unittest.TestCase): # Write some files into '1' and run replicate- they should be moved # to the other partitions and then node should get deleted. cur_part = '1' - df = self.df_mgr.get_diskfile('sda', cur_part, 'a', 'c', 'o') + df = self.df_mgr.get_diskfile('sda', cur_part, 'a', 'c', 'o', + policy=POLICIES.legacy) mkdirs(df._datadir) f = open(os.path.join(df._datadir, normalize_timestamp(time.time()) + '.data'), @@ -939,7 +948,7 @@ class TestObjectReplicator(unittest.TestCase): ohash = hash_path('a', 'c', 'o') data_dir = ohash[-3:] whole_path_from = os.path.join(self.objects, cur_part, data_dir) - ring = replicator.get_object_ring(0) + ring = replicator.load_object_ring(POLICIES[0]) process_arg_checker = [] nodes = [node for node in ring.get_part_nodes(int(cur_part)) @@ -993,7 +1002,8 @@ class TestObjectReplicator(unittest.TestCase): # Write some files into '1' and run replicate- they should be moved # to the other partitions and then node should get deleted. cur_part = '1' - df = self.df_mgr.get_diskfile('sda', cur_part, 'a', 'c', 'o') + df = self.df_mgr.get_diskfile('sda', cur_part, 'a', 'c', 'o', + policy=POLICIES.legacy) mkdirs(df._datadir) f = open(os.path.join(df._datadir, normalize_timestamp(time.time()) + '.data'), @@ -1004,10 +1014,11 @@ class TestObjectReplicator(unittest.TestCase): data_dir = ohash[-3:] whole_path_from = os.path.join(self.objects, cur_part, data_dir) process_arg_checker = [] - ring = replicator.get_object_ring(0) + ring = replicator.load_object_ring(POLICIES[0]) nodes = [node for node in ring.get_part_nodes(int(cur_part)) if node['ip'] not in _ips()] + for node in nodes: rsync_mod = '%s::object/sda/objects/%s' % (node['ip'], cur_part) @@ -1071,8 +1082,8 @@ class TestObjectReplicator(unittest.TestCase): expect = 'Error syncing partition' for job in jobs: set_default(self) - ring = self.replicator.get_object_ring(job['policy_idx']) - self.headers['X-Backend-Storage-Policy-Index'] = job['policy_idx'] + ring = job['policy'].object_ring + self.headers['X-Backend-Storage-Policy-Index'] = int(job['policy']) self.replicator.update(job) self.assertTrue(error in mock_logger.error.call_args[0][0]) self.assertTrue(expect in mock_logger.exception.call_args[0][0]) @@ -1118,7 +1129,7 @@ class TestObjectReplicator(unittest.TestCase): for job in jobs: set_default(self) # limit local job to policy 0 for simplicity - if job['partition'] == '0' and job['policy_idx'] == 0: + if job['partition'] == '0' and int(job['policy']) == 0: local_job = job.copy() continue self.replicator.update(job) diff --git a/test/unit/obj/test_server.py b/test/unit/obj/test_server.py index c8974deb42..52a34347ac 100755 --- a/test/unit/obj/test_server.py +++ b/test/unit/obj/test_server.py @@ -18,6 +18,7 @@ import cPickle as pickle import datetime +import json import errno import operator import os @@ -39,17 +40,19 @@ from eventlet.green import httplib from nose import SkipTest from swift import __version__ as swift_version +from swift.common.http import is_success from test.unit import FakeLogger, debug_logger, mocked_http_conn from test.unit import connect_tcp, readuntil2crlfs, patch_policies from swift.obj import server as object_server from swift.obj import diskfile -from swift.common import utils, storage_policy, bufferedhttp +from swift.common import utils, bufferedhttp from swift.common.utils import hash_path, mkdirs, normalize_timestamp, \ NullLogger, storage_directory, public, replication from swift.common import constraints -from swift.common.swob import Request, HeaderKeyDict +from swift.common.swob import Request, HeaderKeyDict, WsgiStringIO from swift.common.splice import splice -from swift.common.storage_policy import POLICIES +from swift.common.storage_policy import (StoragePolicy, ECStoragePolicy, + POLICIES, EC_POLICY) from swift.common.exceptions import DiskFileDeviceUnavailable @@ -57,7 +60,14 @@ def mock_time(*args, **kwargs): return 5000.0 -@patch_policies +test_policies = [ + StoragePolicy(0, name='zero', is_default=True), + ECStoragePolicy(1, name='one', ec_type='jerasure_rs_vand', + ec_ndata=10, ec_nparity=4), +] + + +@patch_policies(test_policies) class TestObjectController(unittest.TestCase): """Test swift.obj.server.ObjectController""" @@ -68,15 +78,18 @@ class TestObjectController(unittest.TestCase): self.tmpdir = mkdtemp() self.testdir = os.path.join(self.tmpdir, 'tmp_test_object_server_ObjectController') - conf = {'devices': self.testdir, 'mount_check': 'false'} + mkdirs(os.path.join(self.testdir, 'sda1')) + self.conf = {'devices': self.testdir, 'mount_check': 'false'} self.object_controller = object_server.ObjectController( - conf, logger=debug_logger()) + self.conf, logger=debug_logger()) self.object_controller.bytes_per_sync = 1 self._orig_tpool_exc = tpool.execute tpool.execute = lambda f, *args, **kwargs: f(*args, **kwargs) - self.df_mgr = diskfile.DiskFileManager(conf, + self.df_mgr = diskfile.DiskFileManager(self.conf, self.object_controller.logger) + self.logger = debug_logger('test-object-controller') + def tearDown(self): """Tear down for testing swift.object.server.ObjectController""" rmtree(self.tmpdir) @@ -84,7 +97,7 @@ class TestObjectController(unittest.TestCase): def _stage_tmp_dir(self, policy): mkdirs(os.path.join(self.testdir, 'sda1', - diskfile.get_tmp_dir(int(policy)))) + diskfile.get_tmp_dir(policy))) def check_all_api_methods(self, obj_name='o', alt_res=None): path = '/sda1/p/a/c/%s' % obj_name @@ -417,7 +430,8 @@ class TestObjectController(unittest.TestCase): resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 201) - objfile = self.df_mgr.get_diskfile('sda1', 'p', 'a', 'c', 'o') + objfile = self.df_mgr.get_diskfile('sda1', 'p', 'a', 'c', 'o', + policy=POLICIES.legacy) objfile.open() file_name = os.path.basename(objfile._data_file) with open(objfile._data_file) as fp: @@ -568,7 +582,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.status_int, 201) objfile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.data') self.assert_(os.path.isfile(objfile)) @@ -603,7 +617,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.status_int, 201) objfile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.data') self.assert_(os.path.isfile(objfile)) @@ -638,7 +652,7 @@ class TestObjectController(unittest.TestCase): self.assertEqual(resp.status_int, 201) objfile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.data') self.assertTrue(os.path.isfile(objfile)) @@ -715,7 +729,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.status_int, 201) objfile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.data') self.assert_(os.path.isfile(objfile)) @@ -729,6 +743,241 @@ class TestObjectController(unittest.TestCase): 'X-Object-Meta-1': 'One', 'X-Object-Meta-Two': 'Two'}) + def test_PUT_etag_in_footer(self): + timestamp = normalize_timestamp(time()) + req = Request.blank( + '/sda1/p/a/c/o', + headers={'X-Timestamp': timestamp, + 'Content-Type': 'text/plain', + 'Transfer-Encoding': 'chunked', + 'Etag': 'other-etag', + 'X-Backend-Obj-Metadata-Footer': 'yes', + 'X-Backend-Obj-Multipart-Mime-Boundary': 'boundary'}, + environ={'REQUEST_METHOD': 'PUT'}) + + obj_etag = md5("obj data").hexdigest() + footer_meta = json.dumps({"Etag": obj_etag}) + footer_meta_cksum = md5(footer_meta).hexdigest() + + req.body = "\r\n".join(( + "--boundary", + "", + "obj data", + "--boundary", + "Content-MD5: " + footer_meta_cksum, + "", + footer_meta, + "--boundary--", + )) + req.headers.pop("Content-Length", None) + + resp = req.get_response(self.object_controller) + self.assertEqual(resp.etag, obj_etag) + self.assertEqual(resp.status_int, 201) + + objfile = os.path.join( + self.testdir, 'sda1', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', + hash_path('a', 'c', 'o')), + utils.Timestamp(timestamp).internal + '.data') + with open(objfile) as fh: + self.assertEqual(fh.read(), "obj data") + + def test_PUT_etag_in_footer_mismatch(self): + timestamp = normalize_timestamp(time()) + req = Request.blank( + '/sda1/p/a/c/o', + headers={'X-Timestamp': timestamp, + 'Content-Type': 'text/plain', + 'Transfer-Encoding': 'chunked', + 'X-Backend-Obj-Metadata-Footer': 'yes', + 'X-Backend-Obj-Multipart-Mime-Boundary': 'boundary'}, + environ={'REQUEST_METHOD': 'PUT'}) + + footer_meta = json.dumps({"Etag": md5("green").hexdigest()}) + footer_meta_cksum = md5(footer_meta).hexdigest() + + req.body = "\r\n".join(( + "--boundary", + "", + "blue", + "--boundary", + "Content-MD5: " + footer_meta_cksum, + "", + footer_meta, + "--boundary--", + )) + req.headers.pop("Content-Length", None) + + resp = req.get_response(self.object_controller) + self.assertEqual(resp.status_int, 422) + + def test_PUT_meta_in_footer(self): + timestamp = normalize_timestamp(time()) + req = Request.blank( + '/sda1/p/a/c/o', + headers={'X-Timestamp': timestamp, + 'Content-Type': 'text/plain', + 'Transfer-Encoding': 'chunked', + 'X-Object-Meta-X': 'Z', + 'X-Object-Sysmeta-X': 'Z', + 'X-Backend-Obj-Metadata-Footer': 'yes', + 'X-Backend-Obj-Multipart-Mime-Boundary': 'boundary'}, + environ={'REQUEST_METHOD': 'PUT'}) + + footer_meta = json.dumps({ + 'X-Object-Meta-X': 'Y', + 'X-Object-Sysmeta-X': 'Y', + }) + footer_meta_cksum = md5(footer_meta).hexdigest() + + req.body = "\r\n".join(( + "--boundary", + "", + "stuff stuff stuff", + "--boundary", + "Content-MD5: " + footer_meta_cksum, + "", + footer_meta, + "--boundary--", + )) + req.headers.pop("Content-Length", None) + + resp = req.get_response(self.object_controller) + self.assertEqual(resp.status_int, 201) + + timestamp = normalize_timestamp(time()) + req = Request.blank( + '/sda1/p/a/c/o', + headers={'X-Timestamp': timestamp}, + environ={'REQUEST_METHOD': 'HEAD'}) + resp = req.get_response(self.object_controller) + self.assertEqual(resp.headers.get('X-Object-Meta-X'), 'Y') + self.assertEqual(resp.headers.get('X-Object-Sysmeta-X'), 'Y') + + def test_PUT_missing_footer_checksum(self): + timestamp = normalize_timestamp(time()) + req = Request.blank( + '/sda1/p/a/c/o', + headers={'X-Timestamp': timestamp, + 'Content-Type': 'text/plain', + 'Transfer-Encoding': 'chunked', + 'X-Backend-Obj-Metadata-Footer': 'yes', + 'X-Backend-Obj-Multipart-Mime-Boundary': 'boundary'}, + environ={'REQUEST_METHOD': 'PUT'}) + + footer_meta = json.dumps({"Etag": md5("obj data").hexdigest()}) + + req.body = "\r\n".join(( + "--boundary", + "", + "obj data", + "--boundary", + # no Content-MD5 + "", + footer_meta, + "--boundary--", + )) + req.headers.pop("Content-Length", None) + + resp = req.get_response(self.object_controller) + self.assertEqual(resp.status_int, 400) + + def test_PUT_bad_footer_checksum(self): + timestamp = normalize_timestamp(time()) + req = Request.blank( + '/sda1/p/a/c/o', + headers={'X-Timestamp': timestamp, + 'Content-Type': 'text/plain', + 'Transfer-Encoding': 'chunked', + 'X-Backend-Obj-Metadata-Footer': 'yes', + 'X-Backend-Obj-Multipart-Mime-Boundary': 'boundary'}, + environ={'REQUEST_METHOD': 'PUT'}) + + footer_meta = json.dumps({"Etag": md5("obj data").hexdigest()}) + bad_footer_meta_cksum = md5(footer_meta + "bad").hexdigest() + + req.body = "\r\n".join(( + "--boundary", + "", + "obj data", + "--boundary", + "Content-MD5: " + bad_footer_meta_cksum, + "", + footer_meta, + "--boundary--", + )) + req.headers.pop("Content-Length", None) + + resp = req.get_response(self.object_controller) + self.assertEqual(resp.status_int, 422) + + def test_PUT_bad_footer_json(self): + timestamp = normalize_timestamp(time()) + req = Request.blank( + '/sda1/p/a/c/o', + headers={'X-Timestamp': timestamp, + 'Content-Type': 'text/plain', + 'Transfer-Encoding': 'chunked', + 'X-Backend-Obj-Metadata-Footer': 'yes', + 'X-Backend-Obj-Multipart-Mime-Boundary': 'boundary'}, + environ={'REQUEST_METHOD': 'PUT'}) + + footer_meta = "{{{[[{{[{[[{[{[[{{{[{{{{[[{{[{[" + footer_meta_cksum = md5(footer_meta).hexdigest() + + req.body = "\r\n".join(( + "--boundary", + "", + "obj data", + "--boundary", + "Content-MD5: " + footer_meta_cksum, + "", + footer_meta, + "--boundary--", + )) + req.headers.pop("Content-Length", None) + + resp = req.get_response(self.object_controller) + self.assertEqual(resp.status_int, 400) + + def test_PUT_extra_mime_docs_ignored(self): + timestamp = normalize_timestamp(time()) + req = Request.blank( + '/sda1/p/a/c/o', + headers={'X-Timestamp': timestamp, + 'Content-Type': 'text/plain', + 'Transfer-Encoding': 'chunked', + 'X-Backend-Obj-Metadata-Footer': 'yes', + 'X-Backend-Obj-Multipart-Mime-Boundary': 'boundary'}, + environ={'REQUEST_METHOD': 'PUT'}) + + footer_meta = json.dumps({'X-Object-Meta-Mint': 'pepper'}) + footer_meta_cksum = md5(footer_meta).hexdigest() + + req.body = "\r\n".join(( + "--boundary", + "", + "obj data", + "--boundary", + "Content-MD5: " + footer_meta_cksum, + "", + footer_meta, + "--boundary", + "This-Document-Is-Useless: yes", + "", + "blah blah I take up space", + "--boundary--" + )) + req.headers.pop("Content-Length", None) + + resp = req.get_response(self.object_controller) + self.assertEqual(resp.status_int, 201) + + # swob made this into a StringIO for us + wsgi_input = req.environ['wsgi.input'] + self.assertEqual(wsgi_input.tell(), len(wsgi_input.getvalue())) + def test_PUT_user_metadata_no_xattr(self): timestamp = normalize_timestamp(time()) req = Request.blank( @@ -768,7 +1017,7 @@ class TestObjectController(unittest.TestCase): headers={'X-Timestamp': timestamp, 'Content-Type': 'text/plain', 'Content-Length': '6'}) - req.environ['wsgi.input'] = StringIO('VERIFY') + req.environ['wsgi.input'] = WsgiStringIO('VERIFY') resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 408) @@ -788,7 +1037,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.status_int, 201) objfile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), timestamp + '.data') self.assert_(os.path.isfile(objfile)) @@ -831,7 +1080,7 @@ class TestObjectController(unittest.TestCase): # original .data file metadata should be unchanged objfile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), timestamp1 + '.data') self.assert_(os.path.isfile(objfile)) @@ -849,7 +1098,7 @@ class TestObjectController(unittest.TestCase): # .meta file metadata should have only user meta items metafile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), timestamp2 + '.meta') self.assert_(os.path.isfile(metafile)) @@ -1017,6 +1266,40 @@ class TestObjectController(unittest.TestCase): finally: object_server.http_connect = old_http_connect + def test_PUT_durable_files(self): + for policy in POLICIES: + timestamp = utils.Timestamp(int(time())).internal + data_file_tail = '.data' + headers = {'X-Timestamp': timestamp, + 'Content-Length': '6', + 'Content-Type': 'application/octet-stream', + 'X-Backend-Storage-Policy-Index': int(policy)} + if policy.policy_type == EC_POLICY: + headers['X-Object-Sysmeta-Ec-Frag-Index'] = '2' + data_file_tail = '#2.data' + req = Request.blank( + '/sda1/p/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, + headers=headers) + req.body = 'VERIFY' + resp = req.get_response(self.object_controller) + + self.assertEquals(resp.status_int, 201) + obj_dir = os.path.join( + self.testdir, 'sda1', + storage_directory(diskfile.get_data_dir(int(policy)), + 'p', hash_path('a', 'c', 'o'))) + data_file = os.path.join(obj_dir, timestamp) + data_file_tail + self.assertTrue(os.path.isfile(data_file), + 'Expected file %r not found in %r for policy %r' + % (data_file, os.listdir(obj_dir), int(policy))) + durable_file = os.path.join(obj_dir, timestamp) + '.durable' + if policy.policy_type == EC_POLICY: + self.assertTrue(os.path.isfile(durable_file)) + self.assertFalse(os.path.getsize(durable_file)) + else: + self.assertFalse(os.path.isfile(durable_file)) + rmtree(obj_dir) + def test_HEAD(self): # Test swift.obj.server.ObjectController.HEAD req = Request.blank('/sda1/p/a/c', environ={'REQUEST_METHOD': 'HEAD'}) @@ -1058,7 +1341,7 @@ class TestObjectController(unittest.TestCase): objfile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.data') os.unlink(objfile) @@ -1102,7 +1385,8 @@ class TestObjectController(unittest.TestCase): req.body = 'VERIFY' resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 201) - disk_file = self.df_mgr.get_diskfile('sda1', 'p', 'a', 'c', 'o') + disk_file = self.df_mgr.get_diskfile('sda1', 'p', 'a', 'c', 'o', + policy=POLICIES.legacy) disk_file.open() file_name = os.path.basename(disk_file._data_file) @@ -1133,7 +1417,7 @@ class TestObjectController(unittest.TestCase): resp = server_handler.OPTIONS(req) self.assertEquals(200, resp.status_int) for verb in 'OPTIONS GET POST PUT DELETE HEAD REPLICATE \ - REPLICATION'.split(): + SSYNC'.split(): self.assertTrue( verb in resp.headers['Allow'].split(', ')) self.assertEquals(len(resp.headers['Allow'].split(', ')), 8) @@ -1201,7 +1485,7 @@ class TestObjectController(unittest.TestCase): objfile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.data') os.unlink(objfile) @@ -1290,6 +1574,58 @@ class TestObjectController(unittest.TestCase): resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 412) + def test_GET_if_match_etag_is_at(self): + headers = { + 'X-Timestamp': utils.Timestamp(time()).internal, + 'Content-Type': 'application/octet-stream', + 'X-Object-Meta-Xtag': 'madeup', + } + req = Request.blank('/sda1/p/a/c/o', method='PUT', + headers=headers) + req.body = 'test' + resp = req.get_response(self.object_controller) + self.assertEquals(resp.status_int, 201) + real_etag = resp.etag + + # match x-backend-etag-is-at + req = Request.blank('/sda1/p/a/c/o', headers={ + 'If-Match': 'madeup', + 'X-Backend-Etag-Is-At': 'X-Object-Meta-Xtag'}) + resp = req.get_response(self.object_controller) + self.assertEqual(resp.status_int, 200) + + # no match x-backend-etag-is-at + req = Request.blank('/sda1/p/a/c/o', headers={ + 'If-Match': real_etag, + 'X-Backend-Etag-Is-At': 'X-Object-Meta-Xtag'}) + resp = req.get_response(self.object_controller) + self.assertEqual(resp.status_int, 412) + + # etag-is-at metadata doesn't exist, default to real etag + req = Request.blank('/sda1/p/a/c/o', headers={ + 'If-Match': real_etag, + 'X-Backend-Etag-Is-At': 'X-Object-Meta-Missing'}) + resp = req.get_response(self.object_controller) + self.assertEqual(resp.status_int, 200) + + # sanity no-match with no etag-is-at + req = Request.blank('/sda1/p/a/c/o', headers={ + 'If-Match': 'madeup'}) + resp = req.get_response(self.object_controller) + self.assertEqual(resp.status_int, 412) + + # sanity match with no etag-is-at + req = Request.blank('/sda1/p/a/c/o', headers={ + 'If-Match': real_etag}) + resp = req.get_response(self.object_controller) + self.assertEqual(resp.status_int, 200) + + # sanity with no if-match + req = Request.blank('/sda1/p/a/c/o', headers={ + 'X-Backend-Etag-Is-At': 'X-Object-Meta-Xtag'}) + resp = req.get_response(self.object_controller) + self.assertEqual(resp.status_int, 200) + def test_HEAD_if_match(self): req = Request.blank('/sda1/p/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, headers={ @@ -1692,7 +2028,8 @@ class TestObjectController(unittest.TestCase): req.body = 'VERIFY' resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 201) - disk_file = self.df_mgr.get_diskfile('sda1', 'p', 'a', 'c', 'o') + disk_file = self.df_mgr.get_diskfile('sda1', 'p', 'a', 'c', 'o', + policy=POLICIES.legacy) disk_file.open() file_name = os.path.basename(disk_file._data_file) etag = md5() @@ -1724,7 +2061,8 @@ class TestObjectController(unittest.TestCase): req.body = 'VERIFY' resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 201) - disk_file = self.df_mgr.get_diskfile('sda1', 'p', 'a', 'c', 'o') + disk_file = self.df_mgr.get_diskfile('sda1', 'p', 'a', 'c', 'o', + policy=POLICIES.legacy) disk_file.open() file_name = os.path.basename(disk_file._data_file) with open(disk_file._data_file) as fp: @@ -1752,7 +2090,8 @@ class TestObjectController(unittest.TestCase): req.body = 'VERIFY' resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 201) - disk_file = self.df_mgr.get_diskfile('sda1', 'p', 'a', 'c', 'o') + disk_file = self.df_mgr.get_diskfile('sda1', 'p', 'a', 'c', 'o', + policy=POLICIES.legacy) disk_file.open() file_name = os.path.basename(disk_file._data_file) etag = md5() @@ -1810,7 +2149,6 @@ class TestObjectController(unittest.TestCase): environ={'REQUEST_METHOD': 'DELETE'}) resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 400) - # self.assertRaises(KeyError, self.object_controller.DELETE, req) # The following should have created a tombstone file timestamp = normalize_timestamp(1000) @@ -1821,7 +2159,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.status_int, 404) ts_1000_file = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.ts') self.assertTrue(os.path.isfile(ts_1000_file)) @@ -1837,7 +2175,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.status_int, 404) ts_999_file = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.ts') self.assertFalse(os.path.isfile(ts_999_file)) @@ -1857,7 +2195,7 @@ class TestObjectController(unittest.TestCase): # There should now be 1000 ts and a 1001 data file. data_1002_file = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), orig_timestamp + '.data') self.assertTrue(os.path.isfile(data_1002_file)) @@ -1873,7 +2211,7 @@ class TestObjectController(unittest.TestCase): self.assertEqual(resp.headers['X-Backend-Timestamp'], orig_timestamp) ts_1001_file = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.ts') self.assertFalse(os.path.isfile(ts_1001_file)) @@ -1888,7 +2226,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.status_int, 204) ts_1003_file = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.ts') self.assertTrue(os.path.isfile(ts_1003_file)) @@ -1930,7 +2268,7 @@ class TestObjectController(unittest.TestCase): orig_timestamp.internal) objfile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.ts') self.assertFalse(os.path.isfile(objfile)) @@ -1949,7 +2287,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.status_int, 204) objfile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.ts') self.assert_(os.path.isfile(objfile)) @@ -1968,7 +2306,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.status_int, 404) objfile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.ts') self.assert_(os.path.isfile(objfile)) @@ -1987,7 +2325,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.status_int, 404) objfile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(timestamp).internal + '.ts') self.assertFalse(os.path.isfile(objfile)) @@ -2184,7 +2522,7 @@ class TestObjectController(unittest.TestCase): def test_call_bad_request(self): # Test swift.obj.server.ObjectController.__call__ - inbuf = StringIO() + inbuf = WsgiStringIO() errbuf = StringIO() outbuf = StringIO() @@ -2211,7 +2549,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(outbuf.getvalue()[:4], '400 ') def test_call_not_found(self): - inbuf = StringIO() + inbuf = WsgiStringIO() errbuf = StringIO() outbuf = StringIO() @@ -2238,7 +2576,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(outbuf.getvalue()[:4], '404 ') def test_call_bad_method(self): - inbuf = StringIO() + inbuf = WsgiStringIO() errbuf = StringIO() outbuf = StringIO() @@ -2274,7 +2612,7 @@ class TestObjectController(unittest.TestCase): with mock.patch("swift.obj.diskfile.hash_path", my_hash_path): with mock.patch("swift.obj.server.check_object_creation", my_check): - inbuf = StringIO() + inbuf = WsgiStringIO() errbuf = StringIO() outbuf = StringIO() @@ -2303,7 +2641,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(errbuf.getvalue(), '') self.assertEquals(outbuf.getvalue()[:4], '201 ') - inbuf = StringIO() + inbuf = WsgiStringIO() errbuf = StringIO() outbuf = StringIO() @@ -2454,6 +2792,9 @@ class TestObjectController(unittest.TestCase): return ' ' return '' + def set_hundred_continue_response_headers(*a, **kw): + pass + req = Request.blank( '/sda1/p/a/c/o', environ={'REQUEST_METHOD': 'PUT', 'wsgi.input': SlowBody()}, @@ -2483,6 +2824,9 @@ class TestObjectController(unittest.TestCase): return ' ' return '' + def set_hundred_continue_response_headers(*a, **kw): + pass + req = Request.blank( '/sda1/p/a/c/o', environ={'REQUEST_METHOD': 'PUT', 'wsgi.input': ShortBody()}, @@ -2554,8 +2898,8 @@ class TestObjectController(unittest.TestCase): self.object_controller.async_update( 'PUT', 'a', 'c', 'o', '127.0.0.1:1234', 1, 'sdc1', {'x-timestamp': '1', 'x-out': 'set', - 'X-Backend-Storage-Policy-Index': policy.idx}, 'sda1', - policy.idx) + 'X-Backend-Storage-Policy-Index': int(policy)}, 'sda1', + policy) finally: object_server.http_connect = orig_http_connect self.assertEquals( @@ -2563,12 +2907,15 @@ class TestObjectController(unittest.TestCase): ['127.0.0.1', '1234', 'sdc1', 1, 'PUT', '/a/c/o', { 'x-timestamp': '1', 'x-out': 'set', 'user-agent': 'object-server %s' % os.getpid(), - 'X-Backend-Storage-Policy-Index': policy.idx}]) + 'X-Backend-Storage-Policy-Index': int(policy)}]) - @patch_policies([storage_policy.StoragePolicy(0, 'zero', True), - storage_policy.StoragePolicy(1, 'one'), - storage_policy.StoragePolicy(37, 'fantastico')]) + @patch_policies([StoragePolicy(0, 'zero', True), + StoragePolicy(1, 'one'), + StoragePolicy(37, 'fantastico')]) def test_updating_multiple_delete_at_container_servers(self): + # update router post patch + self.object_controller._diskfile_router = diskfile.DiskFileRouter( + self.conf, self.object_controller.logger) policy = random.choice(list(POLICIES)) self.object_controller.expiring_objects_account = 'exp' self.object_controller.expiring_objects_container_divisor = 60 @@ -2607,7 +2954,7 @@ class TestObjectController(unittest.TestCase): headers={'X-Timestamp': '12345', 'Content-Type': 'application/burrito', 'Content-Length': '0', - 'X-Backend-Storage-Policy-Index': policy.idx, + 'X-Backend-Storage-Policy-Index': int(policy), 'X-Container-Partition': '20', 'X-Container-Host': '1.2.3.4:5', 'X-Container-Device': 'sdb1', @@ -2643,7 +2990,7 @@ class TestObjectController(unittest.TestCase): 'X-Backend-Storage-Policy-Index': '37', 'referer': 'PUT http://localhost/sda1/p/a/c/o', 'user-agent': 'object-server %d' % os.getpid(), - 'X-Backend-Storage-Policy-Index': policy.idx, + 'X-Backend-Storage-Policy-Index': int(policy), 'x-trans-id': '-'})}) self.assertEquals( http_connect_args[1], @@ -2684,10 +3031,13 @@ class TestObjectController(unittest.TestCase): 'X-Backend-Storage-Policy-Index': 0, 'x-trans-id': '-'})}) - @patch_policies([storage_policy.StoragePolicy(0, 'zero', True), - storage_policy.StoragePolicy(1, 'one'), - storage_policy.StoragePolicy(26, 'twice-thirteen')]) + @patch_policies([StoragePolicy(0, 'zero', True), + StoragePolicy(1, 'one'), + StoragePolicy(26, 'twice-thirteen')]) def test_updating_multiple_container_servers(self): + # update router post patch + self.object_controller._diskfile_router = diskfile.DiskFileRouter( + self.conf, self.object_controller.logger) http_connect_args = [] def fake_http_connect(ipaddr, port, device, partition, method, path, @@ -2788,7 +3138,7 @@ class TestObjectController(unittest.TestCase): int(delete_at_timestamp) / self.object_controller.expiring_objects_container_divisor * self.object_controller.expiring_objects_container_divisor) - req = Request.blank('/sda1/p/a/c/o', method='PUT', body='', headers={ + headers = { 'Content-Type': 'text/plain', 'X-Timestamp': put_timestamp, 'X-Container-Host': '10.0.0.1:6001', @@ -2799,8 +3149,11 @@ class TestObjectController(unittest.TestCase): 'X-Delete-At-Partition': 'p', 'X-Delete-At-Host': '10.0.0.2:6002', 'X-Delete-At-Device': 'sda1', - 'X-Backend-Storage-Policy-Index': int(policy), - }) + 'X-Backend-Storage-Policy-Index': int(policy)} + if policy.policy_type == EC_POLICY: + headers['X-Object-Sysmeta-Ec-Frag-Index'] = '2' + req = Request.blank( + '/sda1/p/a/c/o', method='PUT', body='', headers=headers) with mocked_http_conn( 500, 500, give_connect=capture_updates) as fake_conn: resp = req.get_response(self.object_controller) @@ -2836,7 +3189,7 @@ class TestObjectController(unittest.TestCase): self.assertEqual(headers[key], str(value)) # check async pendings async_dir = os.path.join(self.testdir, 'sda1', - diskfile.get_async_dir(policy.idx)) + diskfile.get_async_dir(policy)) found_files = [] for root, dirs, files in os.walk(async_dir): for f in files: @@ -2846,7 +3199,7 @@ class TestObjectController(unittest.TestCase): if data['account'] == 'a': self.assertEquals( int(data['headers'] - ['X-Backend-Storage-Policy-Index']), policy.idx) + ['X-Backend-Storage-Policy-Index']), int(policy)) elif data['account'] == '.expiring_objects': self.assertEquals( int(data['headers'] @@ -2870,12 +3223,12 @@ class TestObjectController(unittest.TestCase): self.object_controller.async_update( 'PUT', 'a', 'c', 'o', '127.0.0.1:1234', 1, 'sdc1', {'x-timestamp': '1', 'x-out': 'set', - 'X-Backend-Storage-Policy-Index': policy.idx}, 'sda1', - policy.idx) + 'X-Backend-Storage-Policy-Index': int(policy)}, 'sda1', + policy) finally: object_server.http_connect = orig_http_connect utils.HASH_PATH_PREFIX = _prefix - async_dir = diskfile.get_async_dir(policy.idx) + async_dir = diskfile.get_async_dir(policy) self.assertEquals( pickle.load(open(os.path.join( self.testdir, 'sda1', async_dir, 'a83', @@ -2883,7 +3236,7 @@ class TestObjectController(unittest.TestCase): utils.Timestamp(1).internal))), {'headers': {'x-timestamp': '1', 'x-out': 'set', 'user-agent': 'object-server %s' % os.getpid(), - 'X-Backend-Storage-Policy-Index': policy.idx}, + 'X-Backend-Storage-Policy-Index': int(policy)}, 'account': 'a', 'container': 'c', 'obj': 'o', 'op': 'PUT'}) def test_async_update_saves_on_non_2xx(self): @@ -2914,9 +3267,9 @@ class TestObjectController(unittest.TestCase): self.object_controller.async_update( 'PUT', 'a', 'c', 'o', '127.0.0.1:1234', 1, 'sdc1', {'x-timestamp': '1', 'x-out': str(status), - 'X-Backend-Storage-Policy-Index': policy.idx}, 'sda1', - policy.idx) - async_dir = diskfile.get_async_dir(policy.idx) + 'X-Backend-Storage-Policy-Index': int(policy)}, 'sda1', + policy) + async_dir = diskfile.get_async_dir(policy) self.assertEquals( pickle.load(open(os.path.join( self.testdir, 'sda1', async_dir, 'a83', @@ -2926,7 +3279,7 @@ class TestObjectController(unittest.TestCase): 'user-agent': 'object-server %s' % os.getpid(), 'X-Backend-Storage-Policy-Index': - policy.idx}, + int(policy)}, 'account': 'a', 'container': 'c', 'obj': 'o', 'op': 'PUT'}) finally: @@ -2990,8 +3343,8 @@ class TestObjectController(unittest.TestCase): self.object_controller.async_update( 'PUT', 'a', 'c', 'o', '127.0.0.1:1234', 1, 'sdc1', {'x-timestamp': '1', 'x-out': str(status)}, 'sda1', - policy.idx) - async_dir = diskfile.get_async_dir(int(policy)) + policy) + async_dir = diskfile.get_async_dir(policy) self.assertTrue( os.path.exists(os.path.join( self.testdir, 'sda1', async_dir, 'a83', @@ -3002,6 +3355,7 @@ class TestObjectController(unittest.TestCase): utils.HASH_PATH_PREFIX = _prefix def test_container_update_no_async_update(self): + policy = random.choice(list(POLICIES)) given_args = [] def fake_async_update(*args): @@ -3012,12 +3366,13 @@ class TestObjectController(unittest.TestCase): '/v1/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, headers={'X-Timestamp': 1, - 'X-Trans-Id': '1234'}) + 'X-Trans-Id': '1234', + 'X-Backend-Storage-Policy-Index': int(policy)}) self.object_controller.container_update( 'PUT', 'a', 'c', 'o', req, { 'x-size': '0', 'x-etag': 'd41d8cd98f00b204e9800998ecf8427e', 'x-content-type': 'text/plain', 'x-timestamp': '1'}, - 'sda1', 0) + 'sda1', policy) self.assertEquals(given_args, []) def test_container_update_success(self): @@ -3099,6 +3454,7 @@ class TestObjectController(unittest.TestCase): 'x-foo': 'bar'})) def test_container_update_async(self): + policy = random.choice(list(POLICIES)) req = Request.blank( '/sda1/0/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, @@ -3107,26 +3463,28 @@ class TestObjectController(unittest.TestCase): 'X-Container-Host': 'chost:cport', 'X-Container-Partition': 'cpartition', 'X-Container-Device': 'cdevice', - 'Content-Type': 'text/plain'}, body='') + 'Content-Type': 'text/plain', + 'X-Object-Sysmeta-Ec-Frag-Index': 0, + 'X-Backend-Storage-Policy-Index': int(policy)}, body='') given_args = [] def fake_pickle_async_update(*args): given_args[:] = args - self.object_controller._diskfile_mgr.pickle_async_update = \ - fake_pickle_async_update + diskfile_mgr = self.object_controller._diskfile_router[policy] + diskfile_mgr.pickle_async_update = fake_pickle_async_update with mocked_http_conn(500) as fake_conn: resp = req.get_response(self.object_controller) self.assertRaises(StopIteration, fake_conn.code_iter.next) self.assertEqual(resp.status_int, 201) self.assertEqual(len(given_args), 7) (objdevice, account, container, obj, data, timestamp, - policy_index) = given_args + policy) = given_args self.assertEqual(objdevice, 'sda1') self.assertEqual(account, 'a') self.assertEqual(container, 'c') self.assertEqual(obj, 'o') self.assertEqual(timestamp, utils.Timestamp(1).internal) - self.assertEqual(policy_index, 0) + self.assertEqual(policy, policy) self.assertEqual(data, { 'headers': HeaderKeyDict({ 'X-Size': '0', @@ -3135,7 +3493,7 @@ class TestObjectController(unittest.TestCase): 'X-Timestamp': utils.Timestamp(1).internal, 'X-Trans-Id': '123', 'Referer': 'PUT http://localhost/sda1/0/a/c/o', - 'X-Backend-Storage-Policy-Index': '0', + 'X-Backend-Storage-Policy-Index': int(policy), 'X-Etag': 'd41d8cd98f00b204e9800998ecf8427e'}), 'obj': 'o', 'account': 'a', @@ -3143,6 +3501,7 @@ class TestObjectController(unittest.TestCase): 'op': 'PUT'}) def test_container_update_bad_args(self): + policy = random.choice(list(POLICIES)) given_args = [] def fake_async_update(*args): @@ -3155,7 +3514,8 @@ class TestObjectController(unittest.TestCase): 'X-Trans-Id': '123', 'X-Container-Host': 'chost,badhost', 'X-Container-Partition': 'cpartition', - 'X-Container-Device': 'cdevice'}) + 'X-Container-Device': 'cdevice', + 'X-Backend-Storage-Policy-Index': int(policy)}) with mock.patch.object(self.object_controller, 'async_update', fake_async_update): self.object_controller.container_update( @@ -3163,7 +3523,7 @@ class TestObjectController(unittest.TestCase): 'x-size': '0', 'x-etag': 'd41d8cd98f00b204e9800998ecf8427e', 'x-content-type': 'text/plain', 'x-timestamp': '1'}, - 'sda1', 0) + 'sda1', policy) self.assertEqual(given_args, []) errors = self.object_controller.logger.get_lines_for_level('error') self.assertEqual(len(errors), 1) @@ -3176,6 +3536,7 @@ class TestObjectController(unittest.TestCase): def test_delete_at_update_on_put(self): # Test how delete_at_update works when issued a delete for old # expiration info after a new put with no new expiration info. + policy = random.choice(list(POLICIES)) given_args = [] def fake_async_update(*args): @@ -3185,11 +3546,12 @@ class TestObjectController(unittest.TestCase): '/v1/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, headers={'X-Timestamp': 1, - 'X-Trans-Id': '123'}) + 'X-Trans-Id': '123', + 'X-Backend-Storage-Policy-Index': int(policy)}) with mock.patch.object(self.object_controller, 'async_update', fake_async_update): self.object_controller.delete_at_update( - 'DELETE', 2, 'a', 'c', 'o', req, 'sda1', 0) + 'DELETE', 2, 'a', 'c', 'o', req, 'sda1', policy) self.assertEquals( given_args, [ 'DELETE', '.expiring_objects', '0000000000', @@ -3199,12 +3561,13 @@ class TestObjectController(unittest.TestCase): 'x-timestamp': utils.Timestamp('1').internal, 'x-trans-id': '123', 'referer': 'PUT http://localhost/v1/a/c/o'}), - 'sda1', 0]) + 'sda1', policy]) def test_delete_at_negative(self): # Test how delete_at_update works when issued a delete for old # expiration info after a new put with no new expiration info. # Test negative is reset to 0 + policy = random.choice(list(POLICIES)) given_args = [] def fake_async_update(*args): @@ -3215,23 +3578,26 @@ class TestObjectController(unittest.TestCase): '/v1/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, headers={'X-Timestamp': 1, - 'X-Trans-Id': '1234'}) + 'X-Trans-Id': '1234', 'X-Backend-Storage-Policy-Index': + int(policy)}) self.object_controller.delete_at_update( - 'DELETE', -2, 'a', 'c', 'o', req, 'sda1', 0) + 'DELETE', -2, 'a', 'c', 'o', req, 'sda1', policy) self.assertEquals(given_args, [ 'DELETE', '.expiring_objects', '0000000000', '0000000000-a/c/o', None, None, None, HeaderKeyDict({ + # the expiring objects account is always 0 'X-Backend-Storage-Policy-Index': 0, 'x-timestamp': utils.Timestamp('1').internal, 'x-trans-id': '1234', 'referer': 'PUT http://localhost/v1/a/c/o'}), - 'sda1', 0]) + 'sda1', policy]) def test_delete_at_cap(self): # Test how delete_at_update works when issued a delete for old # expiration info after a new put with no new expiration info. # Test past cap is reset to cap + policy = random.choice(list(POLICIES)) given_args = [] def fake_async_update(*args): @@ -3242,9 +3608,10 @@ class TestObjectController(unittest.TestCase): '/v1/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, headers={'X-Timestamp': 1, - 'X-Trans-Id': '1234'}) + 'X-Trans-Id': '1234', + 'X-Backend-Storage-Policy-Index': int(policy)}) self.object_controller.delete_at_update( - 'DELETE', 12345678901, 'a', 'c', 'o', req, 'sda1', 0) + 'DELETE', 12345678901, 'a', 'c', 'o', req, 'sda1', policy) expiring_obj_container = given_args.pop(2) expected_exp_cont = utils.get_expirer_container( utils.normalize_delete_at_timestamp(12345678901), @@ -3259,12 +3626,13 @@ class TestObjectController(unittest.TestCase): 'x-timestamp': utils.Timestamp('1').internal, 'x-trans-id': '1234', 'referer': 'PUT http://localhost/v1/a/c/o'}), - 'sda1', 0]) + 'sda1', policy]) def test_delete_at_update_put_with_info(self): # Keep next test, # test_delete_at_update_put_with_info_but_missing_container, in sync # with this one but just missing the X-Delete-At-Container header. + policy = random.choice(list(POLICIES)) given_args = [] def fake_async_update(*args): @@ -3279,14 +3647,16 @@ class TestObjectController(unittest.TestCase): 'X-Delete-At-Container': '0', 'X-Delete-At-Host': '127.0.0.1:1234', 'X-Delete-At-Partition': '3', - 'X-Delete-At-Device': 'sdc1'}) + 'X-Delete-At-Device': 'sdc1', + 'X-Backend-Storage-Policy-Index': int(policy)}) self.object_controller.delete_at_update('PUT', 2, 'a', 'c', 'o', - req, 'sda1', 0) + req, 'sda1', policy) self.assertEquals( given_args, [ 'PUT', '.expiring_objects', '0000000000', '0000000002-a/c/o', '127.0.0.1:1234', '3', 'sdc1', HeaderKeyDict({ + # the .expiring_objects account is always policy-0 'X-Backend-Storage-Policy-Index': 0, 'x-size': '0', 'x-etag': 'd41d8cd98f00b204e9800998ecf8427e', @@ -3294,18 +3664,19 @@ class TestObjectController(unittest.TestCase): 'x-timestamp': utils.Timestamp('1').internal, 'x-trans-id': '1234', 'referer': 'PUT http://localhost/v1/a/c/o'}), - 'sda1', 0]) + 'sda1', policy]) def test_delete_at_update_put_with_info_but_missing_container(self): # Same as previous test, test_delete_at_update_put_with_info, but just # missing the X-Delete-At-Container header. + policy = random.choice(list(POLICIES)) given_args = [] def fake_async_update(*args): given_args.extend(args) self.object_controller.async_update = fake_async_update - self.object_controller.logger = FakeLogger() + self.object_controller.logger = self.logger req = Request.blank( '/v1/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, @@ -3313,16 +3684,18 @@ class TestObjectController(unittest.TestCase): 'X-Trans-Id': '1234', 'X-Delete-At-Host': '127.0.0.1:1234', 'X-Delete-At-Partition': '3', - 'X-Delete-At-Device': 'sdc1'}) + 'X-Delete-At-Device': 'sdc1', + 'X-Backend-Storage-Policy-Index': int(policy)}) self.object_controller.delete_at_update('PUT', 2, 'a', 'c', 'o', - req, 'sda1', 0) + req, 'sda1', policy) self.assertEquals( - self.object_controller.logger.log_dict['warning'], - [(('X-Delete-At-Container header must be specified for expiring ' - 'objects background PUT to work properly. Making best guess as ' - 'to the container name for now.',), {})]) + self.logger.get_lines_for_level('warning'), + ['X-Delete-At-Container header must be specified for expiring ' + 'objects background PUT to work properly. Making best guess as ' + 'to the container name for now.']) def test_delete_at_update_delete(self): + policy = random.choice(list(POLICIES)) given_args = [] def fake_async_update(*args): @@ -3333,9 +3706,10 @@ class TestObjectController(unittest.TestCase): '/v1/a/c/o', environ={'REQUEST_METHOD': 'DELETE'}, headers={'X-Timestamp': 1, - 'X-Trans-Id': '1234'}) + 'X-Trans-Id': '1234', + 'X-Backend-Storage-Policy-Index': int(policy)}) self.object_controller.delete_at_update('DELETE', 2, 'a', 'c', 'o', - req, 'sda1', 0) + req, 'sda1', policy) self.assertEquals( given_args, [ 'DELETE', '.expiring_objects', '0000000000', @@ -3345,11 +3719,12 @@ class TestObjectController(unittest.TestCase): 'x-timestamp': utils.Timestamp('1').internal, 'x-trans-id': '1234', 'referer': 'DELETE http://localhost/v1/a/c/o'}), - 'sda1', 0]) + 'sda1', policy]) def test_delete_backend_replication(self): # If X-Backend-Replication: True delete_at_update should completely # short-circuit. + policy = random.choice(list(POLICIES)) given_args = [] def fake_async_update(*args): @@ -3361,12 +3736,14 @@ class TestObjectController(unittest.TestCase): environ={'REQUEST_METHOD': 'PUT'}, headers={'X-Timestamp': 1, 'X-Trans-Id': '1234', - 'X-Backend-Replication': 'True'}) + 'X-Backend-Replication': 'True', + 'X-Backend-Storage-Policy-Index': int(policy)}) self.object_controller.delete_at_update( - 'DELETE', -2, 'a', 'c', 'o', req, 'sda1', 0) + 'DELETE', -2, 'a', 'c', 'o', req, 'sda1', policy) self.assertEquals(given_args, []) def test_POST_calls_delete_at(self): + policy = random.choice(list(POLICIES)) given_args = [] def fake_delete_at_update(*args): @@ -3378,7 +3755,9 @@ class TestObjectController(unittest.TestCase): '/sda1/p/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, headers={'X-Timestamp': normalize_timestamp(time()), 'Content-Length': '4', - 'Content-Type': 'application/octet-stream'}) + 'Content-Type': 'application/octet-stream', + 'X-Backend-Storage-Policy-Index': int(policy), + 'X-Object-Sysmeta-Ec-Frag-Index': 2}) req.body = 'TEST' resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 201) @@ -3389,7 +3768,8 @@ class TestObjectController(unittest.TestCase): '/sda1/p/a/c/o', environ={'REQUEST_METHOD': 'POST'}, headers={'X-Timestamp': normalize_timestamp(time()), - 'Content-Type': 'application/x-test'}) + 'Content-Type': 'application/x-test', + 'X-Backend-Storage-Policy-Index': int(policy)}) resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 202) self.assertEquals(given_args, []) @@ -3402,13 +3782,14 @@ class TestObjectController(unittest.TestCase): environ={'REQUEST_METHOD': 'POST'}, headers={'X-Timestamp': timestamp1, 'Content-Type': 'application/x-test', - 'X-Delete-At': delete_at_timestamp1}) + 'X-Delete-At': delete_at_timestamp1, + 'X-Backend-Storage-Policy-Index': int(policy)}) resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 202) self.assertEquals( given_args, [ 'PUT', int(delete_at_timestamp1), 'a', 'c', 'o', - given_args[5], 'sda1', 0]) + given_args[5], 'sda1', policy]) while given_args: given_args.pop() @@ -3421,17 +3802,19 @@ class TestObjectController(unittest.TestCase): environ={'REQUEST_METHOD': 'POST'}, headers={'X-Timestamp': timestamp2, 'Content-Type': 'application/x-test', - 'X-Delete-At': delete_at_timestamp2}) + 'X-Delete-At': delete_at_timestamp2, + 'X-Backend-Storage-Policy-Index': int(policy)}) resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 202) self.assertEquals( given_args, [ 'PUT', int(delete_at_timestamp2), 'a', 'c', 'o', - given_args[5], 'sda1', 0, + given_args[5], 'sda1', policy, 'DELETE', int(delete_at_timestamp1), 'a', 'c', 'o', - given_args[5], 'sda1', 0]) + given_args[5], 'sda1', policy]) def test_PUT_calls_delete_at(self): + policy = random.choice(list(POLICIES)) given_args = [] def fake_delete_at_update(*args): @@ -3443,7 +3826,9 @@ class TestObjectController(unittest.TestCase): '/sda1/p/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, headers={'X-Timestamp': normalize_timestamp(time()), 'Content-Length': '4', - 'Content-Type': 'application/octet-stream'}) + 'Content-Type': 'application/octet-stream', + 'X-Backend-Storage-Policy-Index': int(policy), + 'X-Object-Sysmeta-Ec-Frag-Index': 4}) req.body = 'TEST' resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 201) @@ -3457,14 +3842,16 @@ class TestObjectController(unittest.TestCase): headers={'X-Timestamp': timestamp1, 'Content-Length': '4', 'Content-Type': 'application/octet-stream', - 'X-Delete-At': delete_at_timestamp1}) + 'X-Delete-At': delete_at_timestamp1, + 'X-Backend-Storage-Policy-Index': int(policy), + 'X-Object-Sysmeta-Ec-Frag-Index': 3}) req.body = 'TEST' resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 201) self.assertEquals( given_args, [ 'PUT', int(delete_at_timestamp1), 'a', 'c', 'o', - given_args[5], 'sda1', 0]) + given_args[5], 'sda1', policy]) while given_args: given_args.pop() @@ -3478,16 +3865,18 @@ class TestObjectController(unittest.TestCase): headers={'X-Timestamp': timestamp2, 'Content-Length': '4', 'Content-Type': 'application/octet-stream', - 'X-Delete-At': delete_at_timestamp2}) + 'X-Delete-At': delete_at_timestamp2, + 'X-Backend-Storage-Policy-Index': int(policy), + 'X-Object-Sysmeta-Ec-Frag-Index': 3}) req.body = 'TEST' resp = req.get_response(self.object_controller) self.assertEquals(resp.status_int, 201) self.assertEquals( given_args, [ 'PUT', int(delete_at_timestamp2), 'a', 'c', 'o', - given_args[5], 'sda1', 0, + given_args[5], 'sda1', policy, 'DELETE', int(delete_at_timestamp1), 'a', 'c', 'o', - given_args[5], 'sda1', 0]) + given_args[5], 'sda1', policy]) def test_GET_but_expired(self): test_time = time() + 10000 @@ -3742,7 +4131,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.body, 'TEST') objfile = os.path.join( self.testdir, 'sda1', - storage_directory(diskfile.get_data_dir(0), 'p', + storage_directory(diskfile.get_data_dir(POLICIES[0]), 'p', hash_path('a', 'c', 'o')), utils.Timestamp(test_timestamp).internal + '.data') self.assert_(os.path.isfile(objfile)) @@ -3909,7 +4298,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.status_int, 201) self.assertEquals(given_args, [ 'PUT', int(delete_at_timestamp1), 'a', 'c', 'o', - given_args[5], 'sda1', 0]) + given_args[5], 'sda1', POLICIES[0]]) while given_args: given_args.pop() @@ -3925,7 +4314,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.status_int, 204) self.assertEquals(given_args, [ 'DELETE', int(delete_at_timestamp1), 'a', 'c', 'o', - given_args[5], 'sda1', 0]) + given_args[5], 'sda1', POLICIES[0]]) def test_PUT_delete_at_in_past(self): req = Request.blank( @@ -3967,10 +4356,10 @@ class TestObjectController(unittest.TestCase): def my_tpool_execute(func, *args, **kwargs): return func(*args, **kwargs) - was_get_hashes = diskfile.get_hashes + was_get_hashes = diskfile.DiskFileManager._get_hashes was_tpool_exe = tpool.execute try: - diskfile.get_hashes = fake_get_hashes + diskfile.DiskFileManager._get_hashes = fake_get_hashes tpool.execute = my_tpool_execute req = Request.blank('/sda1/p/suff', environ={'REQUEST_METHOD': 'REPLICATE'}, @@ -3981,7 +4370,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(p_data, {1: 2}) finally: tpool.execute = was_tpool_exe - diskfile.get_hashes = was_get_hashes + diskfile.DiskFileManager._get_hashes = was_get_hashes def test_REPLICATE_timeout(self): @@ -3991,10 +4380,10 @@ class TestObjectController(unittest.TestCase): def my_tpool_execute(func, *args, **kwargs): return func(*args, **kwargs) - was_get_hashes = diskfile.get_hashes + was_get_hashes = diskfile.DiskFileManager._get_hashes was_tpool_exe = tpool.execute try: - diskfile.get_hashes = fake_get_hashes + diskfile.DiskFileManager._get_hashes = fake_get_hashes tpool.execute = my_tpool_execute req = Request.blank('/sda1/p/suff', environ={'REQUEST_METHOD': 'REPLICATE'}, @@ -4002,7 +4391,7 @@ class TestObjectController(unittest.TestCase): self.assertRaises(Timeout, self.object_controller.REPLICATE, req) finally: tpool.execute = was_tpool_exe - diskfile.get_hashes = was_get_hashes + diskfile.DiskFileManager._get_hashes = was_get_hashes def test_REPLICATE_insufficient_storage(self): conf = {'devices': self.testdir, 'mount_check': 'true'} @@ -4020,9 +4409,9 @@ class TestObjectController(unittest.TestCase): resp = req.get_response(self.object_controller) self.assertEqual(resp.status_int, 507) - def test_REPLICATION_can_be_called(self): + def test_SSYNC_can_be_called(self): req = Request.blank('/sda1/p/other/suff', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, headers={}) resp = req.get_response(self.object_controller) self.assertEqual(resp.status_int, 200) @@ -4041,7 +4430,7 @@ class TestObjectController(unittest.TestCase): return '' def fake_fallocate(fd, size): - raise OSError(42, 'Unable to fallocate(%d)' % size) + raise OSError(errno.ENOSPC, os.strerror(errno.ENOSPC)) orig_fallocate = diskfile.fallocate try: @@ -4113,7 +4502,7 @@ class TestObjectController(unittest.TestCase): def test_list_allowed_methods(self): # Test list of allowed_methods obj_methods = ['DELETE', 'PUT', 'HEAD', 'GET', 'POST'] - repl_methods = ['REPLICATE', 'REPLICATION'] + repl_methods = ['REPLICATE', 'SSYNC'] for method_name in obj_methods: method = getattr(self.object_controller, method_name) self.assertFalse(hasattr(method, 'replication')) @@ -4124,7 +4513,7 @@ class TestObjectController(unittest.TestCase): def test_correct_allowed_method(self): # Test correct work for allowed method using # swift.obj.server.ObjectController.__call__ - inbuf = StringIO() + inbuf = WsgiStringIO() errbuf = StringIO() outbuf = StringIO() self.object_controller = object_server.app_factory( @@ -4162,12 +4551,12 @@ class TestObjectController(unittest.TestCase): def test_not_allowed_method(self): # Test correct work for NOT allowed method using # swift.obj.server.ObjectController.__call__ - inbuf = StringIO() + inbuf = WsgiStringIO() errbuf = StringIO() outbuf = StringIO() self.object_controller = object_server.ObjectController( {'devices': self.testdir, 'mount_check': 'false', - 'replication_server': 'false'}, logger=FakeLogger()) + 'replication_server': 'false'}, logger=self.logger) def start_response(*args): # Sends args to outbuf @@ -4207,11 +4596,10 @@ class TestObjectController(unittest.TestCase): env, start_response) self.assertEqual(response, answer) self.assertEqual( - self.object_controller.logger.log_dict['info'], - [(('None - - [01/Jan/1970:02:46:41 +0000] "PUT' - ' /sda1/p/a/c/o" 405 - "-" "-" "-" 1.0000 "-"' - ' 1234 -',), - {})]) + self.logger.get_lines_for_level('info'), + ['None - - [01/Jan/1970:02:46:41 +0000] "PUT' + ' /sda1/p/a/c/o" 405 - "-" "-" "-" 1.0000 "-"' + ' 1234 -']) def test_call_incorrect_replication_method(self): inbuf = StringIO() @@ -4246,7 +4634,7 @@ class TestObjectController(unittest.TestCase): self.assertEquals(outbuf.getvalue()[:4], '405 ') def test_not_utf8_and_not_logging_requests(self): - inbuf = StringIO() + inbuf = WsgiStringIO() errbuf = StringIO() outbuf = StringIO() self.object_controller = object_server.ObjectController( @@ -4281,17 +4669,17 @@ class TestObjectController(unittest.TestCase): new=mock_method): response = self.object_controller.__call__(env, start_response) self.assertEqual(response, answer) - self.assertEqual(self.object_controller.logger.log_dict['info'], - []) + self.assertEqual(self.logger.get_lines_for_level('info'), []) def test__call__returns_500(self): - inbuf = StringIO() + inbuf = WsgiStringIO() errbuf = StringIO() outbuf = StringIO() + self.logger = debug_logger('test') self.object_controller = object_server.ObjectController( {'devices': self.testdir, 'mount_check': 'false', 'replication_server': 'false', 'log_requests': 'false'}, - logger=FakeLogger()) + logger=self.logger) def start_response(*args): # Sends args to outbuf @@ -4323,24 +4711,21 @@ class TestObjectController(unittest.TestCase): response = self.object_controller.__call__(env, start_response) self.assertTrue(response[0].startswith( 'Traceback (most recent call last):')) - self.assertEqual( - self.object_controller.logger.log_dict['exception'], - [(('ERROR __call__ error with %(method)s %(path)s ', - {'method': 'PUT', 'path': '/sda1/p/a/c/o'}), - {}, - '')]) - self.assertEqual(self.object_controller.logger.log_dict['INFO'], - []) + self.assertEqual(self.logger.get_lines_for_level('error'), [ + 'ERROR __call__ error with %(method)s %(path)s : ' % { + 'method': 'PUT', 'path': '/sda1/p/a/c/o'}, + ]) + self.assertEqual(self.logger.get_lines_for_level('info'), []) def test_PUT_slow(self): - inbuf = StringIO() + inbuf = WsgiStringIO() errbuf = StringIO() outbuf = StringIO() self.object_controller = object_server.ObjectController( {'devices': self.testdir, 'mount_check': 'false', 'replication_server': 'false', 'log_requests': 'false', 'slow': '10'}, - logger=FakeLogger()) + logger=self.logger) def start_response(*args): # Sends args to outbuf @@ -4373,14 +4758,14 @@ class TestObjectController(unittest.TestCase): mock.MagicMock()) as ms: self.object_controller.__call__(env, start_response) ms.assert_called_with(9) - self.assertEqual( - self.object_controller.logger.log_dict['info'], []) + self.assertEqual(self.logger.get_lines_for_level('info'), + []) def test_log_line_format(self): req = Request.blank( '/sda1/p/a/c/o', environ={'REQUEST_METHOD': 'HEAD', 'REMOTE_ADDR': '1.2.3.4'}) - self.object_controller.logger = FakeLogger() + self.object_controller.logger = self.logger with mock.patch( 'time.gmtime', mock.MagicMock(side_effect=[gmtime(10001.0)])): with mock.patch( @@ -4390,13 +4775,16 @@ class TestObjectController(unittest.TestCase): 'os.getpid', mock.MagicMock(return_value=1234)): req.get_response(self.object_controller) self.assertEqual( - self.object_controller.logger.log_dict['info'], - [(('1.2.3.4 - - [01/Jan/1970:02:46:41 +0000] "HEAD /sda1/p/a/c/o" ' - '404 - "-" "-" "-" 2.0000 "-" 1234 -',), {})]) + self.logger.get_lines_for_level('info'), + ['1.2.3.4 - - [01/Jan/1970:02:46:41 +0000] "HEAD /sda1/p/a/c/o" ' + '404 - "-" "-" "-" 2.0000 "-" 1234 -']) - @patch_policies([storage_policy.StoragePolicy(0, 'zero', True), - storage_policy.StoragePolicy(1, 'one', False)]) + @patch_policies([StoragePolicy(0, 'zero', True), + StoragePolicy(1, 'one', False)]) def test_dynamic_datadir(self): + # update router post patch + self.object_controller._diskfile_router = diskfile.DiskFileRouter( + self.conf, self.object_controller.logger) timestamp = normalize_timestamp(time()) req = Request.blank('/sda1/p/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, headers={'X-Timestamp': timestamp, @@ -4430,7 +4818,50 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.status_int, 201) self.assertTrue(os.path.isdir(object_dir)) + def test_storage_policy_index_is_validated(self): + # sanity check that index for existing policy is ok + ts = (utils.Timestamp(t).internal for t in + itertools.count(int(time()))) + methods = ('PUT', 'POST', 'GET', 'HEAD', 'REPLICATE', 'DELETE') + valid_indices = sorted([int(policy) for policy in POLICIES]) + for index in valid_indices: + object_dir = self.testdir + "/sda1/objects" + if index > 0: + object_dir = "%s-%s" % (object_dir, index) + self.assertFalse(os.path.isdir(object_dir)) + for method in methods: + headers = { + 'X-Timestamp': ts.next(), + 'Content-Type': 'application/x-test', + 'X-Backend-Storage-Policy-Index': index} + if POLICIES[index].policy_type == EC_POLICY: + headers['X-Object-Sysmeta-Ec-Frag-Index'] = '2' + req = Request.blank( + '/sda1/p/a/c/o', + environ={'REQUEST_METHOD': method}, + headers=headers) + req.body = 'VERIFY' + resp = req.get_response(self.object_controller) + self.assertTrue(is_success(resp.status_int), + '%s method failed: %r' % (method, resp.status)) + # index for non-existent policy should return 503 + index = valid_indices[-1] + 1 + for method in methods: + req = Request.blank('/sda1/p/a/c/o', + environ={'REQUEST_METHOD': method}, + headers={ + 'X-Timestamp': ts.next(), + 'Content-Type': 'application/x-test', + 'X-Backend-Storage-Policy-Index': index}) + req.body = 'VERIFY' + object_dir = self.testdir + "/sda1/objects-%s" % index + resp = req.get_response(self.object_controller) + self.assertEquals(resp.status_int, 503) + self.assertFalse(os.path.isdir(object_dir)) + + +@patch_policies(test_policies) class TestObjectServer(unittest.TestCase): def setUp(self): @@ -4442,13 +4873,13 @@ class TestObjectServer(unittest.TestCase): for device in ('sda1', 'sdb1'): os.makedirs(os.path.join(self.devices, device)) - conf = { + self.conf = { 'devices': self.devices, 'swift_dir': self.tempdir, 'mount_check': 'false', } self.logger = debug_logger('test-object-server') - app = object_server.ObjectController(conf, logger=self.logger) + app = object_server.ObjectController(self.conf, logger=self.logger) sock = listen(('127.0.0.1', 0)) self.server = spawn(wsgi.server, sock, app, utils.NullLogger()) self.port = sock.getsockname()[1] @@ -4481,6 +4912,23 @@ class TestObjectServer(unittest.TestCase): resp.read() resp.close() + def test_expect_on_put_footer(self): + test_body = 'test' + headers = { + 'Expect': '100-continue', + 'Content-Length': len(test_body), + 'X-Timestamp': utils.Timestamp(time()).internal, + 'X-Backend-Obj-Metadata-Footer': 'yes', + 'X-Backend-Obj-Multipart-Mime-Boundary': 'boundary123', + } + conn = bufferedhttp.http_connect('127.0.0.1', self.port, 'sda1', '0', + 'PUT', '/a/c/o', headers=headers) + resp = conn.getexpect() + self.assertEqual(resp.status, 100) + headers = HeaderKeyDict(resp.getheaders()) + self.assertEqual(headers['X-Obj-Metadata-Footer'], 'yes') + resp.close() + def test_expect_on_put_conflict(self): test_body = 'test' put_timestamp = utils.Timestamp(time()) @@ -4509,7 +4957,379 @@ class TestObjectServer(unittest.TestCase): resp.read() resp.close() + def test_multiphase_put_no_mime_boundary(self): + test_data = 'obj data' + put_timestamp = utils.Timestamp(time()).internal + headers = { + 'Content-Type': 'text/plain', + 'X-Timestamp': put_timestamp, + 'Transfer-Encoding': 'chunked', + 'Expect': '100-continue', + 'X-Backend-Obj-Content-Length': len(test_data), + 'X-Backend-Obj-Multiphase-Commit': 'yes', + } + conn = bufferedhttp.http_connect('127.0.0.1', self.port, 'sda1', '0', + 'PUT', '/a/c/o', headers=headers) + resp = conn.getexpect() + self.assertEqual(resp.status, 400) + resp.read() + resp.close() + def test_expect_on_multiphase_put(self): + test_data = 'obj data' + test_doc = "\r\n".join(( + "--boundary123", + "X-Document: object body", + "", + test_data, + "--boundary123", + )) + + put_timestamp = utils.Timestamp(time()).internal + headers = { + 'Content-Type': 'text/plain', + 'X-Timestamp': put_timestamp, + 'Transfer-Encoding': 'chunked', + 'Expect': '100-continue', + 'X-Backend-Obj-Content-Length': len(test_data), + 'X-Backend-Obj-Multipart-Mime-Boundary': 'boundary123', + 'X-Backend-Obj-Multiphase-Commit': 'yes', + } + conn = bufferedhttp.http_connect('127.0.0.1', self.port, 'sda1', '0', + 'PUT', '/a/c/o', headers=headers) + resp = conn.getexpect() + self.assertEqual(resp.status, 100) + headers = HeaderKeyDict(resp.getheaders()) + self.assertEqual(headers['X-Obj-Multiphase-Commit'], 'yes') + + to_send = "%x\r\n%s\r\n0\r\n\r\n" % (len(test_doc), test_doc) + conn.send(to_send) + + # verify 100-continue response to mark end of phase1 + resp = conn.getexpect() + self.assertEqual(resp.status, 100) + resp.close() + + def test_multiphase_put_metadata_footer(self): + # Test 2-phase commit conversation - end of 1st phase marked + # by 100-continue response from the object server, with a + # successful 2nd phase marked by the presence of a .durable + # file along with .data file in the object data directory + test_data = 'obj data' + footer_meta = { + "X-Object-Sysmeta-Ec-Frag-Index": "2", + "Etag": md5(test_data).hexdigest(), + } + footer_json = json.dumps(footer_meta) + footer_meta_cksum = md5(footer_json).hexdigest() + test_doc = "\r\n".join(( + "--boundary123", + "X-Document: object body", + "", + test_data, + "--boundary123", + "X-Document: object metadata", + "Content-MD5: " + footer_meta_cksum, + "", + footer_json, + "--boundary123", + )) + + # phase1 - PUT request with object metadata in footer and + # multiphase commit conversation + put_timestamp = utils.Timestamp(time()).internal + headers = { + 'Content-Type': 'text/plain', + 'X-Timestamp': put_timestamp, + 'Transfer-Encoding': 'chunked', + 'Expect': '100-continue', + 'X-Backend-Storage-Policy-Index': '1', + 'X-Backend-Obj-Content-Length': len(test_data), + 'X-Backend-Obj-Metadata-Footer': 'yes', + 'X-Backend-Obj-Multipart-Mime-Boundary': 'boundary123', + 'X-Backend-Obj-Multiphase-Commit': 'yes', + } + conn = bufferedhttp.http_connect('127.0.0.1', self.port, 'sda1', '0', + 'PUT', '/a/c/o', headers=headers) + resp = conn.getexpect() + self.assertEqual(resp.status, 100) + headers = HeaderKeyDict(resp.getheaders()) + self.assertEqual(headers['X-Obj-Multiphase-Commit'], 'yes') + self.assertEqual(headers['X-Obj-Metadata-Footer'], 'yes') + + to_send = "%x\r\n%s\r\n0\r\n\r\n" % (len(test_doc), test_doc) + conn.send(to_send) + # verify 100-continue response to mark end of phase1 + resp = conn.getexpect() + self.assertEqual(resp.status, 100) + + # send commit confirmation to start phase2 + commit_confirmation_doc = "\r\n".join(( + "X-Document: put commit", + "", + "commit_confirmation", + "--boundary123--", + )) + to_send = "%x\r\n%s\r\n0\r\n\r\n" % \ + (len(commit_confirmation_doc), commit_confirmation_doc) + conn.send(to_send) + + # verify success (2xx) to make end of phase2 + resp = conn.getresponse() + self.assertEqual(resp.status, 201) + resp.read() + resp.close() + + # verify successful object data and durable state file write + obj_basename = os.path.join( + self.devices, 'sda1', + storage_directory(diskfile.get_data_dir(POLICIES[1]), '0', + hash_path('a', 'c', 'o')), + put_timestamp) + obj_datafile = obj_basename + '#2.data' + self.assertTrue(os.path.isfile(obj_datafile)) + obj_durablefile = obj_basename + '.durable' + self.assertTrue(os.path.isfile(obj_durablefile)) + + def test_multiphase_put_no_metadata_footer(self): + # Test 2-phase commit conversation, with no metadata footer + # at the end of object data - end of 1st phase marked + # by 100-continue response from the object server, with a + # successful 2nd phase marked by the presence of a .durable + # file along with .data file in the object data directory + # (No metadata footer case) + test_data = 'obj data' + test_doc = "\r\n".join(( + "--boundary123", + "X-Document: object body", + "", + test_data, + "--boundary123", + )) + + # phase1 - PUT request with multiphase commit conversation + # no object metadata in footer + put_timestamp = utils.Timestamp(time()).internal + headers = { + 'Content-Type': 'text/plain', + 'X-Timestamp': put_timestamp, + 'Transfer-Encoding': 'chunked', + 'Expect': '100-continue', + # normally the frag index gets sent in the MIME footer (which this + # test doesn't have, see `test_multiphase_put_metadata_footer`), + # but the proxy *could* send the frag index in the headers and + # this test verifies that would work. + 'X-Object-Sysmeta-Ec-Frag-Index': '2', + 'X-Backend-Storage-Policy-Index': '1', + 'X-Backend-Obj-Content-Length': len(test_data), + 'X-Backend-Obj-Multipart-Mime-Boundary': 'boundary123', + 'X-Backend-Obj-Multiphase-Commit': 'yes', + } + conn = bufferedhttp.http_connect('127.0.0.1', self.port, 'sda1', '0', + 'PUT', '/a/c/o', headers=headers) + resp = conn.getexpect() + self.assertEqual(resp.status, 100) + headers = HeaderKeyDict(resp.getheaders()) + self.assertEqual(headers['X-Obj-Multiphase-Commit'], 'yes') + + to_send = "%x\r\n%s\r\n0\r\n\r\n" % (len(test_doc), test_doc) + conn.send(to_send) + # verify 100-continue response to mark end of phase1 + resp = conn.getexpect() + self.assertEqual(resp.status, 100) + + # send commit confirmation to start phase2 + commit_confirmation_doc = "\r\n".join(( + "X-Document: put commit", + "", + "commit_confirmation", + "--boundary123--", + )) + to_send = "%x\r\n%s\r\n0\r\n\r\n" % \ + (len(commit_confirmation_doc), commit_confirmation_doc) + conn.send(to_send) + + # verify success (2xx) to make end of phase2 + resp = conn.getresponse() + self.assertEqual(resp.status, 201) + resp.read() + resp.close() + + # verify successful object data and durable state file write + obj_basename = os.path.join( + self.devices, 'sda1', + storage_directory(diskfile.get_data_dir(POLICIES[1]), '0', + hash_path('a', 'c', 'o')), + put_timestamp) + obj_datafile = obj_basename + '#2.data' + self.assertTrue(os.path.isfile(obj_datafile)) + obj_durablefile = obj_basename + '.durable' + self.assertTrue(os.path.isfile(obj_durablefile)) + + def test_multiphase_put_draining(self): + # We want to ensure that we read the whole response body even if + # it's multipart MIME and there's document parts that we don't + # expect or understand. This'll help save our bacon if we ever jam + # more stuff in there. + in_a_timeout = [False] + + # inherit from BaseException so we get a stack trace when the test + # fails instead of just a 500 + class NotInATimeout(BaseException): + pass + + class FakeTimeout(BaseException): + def __enter__(self): + in_a_timeout[0] = True + + def __exit__(self, typ, value, tb): + in_a_timeout[0] = False + + class PickyWsgiStringIO(WsgiStringIO): + def read(self, *a, **kw): + if not in_a_timeout[0]: + raise NotInATimeout() + return WsgiStringIO.read(self, *a, **kw) + + def readline(self, *a, **kw): + if not in_a_timeout[0]: + raise NotInATimeout() + return WsgiStringIO.readline(self, *a, **kw) + + test_data = 'obj data' + footer_meta = { + "X-Object-Sysmeta-Ec-Frag-Index": "7", + "Etag": md5(test_data).hexdigest(), + } + footer_json = json.dumps(footer_meta) + footer_meta_cksum = md5(footer_json).hexdigest() + test_doc = "\r\n".join(( + "--boundary123", + "X-Document: object body", + "", + test_data, + "--boundary123", + "X-Document: object metadata", + "Content-MD5: " + footer_meta_cksum, + "", + footer_json, + "--boundary123", + "X-Document: we got cleverer", + "", + "stuff stuff meaningless stuuuuuuuuuuff", + "--boundary123", + "X-Document: we got even cleverer; can you believe it?", + "Waneshaft: ambifacient lunar", + "Casing: malleable logarithmic", + "", + "potato potato potato potato potato potato potato", + "--boundary123--" + )) + + # phase1 - PUT request with object metadata in footer and + # multiphase commit conversation + put_timestamp = utils.Timestamp(time()).internal + headers = { + 'Content-Type': 'text/plain', + 'X-Timestamp': put_timestamp, + 'Transfer-Encoding': 'chunked', + 'Expect': '100-continue', + 'X-Backend-Storage-Policy-Index': '1', + 'X-Backend-Obj-Content-Length': len(test_data), + 'X-Backend-Obj-Metadata-Footer': 'yes', + 'X-Backend-Obj-Multipart-Mime-Boundary': 'boundary123', + } + wsgi_input = PickyWsgiStringIO(test_doc) + req = Request.blank( + "/sda1/0/a/c/o", + environ={'REQUEST_METHOD': 'PUT', 'wsgi.input': wsgi_input}, + headers=headers) + + app = object_server.ObjectController(self.conf, logger=self.logger) + with mock.patch('swift.obj.server.ChunkReadTimeout', FakeTimeout): + resp = req.get_response(app) + self.assertEqual(resp.status_int, 201) # sanity check + + in_a_timeout[0] = True # so we can check without an exception + self.assertEqual(wsgi_input.read(), '') # we read all the bytes + + def test_multiphase_put_bad_commit_message(self): + # Test 2-phase commit conversation - end of 1st phase marked + # by 100-continue response from the object server, with 2nd + # phase commit confirmation being received corrupt + test_data = 'obj data' + footer_meta = { + "X-Object-Sysmeta-Ec-Frag-Index": "7", + "Etag": md5(test_data).hexdigest(), + } + footer_json = json.dumps(footer_meta) + footer_meta_cksum = md5(footer_json).hexdigest() + test_doc = "\r\n".join(( + "--boundary123", + "X-Document: object body", + "", + test_data, + "--boundary123", + "X-Document: object metadata", + "Content-MD5: " + footer_meta_cksum, + "", + footer_json, + "--boundary123", + )) + + # phase1 - PUT request with object metadata in footer and + # multiphase commit conversation + put_timestamp = utils.Timestamp(time()).internal + headers = { + 'Content-Type': 'text/plain', + 'X-Timestamp': put_timestamp, + 'Transfer-Encoding': 'chunked', + 'Expect': '100-continue', + 'X-Backend-Storage-Policy-Index': '1', + 'X-Backend-Obj-Content-Length': len(test_data), + 'X-Backend-Obj-Metadata-Footer': 'yes', + 'X-Backend-Obj-Multipart-Mime-Boundary': 'boundary123', + 'X-Backend-Obj-Multiphase-Commit': 'yes', + } + conn = bufferedhttp.http_connect('127.0.0.1', self.port, 'sda1', '0', + 'PUT', '/a/c/o', headers=headers) + resp = conn.getexpect() + self.assertEqual(resp.status, 100) + headers = HeaderKeyDict(resp.getheaders()) + self.assertEqual(headers['X-Obj-Multiphase-Commit'], 'yes') + self.assertEqual(headers['X-Obj-Metadata-Footer'], 'yes') + + to_send = "%x\r\n%s\r\n0\r\n\r\n" % (len(test_doc), test_doc) + conn.send(to_send) + # verify 100-continue response to mark end of phase1 + resp = conn.getexpect() + self.assertEqual(resp.status, 100) + + # send commit confirmation to start phase2 + commit_confirmation_doc = "\r\n".join(( + "junkjunk", + "--boundary123--", + )) + to_send = "%x\r\n%s\r\n0\r\n\r\n" % \ + (len(commit_confirmation_doc), commit_confirmation_doc) + conn.send(to_send) + resp = conn.getresponse() + self.assertEqual(resp.status, 500) + resp.read() + resp.close() + # verify that durable file was NOT created + obj_basename = os.path.join( + self.devices, 'sda1', + storage_directory(diskfile.get_data_dir(1), '0', + hash_path('a', 'c', 'o')), + put_timestamp) + obj_datafile = obj_basename + '#7.data' + self.assertTrue(os.path.isfile(obj_datafile)) + obj_durablefile = obj_basename + '.durable' + self.assertFalse(os.path.isfile(obj_durablefile)) + + +@patch_policies class TestZeroCopy(unittest.TestCase): """Test the object server's zero-copy functionality""" diff --git a/test/unit/obj/test_ssync_receiver.py b/test/unit/obj/test_ssync_receiver.py index 9af76185b1..4a030c821d 100644 --- a/test/unit/obj/test_ssync_receiver.py +++ b/test/unit/obj/test_ssync_receiver.py @@ -27,6 +27,7 @@ from swift.common import constraints from swift.common import exceptions from swift.common import swob from swift.common import utils +from swift.common.storage_policy import POLICIES from swift.obj import diskfile from swift.obj import server from swift.obj import ssync_receiver @@ -34,6 +35,7 @@ from swift.obj import ssync_receiver from test import unit +@unit.patch_policies() class TestReceiver(unittest.TestCase): def setUp(self): @@ -46,12 +48,12 @@ class TestReceiver(unittest.TestCase): self.testdir = os.path.join( tempfile.mkdtemp(), 'tmp_test_ssync_receiver') utils.mkdirs(os.path.join(self.testdir, 'sda1', 'tmp')) - conf = { + self.conf = { 'devices': self.testdir, 'mount_check': 'false', 'replication_one_per_device': 'false', 'log_requests': 'false'} - self.controller = server.ObjectController(conf) + self.controller = server.ObjectController(self.conf) self.controller.bytes_per_sync = 1 self.account1 = 'a' @@ -91,14 +93,14 @@ class TestReceiver(unittest.TestCase): lines.append(line) return lines - def test_REPLICATION_semaphore_locked(self): + def test_SSYNC_semaphore_locked(self): with mock.patch.object( self.controller, 'replication_semaphore') as \ mocked_replication_semaphore: self.controller.logger = mock.MagicMock() mocked_replication_semaphore.acquire.return_value = False req = swob.Request.blank( - '/device/partition', environ={'REQUEST_METHOD': 'REPLICATION'}) + '/device/partition', environ={'REQUEST_METHOD': 'SSYNC'}) resp = req.get_response(self.controller) self.assertEqual( self.body_lines(resp.body), @@ -109,13 +111,13 @@ class TestReceiver(unittest.TestCase): self.assertFalse(self.controller.logger.error.called) self.assertFalse(self.controller.logger.exception.called) - def test_REPLICATION_calls_replication_lock(self): + def test_SSYNC_calls_replication_lock(self): with mock.patch.object( - self.controller._diskfile_mgr, 'replication_lock') as \ - mocked_replication_lock: + self.controller._diskfile_router[POLICIES.legacy], + 'replication_lock') as mocked_replication_lock: req = swob.Request.blank( '/sda1/1', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n' ':MISSING_CHECK: END\r\n' ':UPDATES: START\r\n:UPDATES: END\r\n') @@ -130,7 +132,7 @@ class TestReceiver(unittest.TestCase): def test_Receiver_with_default_storage_policy(self): req = swob.Request.blank( '/sda1/1', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n' ':MISSING_CHECK: END\r\n' ':UPDATES: START\r\n:UPDATES: END\r\n') @@ -140,13 +142,15 @@ class TestReceiver(unittest.TestCase): body_lines, [':MISSING_CHECK: START', ':MISSING_CHECK: END', ':UPDATES: START', ':UPDATES: END']) - self.assertEqual(rcvr.policy_idx, 0) + self.assertEqual(rcvr.policy, POLICIES[0]) - @unit.patch_policies() def test_Receiver_with_storage_policy_index_header(self): + # update router post policy patch + self.controller._diskfile_router = diskfile.DiskFileRouter( + self.conf, self.controller.logger) req = swob.Request.blank( '/sda1/1', - environ={'REQUEST_METHOD': 'REPLICATION', + environ={'REQUEST_METHOD': 'SSYNC', 'HTTP_X_BACKEND_STORAGE_POLICY_INDEX': '1'}, body=':MISSING_CHECK: START\r\n' ':MISSING_CHECK: END\r\n' @@ -157,19 +161,58 @@ class TestReceiver(unittest.TestCase): body_lines, [':MISSING_CHECK: START', ':MISSING_CHECK: END', ':UPDATES: START', ':UPDATES: END']) - self.assertEqual(rcvr.policy_idx, 1) + self.assertEqual(rcvr.policy, POLICIES[1]) + self.assertEqual(rcvr.frag_index, None) - def test_REPLICATION_replication_lock_fail(self): + def test_Receiver_with_bad_storage_policy_index_header(self): + valid_indices = sorted([int(policy) for policy in POLICIES]) + bad_index = valid_indices[-1] + 1 + req = swob.Request.blank( + '/sda1/1', + environ={'REQUEST_METHOD': 'SSYNC', + 'HTTP_X_BACKEND_SSYNC_FRAG_INDEX': '0', + 'HTTP_X_BACKEND_STORAGE_POLICY_INDEX': bad_index}, + body=':MISSING_CHECK: START\r\n' + ':MISSING_CHECK: END\r\n' + ':UPDATES: START\r\n:UPDATES: END\r\n') + self.controller.logger = mock.MagicMock() + receiver = ssync_receiver.Receiver(self.controller, req) + body_lines = [chunk.strip() for chunk in receiver() if chunk.strip()] + self.assertEqual(body_lines, [":ERROR: 503 'No policy with index 2'"]) + + @unit.patch_policies() + def test_Receiver_with_frag_index_header(self): + # update router post policy patch + self.controller._diskfile_router = diskfile.DiskFileRouter( + self.conf, self.controller.logger) + req = swob.Request.blank( + '/sda1/1', + environ={'REQUEST_METHOD': 'SSYNC', + 'HTTP_X_BACKEND_SSYNC_FRAG_INDEX': '7', + 'HTTP_X_BACKEND_STORAGE_POLICY_INDEX': '1'}, + body=':MISSING_CHECK: START\r\n' + ':MISSING_CHECK: END\r\n' + ':UPDATES: START\r\n:UPDATES: END\r\n') + rcvr = ssync_receiver.Receiver(self.controller, req) + body_lines = [chunk.strip() for chunk in rcvr() if chunk.strip()] + self.assertEqual( + body_lines, + [':MISSING_CHECK: START', ':MISSING_CHECK: END', + ':UPDATES: START', ':UPDATES: END']) + self.assertEqual(rcvr.policy, POLICIES[1]) + self.assertEqual(rcvr.frag_index, 7) + + def test_SSYNC_replication_lock_fail(self): def _mock(path): with exceptions.ReplicationLockTimeout(0.01, '/somewhere/' + path): eventlet.sleep(0.05) with mock.patch.object( - self.controller._diskfile_mgr, 'replication_lock', _mock): - self.controller._diskfile_mgr + self.controller._diskfile_router[POLICIES.legacy], + 'replication_lock', _mock): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/sda1/1', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n' ':MISSING_CHECK: END\r\n' ':UPDATES: START\r\n:UPDATES: END\r\n') @@ -178,19 +221,19 @@ class TestReceiver(unittest.TestCase): self.body_lines(resp.body), [":ERROR: 0 '0.01 seconds: /somewhere/sda1'"]) self.controller.logger.debug.assert_called_once_with( - 'None/sda1/1 REPLICATION LOCK TIMEOUT: 0.01 seconds: ' + 'None/sda1/1 SSYNC LOCK TIMEOUT: 0.01 seconds: ' '/somewhere/sda1') - def test_REPLICATION_initial_path(self): + def test_SSYNC_initial_path(self): with mock.patch.object( self.controller, 'replication_semaphore') as \ mocked_replication_semaphore: req = swob.Request.blank( - '/device', environ={'REQUEST_METHOD': 'REPLICATION'}) + '/device', environ={'REQUEST_METHOD': 'SSYNC'}) resp = req.get_response(self.controller) self.assertEqual( self.body_lines(resp.body), - [":ERROR: 0 'Invalid path: /device'"]) + [":ERROR: 400 'Invalid path: /device'"]) self.assertEqual(resp.status_int, 200) self.assertFalse(mocked_replication_semaphore.acquire.called) self.assertFalse(mocked_replication_semaphore.release.called) @@ -199,11 +242,11 @@ class TestReceiver(unittest.TestCase): self.controller, 'replication_semaphore') as \ mocked_replication_semaphore: req = swob.Request.blank( - '/device/', environ={'REQUEST_METHOD': 'REPLICATION'}) + '/device/', environ={'REQUEST_METHOD': 'SSYNC'}) resp = req.get_response(self.controller) self.assertEqual( self.body_lines(resp.body), - [":ERROR: 0 'Invalid path: /device/'"]) + [":ERROR: 400 'Invalid path: /device/'"]) self.assertEqual(resp.status_int, 200) self.assertFalse(mocked_replication_semaphore.acquire.called) self.assertFalse(mocked_replication_semaphore.release.called) @@ -212,7 +255,7 @@ class TestReceiver(unittest.TestCase): self.controller, 'replication_semaphore') as \ mocked_replication_semaphore: req = swob.Request.blank( - '/device/partition', environ={'REQUEST_METHOD': 'REPLICATION'}) + '/device/partition', environ={'REQUEST_METHOD': 'SSYNC'}) resp = req.get_response(self.controller) self.assertEqual( self.body_lines(resp.body), @@ -226,28 +269,29 @@ class TestReceiver(unittest.TestCase): mocked_replication_semaphore: req = swob.Request.blank( '/device/partition/junk', - environ={'REQUEST_METHOD': 'REPLICATION'}) + environ={'REQUEST_METHOD': 'SSYNC'}) resp = req.get_response(self.controller) self.assertEqual( self.body_lines(resp.body), - [":ERROR: 0 'Invalid path: /device/partition/junk'"]) + [":ERROR: 400 'Invalid path: /device/partition/junk'"]) self.assertEqual(resp.status_int, 200) self.assertFalse(mocked_replication_semaphore.acquire.called) self.assertFalse(mocked_replication_semaphore.release.called) - def test_REPLICATION_mount_check(self): + def test_SSYNC_mount_check(self): with contextlib.nested( mock.patch.object( self.controller, 'replication_semaphore'), mock.patch.object( - self.controller._diskfile_mgr, 'mount_check', False), + self.controller._diskfile_router[POLICIES.legacy], + 'mount_check', False), mock.patch.object( constraints, 'check_mount', return_value=False)) as ( mocked_replication_semaphore, mocked_mount_check, mocked_check_mount): req = swob.Request.blank( - '/device/partition', environ={'REQUEST_METHOD': 'REPLICATION'}) + '/device/partition', environ={'REQUEST_METHOD': 'SSYNC'}) resp = req.get_response(self.controller) self.assertEqual( self.body_lines(resp.body), @@ -259,14 +303,15 @@ class TestReceiver(unittest.TestCase): mock.patch.object( self.controller, 'replication_semaphore'), mock.patch.object( - self.controller._diskfile_mgr, 'mount_check', True), + self.controller._diskfile_router[POLICIES.legacy], + 'mount_check', True), mock.patch.object( constraints, 'check_mount', return_value=False)) as ( mocked_replication_semaphore, mocked_mount_check, mocked_check_mount): req = swob.Request.blank( - '/device/partition', environ={'REQUEST_METHOD': 'REPLICATION'}) + '/device/partition', environ={'REQUEST_METHOD': 'SSYNC'}) resp = req.get_response(self.controller) self.assertEqual( self.body_lines(resp.body), @@ -275,21 +320,23 @@ class TestReceiver(unittest.TestCase): "device

'"]) self.assertEqual(resp.status_int, 200) mocked_check_mount.assert_called_once_with( - self.controller._diskfile_mgr.devices, 'device') + self.controller._diskfile_router[POLICIES.legacy].devices, + 'device') mocked_check_mount.reset_mock() mocked_check_mount.return_value = True req = swob.Request.blank( - '/device/partition', environ={'REQUEST_METHOD': 'REPLICATION'}) + '/device/partition', environ={'REQUEST_METHOD': 'SSYNC'}) resp = req.get_response(self.controller) self.assertEqual( self.body_lines(resp.body), [':ERROR: 0 "Looking for :MISSING_CHECK: START got \'\'"']) self.assertEqual(resp.status_int, 200) mocked_check_mount.assert_called_once_with( - self.controller._diskfile_mgr.devices, 'device') + self.controller._diskfile_router[POLICIES.legacy].devices, + 'device') - def test_REPLICATION_Exception(self): + def test_SSYNC_Exception(self): class _Wrapper(StringIO.StringIO): @@ -306,7 +353,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\nBad content is here') req.remote_addr = '1.2.3.4' @@ -324,7 +371,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger.exception.assert_called_once_with( '1.2.3.4/device/partition EXCEPTION in replication.Receiver') - def test_REPLICATION_Exception_Exception(self): + def test_SSYNC_Exception_Exception(self): class _Wrapper(StringIO.StringIO): @@ -341,7 +388,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\nBad content is here') req.remote_addr = mock.MagicMock() @@ -384,7 +431,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/sda1/1', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n' 'hash ts\r\n' ':MISSING_CHECK: END\r\n' @@ -426,7 +473,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/sda1/1', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n' 'hash ts\r\n' ':MISSING_CHECK: END\r\n' @@ -448,7 +495,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/sda1/1', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n' ':MISSING_CHECK: END\r\n' ':UPDATES: START\r\n:UPDATES: END\r\n') @@ -466,7 +513,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/sda1/1', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n' + self.hash1 + ' ' + self.ts1 + '\r\n' + self.hash2 + ' ' + self.ts2 + '\r\n' @@ -484,9 +531,36 @@ class TestReceiver(unittest.TestCase): self.assertFalse(self.controller.logger.error.called) self.assertFalse(self.controller.logger.exception.called) + def test_MISSING_CHECK_extra_line_parts(self): + # check that rx tolerates extra parts in missing check lines to + # allow for protocol upgrades + extra_1 = 'extra' + extra_2 = 'multiple extra parts' + self.controller.logger = mock.MagicMock() + req = swob.Request.blank( + '/sda1/1', + environ={'REQUEST_METHOD': 'SSYNC'}, + body=':MISSING_CHECK: START\r\n' + + self.hash1 + ' ' + self.ts1 + ' ' + extra_1 + '\r\n' + + self.hash2 + ' ' + self.ts2 + ' ' + extra_2 + '\r\n' + ':MISSING_CHECK: END\r\n' + ':UPDATES: START\r\n:UPDATES: END\r\n') + resp = req.get_response(self.controller) + self.assertEqual( + self.body_lines(resp.body), + [':MISSING_CHECK: START', + self.hash1, + self.hash2, + ':MISSING_CHECK: END', + ':UPDATES: START', ':UPDATES: END']) + self.assertEqual(resp.status_int, 200) + self.assertFalse(self.controller.logger.error.called) + self.assertFalse(self.controller.logger.exception.called) + def test_MISSING_CHECK_have_one_exact(self): object_dir = utils.storage_directory( - os.path.join(self.testdir, 'sda1', diskfile.get_data_dir(0)), + os.path.join(self.testdir, 'sda1', + diskfile.get_data_dir(POLICIES[0])), '1', self.hash1) utils.mkdirs(object_dir) fp = open(os.path.join(object_dir, self.ts1 + '.data'), 'w+') @@ -498,7 +572,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/sda1/1', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n' + self.hash1 + ' ' + self.ts1 + '\r\n' + self.hash2 + ' ' + self.ts2 + '\r\n' @@ -515,10 +589,13 @@ class TestReceiver(unittest.TestCase): self.assertFalse(self.controller.logger.error.called) self.assertFalse(self.controller.logger.exception.called) - @unit.patch_policies def test_MISSING_CHECK_storage_policy(self): + # update router post policy patch + self.controller._diskfile_router = diskfile.DiskFileRouter( + self.conf, self.controller.logger) object_dir = utils.storage_directory( - os.path.join(self.testdir, 'sda1', diskfile.get_data_dir(1)), + os.path.join(self.testdir, 'sda1', + diskfile.get_data_dir(POLICIES[1])), '1', self.hash1) utils.mkdirs(object_dir) fp = open(os.path.join(object_dir, self.ts1 + '.data'), 'w+') @@ -530,7 +607,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/sda1/1', - environ={'REQUEST_METHOD': 'REPLICATION', + environ={'REQUEST_METHOD': 'SSYNC', 'HTTP_X_BACKEND_STORAGE_POLICY_INDEX': '1'}, body=':MISSING_CHECK: START\r\n' + self.hash1 + ' ' + self.ts1 + '\r\n' + @@ -550,7 +627,8 @@ class TestReceiver(unittest.TestCase): def test_MISSING_CHECK_have_one_newer(self): object_dir = utils.storage_directory( - os.path.join(self.testdir, 'sda1', diskfile.get_data_dir(0)), + os.path.join(self.testdir, 'sda1', + diskfile.get_data_dir(POLICIES[0])), '1', self.hash1) utils.mkdirs(object_dir) newer_ts1 = utils.normalize_timestamp(float(self.ts1) + 1) @@ -564,7 +642,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/sda1/1', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n' + self.hash1 + ' ' + self.ts1 + '\r\n' + self.hash2 + ' ' + self.ts2 + '\r\n' @@ -583,7 +661,8 @@ class TestReceiver(unittest.TestCase): def test_MISSING_CHECK_have_one_older(self): object_dir = utils.storage_directory( - os.path.join(self.testdir, 'sda1', diskfile.get_data_dir(0)), + os.path.join(self.testdir, 'sda1', + diskfile.get_data_dir(POLICIES[0])), '1', self.hash1) utils.mkdirs(object_dir) older_ts1 = utils.normalize_timestamp(float(self.ts1) - 1) @@ -597,7 +676,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/sda1/1', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n' + self.hash1 + ' ' + self.ts1 + '\r\n' + self.hash2 + ' ' + self.ts2 + '\r\n' @@ -639,7 +718,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'DELETE /a/c/o\r\n' @@ -686,7 +765,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'DELETE /a/c/o\r\n' @@ -729,7 +808,7 @@ class TestReceiver(unittest.TestCase): mock_shutdown_safe, mock_delete): req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'DELETE /a/c/o\r\n' @@ -751,7 +830,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'bad_subrequest_line\r\n') @@ -770,7 +849,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'DELETE /a/c/o\r\n' @@ -790,7 +869,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'DELETE /a/c/o\r\n') @@ -807,7 +886,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'DELETE /a/c/o\r\n' @@ -824,7 +903,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'DELETE /a/c/o\r\n' @@ -843,7 +922,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'PUT /a/c/o\r\n' @@ -861,7 +940,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'DELETE /a/c/o\r\n' @@ -879,7 +958,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'PUT /a/c/o\r\n\r\n') @@ -896,7 +975,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'PUT /a/c/o\r\n' @@ -926,7 +1005,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'DELETE /a/c/o\r\n\r\n' @@ -949,7 +1028,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'DELETE /a/c/o\r\n\r\n' @@ -975,7 +1054,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'DELETE /a/c/o\r\n\r\n' @@ -1003,7 +1082,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'DELETE /a/c/o\r\n\r\n' @@ -1036,7 +1115,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'PUT /a/c/o\r\n' @@ -1072,8 +1151,10 @@ class TestReceiver(unittest.TestCase): 'content-encoding specialty-header')}) self.assertEqual(req.read_body, '1') - @unit.patch_policies() def test_UPDATES_with_storage_policy(self): + # update router post policy patch + self.controller._diskfile_router = diskfile.DiskFileRouter( + self.conf, self.controller.logger) _PUT_request = [None] @server.public @@ -1086,7 +1167,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION', + environ={'REQUEST_METHOD': 'SSYNC', 'HTTP_X_BACKEND_STORAGE_POLICY_INDEX': '1'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' @@ -1135,7 +1216,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'DELETE /a/c/o\r\n' @@ -1170,7 +1251,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'BONK /a/c/o\r\n' @@ -1206,7 +1287,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'PUT /a/c/o1\r\n' @@ -1317,7 +1398,7 @@ class TestReceiver(unittest.TestCase): self.assertEqual(_requests, []) def test_UPDATES_subreq_does_not_read_all(self): - # This tests that if a REPLICATION subrequest fails and doesn't read + # This tests that if a SSYNC subrequest fails and doesn't read # all the subrequest body that it will read and throw away the rest of # the body before moving on to the next subrequest. # If you comment out the part in ssync_receiver where it does: @@ -1346,7 +1427,7 @@ class TestReceiver(unittest.TestCase): self.controller.logger = mock.MagicMock() req = swob.Request.blank( '/device/partition', - environ={'REQUEST_METHOD': 'REPLICATION'}, + environ={'REQUEST_METHOD': 'SSYNC'}, body=':MISSING_CHECK: START\r\n:MISSING_CHECK: END\r\n' ':UPDATES: START\r\n' 'PUT /a/c/o1\r\n' diff --git a/test/unit/obj/test_ssync_sender.py b/test/unit/obj/test_ssync_sender.py index 87efd64cc7..42bd610eb6 100644 --- a/test/unit/obj/test_ssync_sender.py +++ b/test/unit/obj/test_ssync_sender.py @@ -22,18 +22,24 @@ import time import unittest import eventlet +import itertools import mock from swift.common import exceptions, utils -from swift.obj import ssync_sender, diskfile +from swift.common.storage_policy import POLICIES +from swift.common.exceptions import DiskFileNotExist, DiskFileError, \ + DiskFileDeleted +from swift.common.swob import Request +from swift.common.utils import Timestamp, FileLikeIter +from swift.obj import ssync_sender, diskfile, server, ssync_receiver +from swift.obj.reconstructor import RebuildingECDiskFileStream -from test.unit import DebugLogger, patch_policies +from test.unit import debug_logger, patch_policies class FakeReplicator(object): - - def __init__(self, testdir): - self.logger = mock.MagicMock() + def __init__(self, testdir, policy=None): + self.logger = debug_logger('test-ssync-sender') self.conn_timeout = 1 self.node_timeout = 2 self.http_timeout = 3 @@ -43,7 +49,9 @@ class FakeReplicator(object): 'devices': testdir, 'mount_check': 'false', } - self._diskfile_mgr = diskfile.DiskFileManager(conf, DebugLogger()) + policy = POLICIES.default if policy is None else policy + self._diskfile_router = diskfile.DiskFileRouter(conf, self.logger) + self._diskfile_mgr = self._diskfile_router[policy] class NullBufferedHTTPConnection(object): @@ -90,39 +98,49 @@ class FakeConnection(object): self.closed = True -class TestSender(unittest.TestCase): - +class BaseTestSender(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp() self.testdir = os.path.join(self.tmpdir, 'tmp_test_ssync_sender') - self.replicator = FakeReplicator(self.testdir) - self.sender = ssync_sender.Sender(self.replicator, None, None, None) + utils.mkdirs(os.path.join(self.testdir, 'dev')) + self.daemon = FakeReplicator(self.testdir) + self.sender = ssync_sender.Sender(self.daemon, None, None, None) def tearDown(self): - shutil.rmtree(self.tmpdir, ignore_errors=1) + shutil.rmtree(self.tmpdir, ignore_errors=True) def _make_open_diskfile(self, device='dev', partition='9', account='a', container='c', obj='o', body='test', - extra_metadata=None, policy_idx=0): + extra_metadata=None, policy=None, + frag_index=None, timestamp=None, df_mgr=None): + policy = policy or POLICIES.legacy object_parts = account, container, obj - req_timestamp = utils.normalize_timestamp(time.time()) - df = self.sender.daemon._diskfile_mgr.get_diskfile( - device, partition, *object_parts, policy_idx=policy_idx) + timestamp = Timestamp(time.time()) if timestamp is None else timestamp + if df_mgr is None: + df_mgr = self.daemon._diskfile_router[policy] + df = df_mgr.get_diskfile( + device, partition, *object_parts, policy=policy, + frag_index=frag_index) content_length = len(body) etag = hashlib.md5(body).hexdigest() with df.create() as writer: writer.write(body) metadata = { - 'X-Timestamp': req_timestamp, - 'Content-Length': content_length, + 'X-Timestamp': timestamp.internal, + 'Content-Length': str(content_length), 'ETag': etag, } if extra_metadata: metadata.update(extra_metadata) writer.put(metadata) + writer.commit(timestamp) df.open() return df + +@patch_policies() +class TestSender(BaseTestSender): + def test_call_catches_MessageTimeout(self): def connect(self): @@ -134,16 +152,16 @@ class TestSender(unittest.TestCase): with mock.patch.object(ssync_sender.Sender, 'connect', connect): node = dict(replication_ip='1.2.3.4', replication_port=5678, device='sda1') - job = dict(partition='9') - self.sender = ssync_sender.Sender(self.replicator, node, job, None) + job = dict(partition='9', policy=POLICIES.legacy) + self.sender = ssync_sender.Sender(self.daemon, node, job, None) self.sender.suffixes = ['abc'] success, candidates = self.sender() self.assertFalse(success) - self.assertEquals(candidates, set()) - call = self.replicator.logger.error.mock_calls[0] - self.assertEqual( - call[1][:-1], ('%s:%s/%s/%s %s', '1.2.3.4', 5678, 'sda1', '9')) - self.assertEqual(str(call[1][-1]), '1 second: test connect') + self.assertEquals(candidates, {}) + error_lines = self.daemon.logger.get_lines_for_level('error') + self.assertEqual(1, len(error_lines)) + self.assertEqual('1.2.3.4:5678/sda1/9 1 second: test connect', + error_lines[0]) def test_call_catches_ReplicationException(self): @@ -153,45 +171,44 @@ class TestSender(unittest.TestCase): with mock.patch.object(ssync_sender.Sender, 'connect', connect): node = dict(replication_ip='1.2.3.4', replication_port=5678, device='sda1') - job = dict(partition='9') - self.sender = ssync_sender.Sender(self.replicator, node, job, None) + job = dict(partition='9', policy=POLICIES.legacy) + self.sender = ssync_sender.Sender(self.daemon, node, job, None) self.sender.suffixes = ['abc'] success, candidates = self.sender() self.assertFalse(success) - self.assertEquals(candidates, set()) - call = self.replicator.logger.error.mock_calls[0] - self.assertEqual( - call[1][:-1], ('%s:%s/%s/%s %s', '1.2.3.4', 5678, 'sda1', '9')) - self.assertEqual(str(call[1][-1]), 'test connect') + self.assertEquals(candidates, {}) + error_lines = self.daemon.logger.get_lines_for_level('error') + self.assertEqual(1, len(error_lines)) + self.assertEqual('1.2.3.4:5678/sda1/9 test connect', + error_lines[0]) def test_call_catches_other_exceptions(self): node = dict(replication_ip='1.2.3.4', replication_port=5678, device='sda1') - job = dict(partition='9') - self.sender = ssync_sender.Sender(self.replicator, node, job, None) + job = dict(partition='9', policy=POLICIES.legacy) + self.sender = ssync_sender.Sender(self.daemon, node, job, None) self.sender.suffixes = ['abc'] self.sender.connect = 'cause exception' success, candidates = self.sender() self.assertFalse(success) - self.assertEquals(candidates, set()) - call = self.replicator.logger.exception.mock_calls[0] - self.assertEqual( - call[1], - ('%s:%s/%s/%s EXCEPTION in replication.Sender', '1.2.3.4', 5678, - 'sda1', '9')) + self.assertEquals(candidates, {}) + error_lines = self.daemon.logger.get_lines_for_level('error') + for line in error_lines: + self.assertTrue(line.startswith( + '1.2.3.4:5678/sda1/9 EXCEPTION in replication.Sender:')) def test_call_catches_exception_handling_exception(self): - node = dict(replication_ip='1.2.3.4', replication_port=5678, - device='sda1') - job = None # Will cause inside exception handler to fail - self.sender = ssync_sender.Sender(self.replicator, node, job, None) + job = node = None # Will cause inside exception handler to fail + self.sender = ssync_sender.Sender(self.daemon, node, job, None) self.sender.suffixes = ['abc'] self.sender.connect = 'cause exception' success, candidates = self.sender() self.assertFalse(success) - self.assertEquals(candidates, set()) - self.replicator.logger.exception.assert_called_once_with( - 'EXCEPTION in replication.Sender') + self.assertEquals(candidates, {}) + error_lines = self.daemon.logger.get_lines_for_level('error') + for line in error_lines: + self.assertTrue(line.startswith( + 'EXCEPTION in replication.Sender')) def test_call_calls_others(self): self.sender.suffixes = ['abc'] @@ -201,7 +218,7 @@ class TestSender(unittest.TestCase): self.sender.disconnect = mock.MagicMock() success, candidates = self.sender() self.assertTrue(success) - self.assertEquals(candidates, set()) + self.assertEquals(candidates, {}) self.sender.connect.assert_called_once_with() self.sender.missing_check.assert_called_once_with() self.sender.updates.assert_called_once_with() @@ -216,18 +233,17 @@ class TestSender(unittest.TestCase): self.sender.failures = 1 success, candidates = self.sender() self.assertFalse(success) - self.assertEquals(candidates, set()) + self.assertEquals(candidates, {}) self.sender.connect.assert_called_once_with() self.sender.missing_check.assert_called_once_with() self.sender.updates.assert_called_once_with() self.sender.disconnect.assert_called_once_with() - @patch_policies def test_connect(self): node = dict(replication_ip='1.2.3.4', replication_port=5678, - device='sda1') - job = dict(partition='9', policy_idx=1) - self.sender = ssync_sender.Sender(self.replicator, node, job, None) + device='sda1', index=0) + job = dict(partition='9', policy=POLICIES[1]) + self.sender = ssync_sender.Sender(self.daemon, node, job, None) self.sender.suffixes = ['abc'] with mock.patch( 'swift.obj.ssync_sender.bufferedhttp.BufferedHTTPConnection' @@ -240,11 +256,12 @@ class TestSender(unittest.TestCase): mock_conn_class.assert_called_once_with('1.2.3.4:5678') expectations = { 'putrequest': [ - mock.call('REPLICATION', '/sda1/9'), + mock.call('SSYNC', '/sda1/9'), ], 'putheader': [ mock.call('Transfer-Encoding', 'chunked'), mock.call('X-Backend-Storage-Policy-Index', 1), + mock.call('X-Backend-Ssync-Frag-Index', 0), ], 'endheaders': [mock.call()], } @@ -255,10 +272,80 @@ class TestSender(unittest.TestCase): method_name, mock_method.mock_calls, expected_calls)) + def test_call(self): + def patch_sender(sender): + sender.connect = mock.MagicMock() + sender.missing_check = mock.MagicMock() + sender.updates = mock.MagicMock() + sender.disconnect = mock.MagicMock() + + node = dict(replication_ip='1.2.3.4', replication_port=5678, + device='sda1') + job = { + 'device': 'dev', + 'partition': '9', + 'policy': POLICIES.legacy, + 'frag_index': 0, + } + available_map = dict([('9d41d8cd98f00b204e9800998ecf0abc', + '1380144470.00000'), + ('9d41d8cd98f00b204e9800998ecf0def', + '1380144472.22222'), + ('9d41d8cd98f00b204e9800998ecf1def', + '1380144474.44444')]) + + # no suffixes -> no work done + sender = ssync_sender.Sender( + self.daemon, node, job, [], remote_check_objs=None) + patch_sender(sender) + sender.available_map = available_map + success, candidates = sender() + self.assertTrue(success) + self.assertEqual({}, candidates) + + # all objs in sync + sender = ssync_sender.Sender( + self.daemon, node, job, ['ignored'], remote_check_objs=None) + patch_sender(sender) + sender.available_map = available_map + success, candidates = sender() + self.assertTrue(success) + self.assertEqual(available_map, candidates) + + # one obj not in sync, sync'ing faked, all objs should be in return set + wanted = '9d41d8cd98f00b204e9800998ecf0def' + sender = ssync_sender.Sender( + self.daemon, node, job, ['ignored'], + remote_check_objs=None) + patch_sender(sender) + sender.send_list = [wanted] + sender.available_map = available_map + success, candidates = sender() + self.assertTrue(success) + self.assertEqual(available_map, candidates) + + # one obj not in sync, remote check only so that obj is not sync'd + # and should not be in the return set + wanted = '9d41d8cd98f00b204e9800998ecf0def' + remote_check_objs = set(available_map.keys()) + sender = ssync_sender.Sender( + self.daemon, node, job, ['ignored'], + remote_check_objs=remote_check_objs) + patch_sender(sender) + sender.send_list = [wanted] + sender.available_map = available_map + success, candidates = sender() + self.assertTrue(success) + expected_map = dict([('9d41d8cd98f00b204e9800998ecf0abc', + '1380144470.00000'), + ('9d41d8cd98f00b204e9800998ecf1def', + '1380144474.44444')]) + self.assertEqual(expected_map, candidates) + def test_call_and_missing_check(self): - def yield_hashes(device, partition, policy_index, suffixes=None): + def yield_hashes(device, partition, policy, suffixes=None, **kwargs): if device == 'dev' and partition == '9' and suffixes == ['abc'] \ - and policy_index == 0: + and policy == POLICIES.legacy: yield ( '/srv/node/dev/objects/9/abc/' '9d41d8cd98f00b204e9800998ecf0abc', @@ -269,7 +356,12 @@ class TestSender(unittest.TestCase): 'No match for %r %r %r' % (device, partition, suffixes)) self.sender.connection = FakeConnection() - self.sender.job = {'device': 'dev', 'partition': '9'} + self.sender.job = { + 'device': 'dev', + 'partition': '9', + 'policy': POLICIES.legacy, + 'frag_index': 0, + } self.sender.suffixes = ['abc'] self.sender.response = FakeResponse( chunk_body=( @@ -282,13 +374,14 @@ class TestSender(unittest.TestCase): self.sender.disconnect = mock.MagicMock() success, candidates = self.sender() self.assertTrue(success) - self.assertEqual(candidates, set(['9d41d8cd98f00b204e9800998ecf0abc'])) + self.assertEqual(candidates, dict([('9d41d8cd98f00b204e9800998ecf0abc', + '1380144470.00000')])) self.assertEqual(self.sender.failures, 0) def test_call_and_missing_check_with_obj_list(self): - def yield_hashes(device, partition, policy_index, suffixes=None): + def yield_hashes(device, partition, policy, suffixes=None, **kwargs): if device == 'dev' and partition == '9' and suffixes == ['abc'] \ - and policy_index == 0: + and policy == POLICIES.legacy: yield ( '/srv/node/dev/objects/9/abc/' '9d41d8cd98f00b204e9800998ecf0abc', @@ -297,8 +390,13 @@ class TestSender(unittest.TestCase): else: raise Exception( 'No match for %r %r %r' % (device, partition, suffixes)) - job = {'device': 'dev', 'partition': '9'} - self.sender = ssync_sender.Sender(self.replicator, None, job, ['abc'], + job = { + 'device': 'dev', + 'partition': '9', + 'policy': POLICIES.legacy, + 'frag_index': 0, + } + self.sender = ssync_sender.Sender(self.daemon, None, job, ['abc'], ['9d41d8cd98f00b204e9800998ecf0abc']) self.sender.connection = FakeConnection() self.sender.response = FakeResponse( @@ -311,13 +409,14 @@ class TestSender(unittest.TestCase): self.sender.disconnect = mock.MagicMock() success, candidates = self.sender() self.assertTrue(success) - self.assertEqual(candidates, set(['9d41d8cd98f00b204e9800998ecf0abc'])) + self.assertEqual(candidates, dict([('9d41d8cd98f00b204e9800998ecf0abc', + '1380144470.00000')])) self.assertEqual(self.sender.failures, 0) def test_call_and_missing_check_with_obj_list_but_required(self): - def yield_hashes(device, partition, policy_index, suffixes=None): + def yield_hashes(device, partition, policy, suffixes=None, **kwargs): if device == 'dev' and partition == '9' and suffixes == ['abc'] \ - and policy_index == 0: + and policy == POLICIES.legacy: yield ( '/srv/node/dev/objects/9/abc/' '9d41d8cd98f00b204e9800998ecf0abc', @@ -326,8 +425,13 @@ class TestSender(unittest.TestCase): else: raise Exception( 'No match for %r %r %r' % (device, partition, suffixes)) - job = {'device': 'dev', 'partition': '9'} - self.sender = ssync_sender.Sender(self.replicator, None, job, ['abc'], + job = { + 'device': 'dev', + 'partition': '9', + 'policy': POLICIES.legacy, + 'frag_index': 0, + } + self.sender = ssync_sender.Sender(self.daemon, None, job, ['abc'], ['9d41d8cd98f00b204e9800998ecf0abc']) self.sender.connection = FakeConnection() self.sender.response = FakeResponse( @@ -341,14 +445,14 @@ class TestSender(unittest.TestCase): self.sender.disconnect = mock.MagicMock() success, candidates = self.sender() self.assertTrue(success) - self.assertEqual(candidates, set()) + self.assertEqual(candidates, {}) def test_connect_send_timeout(self): - self.replicator.conn_timeout = 0.01 + self.daemon.conn_timeout = 0.01 node = dict(replication_ip='1.2.3.4', replication_port=5678, device='sda1') - job = dict(partition='9') - self.sender = ssync_sender.Sender(self.replicator, node, job, None) + job = dict(partition='9', policy=POLICIES.legacy) + self.sender = ssync_sender.Sender(self.daemon, node, job, None) self.sender.suffixes = ['abc'] def putrequest(*args, **kwargs): @@ -359,18 +463,18 @@ class TestSender(unittest.TestCase): 'putrequest', putrequest): success, candidates = self.sender() self.assertFalse(success) - self.assertEquals(candidates, set()) - call = self.replicator.logger.error.mock_calls[0] - self.assertEqual( - call[1][:-1], ('%s:%s/%s/%s %s', '1.2.3.4', 5678, 'sda1', '9')) - self.assertEqual(str(call[1][-1]), '0.01 seconds: connect send') + self.assertEquals(candidates, {}) + error_lines = self.daemon.logger.get_lines_for_level('error') + for line in error_lines: + self.assertTrue(line.startswith( + '1.2.3.4:5678/sda1/9 0.01 seconds: connect send')) def test_connect_receive_timeout(self): - self.replicator.node_timeout = 0.02 + self.daemon.node_timeout = 0.02 node = dict(replication_ip='1.2.3.4', replication_port=5678, - device='sda1') - job = dict(partition='9') - self.sender = ssync_sender.Sender(self.replicator, node, job, None) + device='sda1', index=0) + job = dict(partition='9', policy=POLICIES.legacy) + self.sender = ssync_sender.Sender(self.daemon, node, job, None) self.sender.suffixes = ['abc'] class FakeBufferedHTTPConnection(NullBufferedHTTPConnection): @@ -383,18 +487,18 @@ class TestSender(unittest.TestCase): FakeBufferedHTTPConnection): success, candidates = self.sender() self.assertFalse(success) - self.assertEquals(candidates, set()) - call = self.replicator.logger.error.mock_calls[0] - self.assertEqual( - call[1][:-1], ('%s:%s/%s/%s %s', '1.2.3.4', 5678, 'sda1', '9')) - self.assertEqual(str(call[1][-1]), '0.02 seconds: connect receive') + self.assertEquals(candidates, {}) + error_lines = self.daemon.logger.get_lines_for_level('error') + for line in error_lines: + self.assertTrue(line.startswith( + '1.2.3.4:5678/sda1/9 0.02 seconds: connect receive')) def test_connect_bad_status(self): - self.replicator.node_timeout = 0.02 + self.daemon.node_timeout = 0.02 node = dict(replication_ip='1.2.3.4', replication_port=5678, - device='sda1') - job = dict(partition='9') - self.sender = ssync_sender.Sender(self.replicator, node, job, None) + device='sda1', index=0) + job = dict(partition='9', policy=POLICIES.legacy) + self.sender = ssync_sender.Sender(self.daemon, node, job, None) self.sender.suffixes = ['abc'] class FakeBufferedHTTPConnection(NullBufferedHTTPConnection): @@ -408,11 +512,11 @@ class TestSender(unittest.TestCase): FakeBufferedHTTPConnection): success, candidates = self.sender() self.assertFalse(success) - self.assertEquals(candidates, set()) - call = self.replicator.logger.error.mock_calls[0] - self.assertEqual( - call[1][:-1], ('%s:%s/%s/%s %s', '1.2.3.4', 5678, 'sda1', '9')) - self.assertEqual(str(call[1][-1]), 'Expected status 200; got 503') + self.assertEquals(candidates, {}) + error_lines = self.daemon.logger.get_lines_for_level('error') + for line in error_lines: + self.assertTrue(line.startswith( + '1.2.3.4:5678/sda1/9 Expected status 200; got 503')) def test_readline_newline_in_buffer(self): self.sender.response_buffer = 'Has a newline already.\r\nOkay.' @@ -420,7 +524,7 @@ class TestSender(unittest.TestCase): self.assertEqual(self.sender.response_buffer, 'Okay.') def test_readline_buffer_exceeds_network_chunk_size_somehow(self): - self.replicator.network_chunk_size = 2 + self.daemon.network_chunk_size = 2 self.sender.response_buffer = '1234567890' self.assertEqual(self.sender.readline(), '1234567890') self.assertEqual(self.sender.response_buffer, '') @@ -473,16 +577,21 @@ class TestSender(unittest.TestCase): self.assertRaises(exceptions.MessageTimeout, self.sender.missing_check) def test_missing_check_has_empty_suffixes(self): - def yield_hashes(device, partition, policy_idx, suffixes=None): - if (device != 'dev' or partition != '9' or policy_idx != 0 or + def yield_hashes(device, partition, policy, suffixes=None, **kwargs): + if (device != 'dev' or partition != '9' or + policy != POLICIES.legacy or suffixes != ['abc', 'def']): yield # Just here to make this a generator raise Exception( 'No match for %r %r %r %r' % (device, partition, - policy_idx, suffixes)) + policy, suffixes)) self.sender.connection = FakeConnection() - self.sender.job = {'device': 'dev', 'partition': '9'} + self.sender.job = { + 'device': 'dev', + 'partition': '9', + 'policy': POLICIES.legacy, + } self.sender.suffixes = ['abc', 'def'] self.sender.response = FakeResponse( chunk_body=( @@ -495,11 +604,12 @@ class TestSender(unittest.TestCase): '17\r\n:MISSING_CHECK: START\r\n\r\n' '15\r\n:MISSING_CHECK: END\r\n\r\n') self.assertEqual(self.sender.send_list, []) - self.assertEqual(self.sender.available_set, set()) + self.assertEqual(self.sender.available_map, {}) def test_missing_check_has_suffixes(self): - def yield_hashes(device, partition, policy_idx, suffixes=None): - if (device == 'dev' and partition == '9' and policy_idx == 0 and + def yield_hashes(device, partition, policy, suffixes=None, **kwargs): + if (device == 'dev' and partition == '9' and + policy == POLICIES.legacy and suffixes == ['abc', 'def']): yield ( '/srv/node/dev/objects/9/abc/' @@ -519,10 +629,14 @@ class TestSender(unittest.TestCase): else: raise Exception( 'No match for %r %r %r %r' % (device, partition, - policy_idx, suffixes)) + policy, suffixes)) self.sender.connection = FakeConnection() - self.sender.job = {'device': 'dev', 'partition': '9'} + self.sender.job = { + 'device': 'dev', + 'partition': '9', + 'policy': POLICIES.legacy, + } self.sender.suffixes = ['abc', 'def'] self.sender.response = FakeResponse( chunk_body=( @@ -538,14 +652,15 @@ class TestSender(unittest.TestCase): '33\r\n9d41d8cd98f00b204e9800998ecf1def 1380144474.44444\r\n\r\n' '15\r\n:MISSING_CHECK: END\r\n\r\n') self.assertEqual(self.sender.send_list, []) - candidates = ['9d41d8cd98f00b204e9800998ecf0abc', - '9d41d8cd98f00b204e9800998ecf0def', - '9d41d8cd98f00b204e9800998ecf1def'] - self.assertEqual(self.sender.available_set, set(candidates)) + candidates = [('9d41d8cd98f00b204e9800998ecf0abc', '1380144470.00000'), + ('9d41d8cd98f00b204e9800998ecf0def', '1380144472.22222'), + ('9d41d8cd98f00b204e9800998ecf1def', '1380144474.44444')] + self.assertEqual(self.sender.available_map, dict(candidates)) def test_missing_check_far_end_disconnect(self): - def yield_hashes(device, partition, policy_idx, suffixes=None): - if (device == 'dev' and partition == '9' and policy_idx == 0 and + def yield_hashes(device, partition, policy, suffixes=None, **kwargs): + if (device == 'dev' and partition == '9' and + policy == POLICIES.legacy and suffixes == ['abc']): yield ( '/srv/node/dev/objects/9/abc/' @@ -555,10 +670,14 @@ class TestSender(unittest.TestCase): else: raise Exception( 'No match for %r %r %r %r' % (device, partition, - policy_idx, suffixes)) + policy, suffixes)) self.sender.connection = FakeConnection() - self.sender.job = {'device': 'dev', 'partition': '9'} + self.sender.job = { + 'device': 'dev', + 'partition': '9', + 'policy': POLICIES.legacy, + } self.sender.suffixes = ['abc'] self.sender.daemon._diskfile_mgr.yield_hashes = yield_hashes self.sender.response = FakeResponse(chunk_body='\r\n') @@ -573,12 +692,14 @@ class TestSender(unittest.TestCase): '17\r\n:MISSING_CHECK: START\r\n\r\n' '33\r\n9d41d8cd98f00b204e9800998ecf0abc 1380144470.00000\r\n\r\n' '15\r\n:MISSING_CHECK: END\r\n\r\n') - self.assertEqual(self.sender.available_set, - set(['9d41d8cd98f00b204e9800998ecf0abc'])) + self.assertEqual(self.sender.available_map, + dict([('9d41d8cd98f00b204e9800998ecf0abc', + '1380144470.00000')])) def test_missing_check_far_end_disconnect2(self): - def yield_hashes(device, partition, policy_idx, suffixes=None): - if (device == 'dev' and partition == '9' and policy_idx == 0 and + def yield_hashes(device, partition, policy, suffixes=None, **kwargs): + if (device == 'dev' and partition == '9' and + policy == POLICIES.legacy and suffixes == ['abc']): yield ( '/srv/node/dev/objects/9/abc/' @@ -588,10 +709,14 @@ class TestSender(unittest.TestCase): else: raise Exception( 'No match for %r %r %r %r' % (device, partition, - policy_idx, suffixes)) + policy, suffixes)) self.sender.connection = FakeConnection() - self.sender.job = {'device': 'dev', 'partition': '9'} + self.sender.job = { + 'device': 'dev', + 'partition': '9', + 'policy': POLICIES.legacy, + } self.sender.suffixes = ['abc'] self.sender.daemon._diskfile_mgr.yield_hashes = yield_hashes self.sender.response = FakeResponse( @@ -607,12 +732,14 @@ class TestSender(unittest.TestCase): '17\r\n:MISSING_CHECK: START\r\n\r\n' '33\r\n9d41d8cd98f00b204e9800998ecf0abc 1380144470.00000\r\n\r\n' '15\r\n:MISSING_CHECK: END\r\n\r\n') - self.assertEqual(self.sender.available_set, - set(['9d41d8cd98f00b204e9800998ecf0abc'])) + self.assertEqual(self.sender.available_map, + dict([('9d41d8cd98f00b204e9800998ecf0abc', + '1380144470.00000')])) def test_missing_check_far_end_unexpected(self): - def yield_hashes(device, partition, policy_idx, suffixes=None): - if (device == 'dev' and partition == '9' and policy_idx == 0 and + def yield_hashes(device, partition, policy, suffixes=None, **kwargs): + if (device == 'dev' and partition == '9' and + policy == POLICIES.legacy and suffixes == ['abc']): yield ( '/srv/node/dev/objects/9/abc/' @@ -622,10 +749,14 @@ class TestSender(unittest.TestCase): else: raise Exception( 'No match for %r %r %r %r' % (device, partition, - policy_idx, suffixes)) + policy, suffixes)) self.sender.connection = FakeConnection() - self.sender.job = {'device': 'dev', 'partition': '9'} + self.sender.job = { + 'device': 'dev', + 'partition': '9', + 'policy': POLICIES.legacy, + } self.sender.suffixes = ['abc'] self.sender.daemon._diskfile_mgr.yield_hashes = yield_hashes self.sender.response = FakeResponse(chunk_body='OH HAI\r\n') @@ -640,12 +771,14 @@ class TestSender(unittest.TestCase): '17\r\n:MISSING_CHECK: START\r\n\r\n' '33\r\n9d41d8cd98f00b204e9800998ecf0abc 1380144470.00000\r\n\r\n' '15\r\n:MISSING_CHECK: END\r\n\r\n') - self.assertEqual(self.sender.available_set, - set(['9d41d8cd98f00b204e9800998ecf0abc'])) + self.assertEqual(self.sender.available_map, + dict([('9d41d8cd98f00b204e9800998ecf0abc', + '1380144470.00000')])) def test_missing_check_send_list(self): - def yield_hashes(device, partition, policy_idx, suffixes=None): - if (device == 'dev' and partition == '9' and policy_idx == 0 and + def yield_hashes(device, partition, policy, suffixes=None, **kwargs): + if (device == 'dev' and partition == '9' and + policy == POLICIES.legacy and suffixes == ['abc']): yield ( '/srv/node/dev/objects/9/abc/' @@ -655,10 +788,14 @@ class TestSender(unittest.TestCase): else: raise Exception( 'No match for %r %r %r %r' % (device, partition, - policy_idx, suffixes)) + policy, suffixes)) self.sender.connection = FakeConnection() - self.sender.job = {'device': 'dev', 'partition': '9'} + self.sender.job = { + 'device': 'dev', + 'partition': '9', + 'policy': POLICIES.legacy, + } self.sender.suffixes = ['abc'] self.sender.response = FakeResponse( chunk_body=( @@ -673,8 +810,45 @@ class TestSender(unittest.TestCase): '33\r\n9d41d8cd98f00b204e9800998ecf0abc 1380144470.00000\r\n\r\n' '15\r\n:MISSING_CHECK: END\r\n\r\n') self.assertEqual(self.sender.send_list, ['0123abc']) - self.assertEqual(self.sender.available_set, - set(['9d41d8cd98f00b204e9800998ecf0abc'])) + self.assertEqual(self.sender.available_map, + dict([('9d41d8cd98f00b204e9800998ecf0abc', + '1380144470.00000')])) + + def test_missing_check_extra_line_parts(self): + # check that sender tolerates extra parts in missing check + # line responses to allow for protocol upgrades + def yield_hashes(device, partition, policy, suffixes=None, **kwargs): + if (device == 'dev' and partition == '9' and + policy == POLICIES.legacy and + suffixes == ['abc']): + yield ( + '/srv/node/dev/objects/9/abc/' + '9d41d8cd98f00b204e9800998ecf0abc', + '9d41d8cd98f00b204e9800998ecf0abc', + '1380144470.00000') + else: + raise Exception( + 'No match for %r %r %r %r' % (device, partition, + policy, suffixes)) + + self.sender.connection = FakeConnection() + self.sender.job = { + 'device': 'dev', + 'partition': '9', + 'policy': POLICIES.legacy, + } + self.sender.suffixes = ['abc'] + self.sender.response = FakeResponse( + chunk_body=( + ':MISSING_CHECK: START\r\n' + '0123abc extra response parts\r\n' + ':MISSING_CHECK: END\r\n')) + self.sender.daemon._diskfile_mgr.yield_hashes = yield_hashes + self.sender.missing_check() + self.assertEqual(self.sender.send_list, ['0123abc']) + self.assertEqual(self.sender.available_map, + dict([('9d41d8cd98f00b204e9800998ecf0abc', + '1380144470.00000')])) def test_updates_timeout(self): self.sender.connection = FakeConnection() @@ -742,7 +916,12 @@ class TestSender(unittest.TestCase): delete_timestamp = utils.normalize_timestamp(time.time()) df.delete(delete_timestamp) self.sender.connection = FakeConnection() - self.sender.job = {'device': device, 'partition': part} + self.sender.job = { + 'device': device, + 'partition': part, + 'policy': POLICIES.legacy, + 'frag_index': 0, + } self.sender.node = {} self.sender.send_list = [object_hash] self.sender.send_delete = mock.MagicMock() @@ -771,7 +950,12 @@ class TestSender(unittest.TestCase): delete_timestamp = utils.normalize_timestamp(time.time()) df.delete(delete_timestamp) self.sender.connection = FakeConnection() - self.sender.job = {'device': device, 'partition': part} + self.sender.job = { + 'device': device, + 'partition': part, + 'policy': POLICIES.legacy, + 'frag_index': 0, + } self.sender.node = {} self.sender.send_list = [object_hash] self.sender.response = FakeResponse( @@ -797,7 +981,12 @@ class TestSender(unittest.TestCase): object_hash = utils.hash_path(*object_parts) expected = df.get_metadata() self.sender.connection = FakeConnection() - self.sender.job = {'device': device, 'partition': part} + self.sender.job = { + 'device': device, + 'partition': part, + 'policy': POLICIES.legacy, + 'frag_index': 0, + } self.sender.node = {} self.sender.send_list = [object_hash] self.sender.send_delete = mock.MagicMock() @@ -821,18 +1010,20 @@ class TestSender(unittest.TestCase): '11\r\n:UPDATES: START\r\n\r\n' 'f\r\n:UPDATES: END\r\n\r\n') - @patch_policies def test_updates_storage_policy_index(self): device = 'dev' part = '9' object_parts = ('a', 'c', 'o') df = self._make_open_diskfile(device, part, *object_parts, - policy_idx=1) + policy=POLICIES[0]) object_hash = utils.hash_path(*object_parts) expected = df.get_metadata() self.sender.connection = FakeConnection() - self.sender.job = {'device': device, 'partition': part, - 'policy_idx': 1} + self.sender.job = { + 'device': device, + 'partition': part, + 'policy': POLICIES[0], + 'frag_index': 0} self.sender.node = {} self.sender.send_list = [object_hash] self.sender.send_delete = mock.MagicMock() @@ -847,7 +1038,7 @@ class TestSender(unittest.TestCase): self.assertEqual(path, '/a/c/o') self.assert_(isinstance(df, diskfile.DiskFile)) self.assertEqual(expected, df.get_metadata()) - self.assertEqual(os.path.join(self.testdir, 'dev/objects-1/9/', + self.assertEqual(os.path.join(self.testdir, 'dev/objects/9/', object_hash[-3:], object_hash), df._datadir) @@ -1054,5 +1245,466 @@ class TestSender(unittest.TestCase): self.assertTrue(self.sender.connection.closed) +@patch_policies(with_ec_default=True) +class TestSsync(BaseTestSender): + """ + Test interactions between sender and receiver. The basis for each test is + actual diskfile state on either side - the connection between sender and + receiver is faked. Assertions are made about the final state of the sender + and receiver diskfiles. + """ + + def make_fake_ssync_connect(self, sender, rx_obj_controller, device, + partition, policy): + trace = [] + + def add_trace(type, msg): + # record a protocol event for later analysis + if msg.strip(): + trace.append((type, msg.strip())) + + def start_response(status, headers, exc_info=None): + assert(status == '200 OK') + + class FakeConnection: + def __init__(self, trace): + self.trace = trace + self.queue = [] + self.src = FileLikeIter(self.queue) + + def send(self, msg): + msg = msg.split('\r\n', 1)[1] + msg = msg.rsplit('\r\n', 1)[0] + add_trace('tx', msg) + self.queue.append(msg) + + def close(self): + pass + + def wrap_gen(gen): + # Strip response head and tail + while True: + try: + msg = gen.next() + if msg: + add_trace('rx', msg) + msg = '%x\r\n%s\r\n' % (len(msg), msg) + yield msg + except StopIteration: + break + + def fake_connect(): + sender.connection = FakeConnection(trace) + headers = {'Transfer-Encoding': 'chunked', + 'X-Backend-Storage-Policy-Index': str(int(policy))} + env = {'REQUEST_METHOD': 'SSYNC'} + path = '/%s/%s' % (device, partition) + req = Request.blank(path, environ=env, headers=headers) + req.environ['wsgi.input'] = sender.connection.src + resp = rx_obj_controller(req.environ, start_response) + wrapped_gen = wrap_gen(resp) + sender.response = FileLikeIter(wrapped_gen) + sender.response.fp = sender.response + return fake_connect + + def setUp(self): + self.device = 'dev' + self.partition = '9' + self.tmpdir = tempfile.mkdtemp() + # sender side setup + self.tx_testdir = os.path.join(self.tmpdir, 'tmp_test_ssync_sender') + utils.mkdirs(os.path.join(self.tx_testdir, self.device)) + self.daemon = FakeReplicator(self.tx_testdir) + + # rx side setup + self.rx_testdir = os.path.join(self.tmpdir, 'tmp_test_ssync_receiver') + utils.mkdirs(os.path.join(self.rx_testdir, self.device)) + conf = { + 'devices': self.rx_testdir, + 'mount_check': 'false', + 'replication_one_per_device': 'false', + 'log_requests': 'false'} + self.rx_controller = server.ObjectController(conf) + self.orig_ensure_flush = ssync_receiver.Receiver._ensure_flush + ssync_receiver.Receiver._ensure_flush = lambda *args: '' + self.ts_iter = (Timestamp(t) + for t in itertools.count(int(time.time()))) + + def tearDown(self): + if self.orig_ensure_flush: + ssync_receiver.Receiver._ensure_flush = self.orig_ensure_flush + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _create_ondisk_files(self, df_mgr, obj_name, policy, timestamp, + frag_indexes=None): + frag_indexes = [] if frag_indexes is None else frag_indexes + metadata = {'Content-Type': 'plain/text'} + diskfiles = [] + for frag_index in frag_indexes: + object_data = '/a/c/%s___%s' % (obj_name, frag_index) + if frag_index is not None: + metadata['X-Object-Sysmeta-Ec-Frag-Index'] = str(frag_index) + df = self._make_open_diskfile( + device=self.device, partition=self.partition, account='a', + container='c', obj=obj_name, body=object_data, + extra_metadata=metadata, timestamp=timestamp, policy=policy, + frag_index=frag_index, df_mgr=df_mgr) + # sanity checks + listing = os.listdir(df._datadir) + self.assertTrue(listing) + for filename in listing: + self.assertTrue(filename.startswith(timestamp.internal)) + diskfiles.append(df) + return diskfiles + + def _open_tx_diskfile(self, obj_name, policy, frag_index=None): + df_mgr = self.daemon._diskfile_router[policy] + df = df_mgr.get_diskfile( + self.device, self.partition, account='a', container='c', + obj=obj_name, policy=policy, frag_index=frag_index) + df.open() + return df + + def _open_rx_diskfile(self, obj_name, policy, frag_index=None): + df = self.rx_controller.get_diskfile( + self.device, self.partition, 'a', 'c', obj_name, policy=policy, + frag_index=frag_index) + df.open() + return df + + def _verify_diskfile_sync(self, tx_df, rx_df, frag_index): + # verify that diskfiles' metadata match + # sanity check, they are not the same ondisk files! + self.assertNotEqual(tx_df._datadir, rx_df._datadir) + rx_metadata = dict(rx_df.get_metadata()) + for k, v in tx_df.get_metadata().iteritems(): + self.assertEqual(v, rx_metadata.pop(k)) + # ugh, ssync duplicates ETag with Etag so have to clear it out here + if 'Etag' in rx_metadata: + rx_metadata.pop('Etag') + self.assertFalse(rx_metadata) + if frag_index: + rx_metadata = rx_df.get_metadata() + fi_key = 'X-Object-Sysmeta-Ec-Frag-Index' + self.assertTrue(fi_key in rx_metadata) + self.assertEqual(frag_index, int(rx_metadata[fi_key])) + + def _analyze_trace(self, trace): + """ + Parse protocol trace captured by fake connection, making some + assertions along the way, and return results as a dict of form: + results = {'tx_missing': , + 'rx_missing': , + 'tx_updates': , + 'rx_updates': } + + Each subreq is a dict with keys: 'method', 'path', 'headers', 'body' + """ + def tx_missing(results, line): + self.assertEqual('tx', line[0]) + results['tx_missing'].append(line[1]) + + def rx_missing(results, line): + self.assertEqual('rx', line[0]) + parts = line[1].split('\r\n') + for part in parts: + results['rx_missing'].append(part) + + def tx_updates(results, line): + self.assertEqual('tx', line[0]) + subrequests = results['tx_updates'] + if line[1].startswith(('PUT', 'DELETE')): + parts = line[1].split('\r\n') + method, path = parts[0].split() + subreq = {'method': method, 'path': path, 'req': line[1], + 'headers': parts[1:]} + subrequests.append(subreq) + else: + self.assertTrue(subrequests) + body = (subrequests[-1]).setdefault('body', '') + body += line[1] + subrequests[-1]['body'] = body + + def rx_updates(results, line): + self.assertEqual('rx', line[0]) + results.setdefault['rx_updates'].append(line[1]) + + def unexpected(results, line): + results.setdefault('unexpected', []).append(line) + + # each trace line is a tuple of ([tx|rx], msg) + handshakes = iter([(('tx', ':MISSING_CHECK: START'), tx_missing), + (('tx', ':MISSING_CHECK: END'), unexpected), + (('rx', ':MISSING_CHECK: START'), rx_missing), + (('rx', ':MISSING_CHECK: END'), unexpected), + (('tx', ':UPDATES: START'), tx_updates), + (('tx', ':UPDATES: END'), unexpected), + (('rx', ':UPDATES: START'), rx_updates), + (('rx', ':UPDATES: END'), unexpected)]) + expect_handshake = handshakes.next() + phases = ('tx_missing', 'rx_missing', 'tx_updates', 'rx_updates') + results = dict((k, []) for k in phases) + handler = unexpected + lines = list(trace) + lines.reverse() + while lines: + line = lines.pop() + if line == expect_handshake[0]: + handler = expect_handshake[1] + try: + expect_handshake = handshakes.next() + except StopIteration: + # should be the last line + self.assertFalse( + lines, 'Unexpected trailing lines %s' % lines) + continue + handler(results, line) + + try: + # check all handshakes occurred + missed = handshakes.next() + self.fail('Handshake %s not found' % str(missed[0])) + except StopIteration: + pass + # check no message outside of a phase + self.assertFalse(results.get('unexpected'), + 'Message outside of a phase: %s' % results.get(None)) + return results + + def _verify_ondisk_files(self, tx_objs, policy, rx_node_index): + # verify tx and rx files that should be in sync + for o_name, diskfiles in tx_objs.iteritems(): + for tx_df in diskfiles: + frag_index = tx_df._frag_index + if frag_index == rx_node_index: + # this frag_index should have been sync'd, + # check rx file is ok + rx_df = self._open_rx_diskfile(o_name, policy, frag_index) + self._verify_diskfile_sync(tx_df, rx_df, frag_index) + expected_body = '/a/c/%s___%s' % (o_name, rx_node_index) + actual_body = ''.join([chunk for chunk in rx_df.reader()]) + self.assertEqual(expected_body, actual_body) + else: + # this frag_index should not have been sync'd, + # check no rx file, + self.assertRaises(DiskFileNotExist, + self._open_rx_diskfile, + o_name, policy, frag_index=frag_index) + # check tx file still intact - ssync does not do any cleanup! + self._open_tx_diskfile(o_name, policy, frag_index) + + def _verify_tombstones(self, tx_objs, policy): + # verify tx and rx tombstones that should be in sync + for o_name, diskfiles in tx_objs.iteritems(): + for tx_df_ in diskfiles: + try: + self._open_tx_diskfile(o_name, policy) + self.fail('DiskFileDeleted expected') + except DiskFileDeleted as exc: + tx_delete_time = exc.timestamp + try: + self._open_rx_diskfile(o_name, policy) + self.fail('DiskFileDeleted expected') + except DiskFileDeleted as exc: + rx_delete_time = exc.timestamp + self.assertEqual(tx_delete_time, rx_delete_time) + + def test_handoff_fragment_revert(self): + # test that a sync_revert type job does send the correct frag archives + # to the receiver, and that those frag archives are then removed from + # local node. + policy = POLICIES.default + rx_node_index = 0 + tx_node_index = 1 + frag_index = rx_node_index + + # create sender side diskfiles... + tx_objs = {} + rx_objs = {} + tx_tombstones = {} + tx_df_mgr = self.daemon._diskfile_router[policy] + rx_df_mgr = self.rx_controller._diskfile_router[policy] + # o1 has primary and handoff fragment archives + t1 = self.ts_iter.next() + tx_objs['o1'] = self._create_ondisk_files( + tx_df_mgr, 'o1', policy, t1, (rx_node_index, tx_node_index)) + # o2 only has primary + t2 = self.ts_iter.next() + tx_objs['o2'] = self._create_ondisk_files( + tx_df_mgr, 'o2', policy, t2, (tx_node_index,)) + # o3 only has handoff + t3 = self.ts_iter.next() + tx_objs['o3'] = self._create_ondisk_files( + tx_df_mgr, 'o3', policy, t3, (rx_node_index,)) + # o4 primary and handoff fragment archives on tx, handoff in sync on rx + t4 = self.ts_iter.next() + tx_objs['o4'] = self._create_ondisk_files( + tx_df_mgr, 'o4', policy, t4, (tx_node_index, rx_node_index,)) + rx_objs['o4'] = self._create_ondisk_files( + rx_df_mgr, 'o4', policy, t4, (rx_node_index,)) + # o5 is a tombstone, missing on receiver + t5 = self.ts_iter.next() + tx_tombstones['o5'] = self._create_ondisk_files( + tx_df_mgr, 'o5', policy, t5, (tx_node_index,)) + tx_tombstones['o5'][0].delete(t5) + + suffixes = set() + for diskfiles in (tx_objs.values() + tx_tombstones.values()): + for df in diskfiles: + suffixes.add(os.path.basename(os.path.dirname(df._datadir))) + + # create ssync sender instance... + job = {'device': self.device, + 'partition': self.partition, + 'policy': policy, + 'frag_index': frag_index, + 'purge': True} + node = {'index': rx_node_index} + self.sender = ssync_sender.Sender(self.daemon, node, job, suffixes) + # fake connection from tx to rx... + self.sender.connect = self.make_fake_ssync_connect( + self.sender, self.rx_controller, self.device, self.partition, + policy) + + # run the sync protocol... + self.sender() + + # verify protocol + results = self._analyze_trace(self.sender.connection.trace) + # sender has handoff frags for o1, o3 and o4 and ts for o5 + self.assertEqual(4, len(results['tx_missing'])) + # receiver is missing frags for o1, o3 and ts for o5 + self.assertEqual(3, len(results['rx_missing'])) + self.assertEqual(3, len(results['tx_updates'])) + self.assertFalse(results['rx_updates']) + sync_paths = [] + for subreq in results.get('tx_updates'): + if subreq.get('method') == 'PUT': + self.assertTrue( + 'X-Object-Sysmeta-Ec-Frag-Index: %s' % rx_node_index + in subreq.get('headers')) + expected_body = '%s___%s' % (subreq['path'], rx_node_index) + self.assertEqual(expected_body, subreq['body']) + elif subreq.get('method') == 'DELETE': + self.assertEqual('/a/c/o5', subreq['path']) + sync_paths.append(subreq.get('path')) + self.assertEqual(['/a/c/o1', '/a/c/o3', '/a/c/o5'], sorted(sync_paths)) + + # verify on disk files... + self._verify_ondisk_files(tx_objs, policy, rx_node_index) + self._verify_tombstones(tx_tombstones, policy) + + def test_fragment_sync(self): + # check that a sync_only type job does call reconstructor to build a + # diskfile to send, and continues making progress despite an error + # when building one diskfile + policy = POLICIES.default + rx_node_index = 0 + tx_node_index = 1 + # for a sync job we iterate over frag index that belongs on local node + frag_index = tx_node_index + + # create sender side diskfiles... + tx_objs = {} + tx_tombstones = {} + rx_objs = {} + tx_df_mgr = self.daemon._diskfile_router[policy] + rx_df_mgr = self.rx_controller._diskfile_router[policy] + # o1 only has primary + t1 = self.ts_iter.next() + tx_objs['o1'] = self._create_ondisk_files( + tx_df_mgr, 'o1', policy, t1, (tx_node_index,)) + # o2 only has primary + t2 = self.ts_iter.next() + tx_objs['o2'] = self._create_ondisk_files( + tx_df_mgr, 'o2', policy, t2, (tx_node_index,)) + # o3 only has primary + t3 = self.ts_iter.next() + tx_objs['o3'] = self._create_ondisk_files( + tx_df_mgr, 'o3', policy, t3, (tx_node_index,)) + # o4 primary fragment archives on tx, handoff in sync on rx + t4 = self.ts_iter.next() + tx_objs['o4'] = self._create_ondisk_files( + tx_df_mgr, 'o4', policy, t4, (tx_node_index,)) + rx_objs['o4'] = self._create_ondisk_files( + rx_df_mgr, 'o4', policy, t4, (rx_node_index,)) + # o5 is a tombstone, missing on receiver + t5 = self.ts_iter.next() + tx_tombstones['o5'] = self._create_ondisk_files( + tx_df_mgr, 'o5', policy, t5, (tx_node_index,)) + tx_tombstones['o5'][0].delete(t5) + + suffixes = set() + for diskfiles in (tx_objs.values() + tx_tombstones.values()): + for df in diskfiles: + suffixes.add(os.path.basename(os.path.dirname(df._datadir))) + + reconstruct_fa_calls = [] + + def fake_reconstruct_fa(job, node, metadata): + reconstruct_fa_calls.append((job, node, policy, metadata)) + if len(reconstruct_fa_calls) == 2: + # simulate second reconstruct failing + raise DiskFileError + content = '%s___%s' % (metadata['name'], rx_node_index) + return RebuildingECDiskFileStream( + metadata, rx_node_index, iter([content])) + + # create ssync sender instance... + job = {'device': self.device, + 'partition': self.partition, + 'policy': policy, + 'frag_index': frag_index, + 'sync_diskfile_builder': fake_reconstruct_fa} + node = {'index': rx_node_index} + self.sender = ssync_sender.Sender(self.daemon, node, job, suffixes) + + # fake connection from tx to rx... + self.sender.connect = self.make_fake_ssync_connect( + self.sender, self.rx_controller, self.device, self.partition, + policy) + + # run the sync protocol... + self.sender() + + # verify protocol + results = self._analyze_trace(self.sender.connection.trace) + # sender has primary for o1, o2 and o3, o4 and ts for o5 + self.assertEqual(5, len(results['tx_missing'])) + # receiver is missing o1, o2 and o3 and ts for o5 + self.assertEqual(4, len(results['rx_missing'])) + # sender can only construct 2 out of 3 missing frags + self.assertEqual(3, len(results['tx_updates'])) + self.assertEqual(3, len(reconstruct_fa_calls)) + self.assertFalse(results['rx_updates']) + actual_sync_paths = [] + for subreq in results.get('tx_updates'): + if subreq.get('method') == 'PUT': + self.assertTrue( + 'X-Object-Sysmeta-Ec-Frag-Index: %s' % rx_node_index + in subreq.get('headers')) + expected_body = '%s___%s' % (subreq['path'], rx_node_index) + self.assertEqual(expected_body, subreq['body']) + elif subreq.get('method') == 'DELETE': + self.assertEqual('/a/c/o5', subreq['path']) + actual_sync_paths.append(subreq.get('path')) + + # remove the failed df from expected synced df's + expect_sync_paths = ['/a/c/o1', '/a/c/o2', '/a/c/o3', '/a/c/o5'] + failed_path = reconstruct_fa_calls[1][3]['name'] + expect_sync_paths.remove(failed_path) + failed_obj = None + for obj, diskfiles in tx_objs.iteritems(): + if diskfiles[0]._name == failed_path: + failed_obj = obj + # sanity check + self.assertTrue(tx_objs.pop(failed_obj)) + + # verify on disk files... + self.assertEqual(sorted(expect_sync_paths), sorted(actual_sync_paths)) + self._verify_ondisk_files(tx_objs, policy, rx_node_index) + self._verify_tombstones(tx_tombstones, policy) + + if __name__ == '__main__': unittest.main() diff --git a/test/unit/obj/test_updater.py b/test/unit/obj/test_updater.py index 1915a55d1d..2ca3965453 100644 --- a/test/unit/obj/test_updater.py +++ b/test/unit/obj/test_updater.py @@ -70,7 +70,7 @@ class TestObjectUpdater(unittest.TestCase): self.sda1 = os.path.join(self.devices_dir, 'sda1') os.mkdir(self.sda1) for policy in POLICIES: - os.mkdir(os.path.join(self.sda1, get_tmp_dir(int(policy)))) + os.mkdir(os.path.join(self.sda1, get_tmp_dir(policy))) self.logger = debug_logger() def tearDown(self): @@ -169,8 +169,8 @@ class TestObjectUpdater(unittest.TestCase): seen = set() class MockObjectUpdater(object_updater.ObjectUpdater): - def process_object_update(self, update_path, device, idx): - seen.add((update_path, idx)) + def process_object_update(self, update_path, device, policy): + seen.add((update_path, int(policy))) os.unlink(update_path) cu = MockObjectUpdater({ @@ -216,7 +216,7 @@ class TestObjectUpdater(unittest.TestCase): 'concurrency': '1', 'node_timeout': '15'}) cu.run_once() - async_dir = os.path.join(self.sda1, get_async_dir(0)) + async_dir = os.path.join(self.sda1, get_async_dir(POLICIES[0])) os.mkdir(async_dir) cu.run_once() self.assert_(os.path.exists(async_dir)) @@ -253,7 +253,7 @@ class TestObjectUpdater(unittest.TestCase): 'concurrency': '1', 'node_timeout': '15'}, logger=self.logger) cu.run_once() - async_dir = os.path.join(self.sda1, get_async_dir(0)) + async_dir = os.path.join(self.sda1, get_async_dir(POLICIES[0])) os.mkdir(async_dir) cu.run_once() self.assert_(os.path.exists(async_dir)) @@ -393,7 +393,7 @@ class TestObjectUpdater(unittest.TestCase): 'mount_check': 'false', 'swift_dir': self.testdir, } - async_dir = os.path.join(self.sda1, get_async_dir(policy.idx)) + async_dir = os.path.join(self.sda1, get_async_dir(policy)) os.mkdir(async_dir) account, container, obj = 'a', 'c', 'o' @@ -412,7 +412,7 @@ class TestObjectUpdater(unittest.TestCase): data = {'op': op, 'account': account, 'container': container, 'obj': obj, 'headers': headers_out} dfmanager.pickle_async_update(self.sda1, account, container, obj, - data, ts.next(), policy.idx) + data, ts.next(), policy) request_log = [] @@ -428,7 +428,7 @@ class TestObjectUpdater(unittest.TestCase): ip, part, method, path, headers, qs, ssl = request_args self.assertEqual(method, op) self.assertEqual(headers['X-Backend-Storage-Policy-Index'], - str(policy.idx)) + str(int(policy))) self.assertEqual(daemon.logger.get_increment_counts(), {'successes': 1, 'unlinks': 1, 'async_pendings': 1}) @@ -444,7 +444,7 @@ class TestObjectUpdater(unittest.TestCase): 'swift_dir': self.testdir, } daemon = object_updater.ObjectUpdater(conf, logger=self.logger) - async_dir = os.path.join(self.sda1, get_async_dir(policy.idx)) + async_dir = os.path.join(self.sda1, get_async_dir(policy)) os.mkdir(async_dir) # write an async @@ -456,12 +456,12 @@ class TestObjectUpdater(unittest.TestCase): 'x-content-type': 'text/plain', 'x-etag': 'd41d8cd98f00b204e9800998ecf8427e', 'x-timestamp': ts.next(), - 'X-Backend-Storage-Policy-Index': policy.idx, + 'X-Backend-Storage-Policy-Index': int(policy), }) data = {'op': op, 'account': account, 'container': container, 'obj': obj, 'headers': headers_out} dfmanager.pickle_async_update(self.sda1, account, container, obj, - data, ts.next(), policy.idx) + data, ts.next(), policy) request_log = [] @@ -481,7 +481,7 @@ class TestObjectUpdater(unittest.TestCase): ip, part, method, path, headers, qs, ssl = request_args self.assertEqual(method, 'PUT') self.assertEqual(headers['X-Backend-Storage-Policy-Index'], - str(policy.idx)) + str(int(policy))) self.assertEqual(daemon.logger.get_increment_counts(), {'successes': 1, 'unlinks': 1, 'async_pendings': 1}) diff --git a/test/unit/proxy/controllers/test_base.py b/test/unit/proxy/controllers/test_base.py index 2c2094ffed..0ebd96eabd 100644 --- a/test/unit/proxy/controllers/test_base.py +++ b/test/unit/proxy/controllers/test_base.py @@ -21,9 +21,11 @@ from swift.proxy.controllers.base import headers_to_container_info, \ headers_to_account_info, headers_to_object_info, get_container_info, \ get_container_memcache_key, get_account_info, get_account_memcache_key, \ get_object_env_key, get_info, get_object_info, \ - Controller, GetOrHeadHandler, _set_info_cache, _set_object_info_cache + Controller, GetOrHeadHandler, _set_info_cache, _set_object_info_cache, \ + bytes_to_skip from swift.common.swob import Request, HTTPException, HeaderKeyDict, \ RESPONSE_REASONS +from swift.common import exceptions from swift.common.utils import split_path from swift.common.http import is_success from swift.common.storage_policy import StoragePolicy @@ -159,9 +161,11 @@ class TestFuncs(unittest.TestCase): def test_GETorHEAD_base(self): base = Controller(self.app) req = Request.blank('/v1/a/c/o/with/slashes') + ring = FakeRing() + nodes = list(ring.get_part_nodes(0)) + list(ring.get_more_nodes(0)) with patch('swift.proxy.controllers.base.' 'http_connect', fake_http_connect(200)): - resp = base.GETorHEAD_base(req, 'object', FakeRing(), 'part', + resp = base.GETorHEAD_base(req, 'object', iter(nodes), 'part', '/a/c/o/with/slashes') self.assertTrue('swift.object/a/c/o/with/slashes' in resp.environ) self.assertEqual( @@ -169,14 +173,14 @@ class TestFuncs(unittest.TestCase): req = Request.blank('/v1/a/c/o') with patch('swift.proxy.controllers.base.' 'http_connect', fake_http_connect(200)): - resp = base.GETorHEAD_base(req, 'object', FakeRing(), 'part', + resp = base.GETorHEAD_base(req, 'object', iter(nodes), 'part', '/a/c/o') self.assertTrue('swift.object/a/c/o' in resp.environ) self.assertEqual(resp.environ['swift.object/a/c/o']['status'], 200) req = Request.blank('/v1/a/c') with patch('swift.proxy.controllers.base.' 'http_connect', fake_http_connect(200)): - resp = base.GETorHEAD_base(req, 'container', FakeRing(), 'part', + resp = base.GETorHEAD_base(req, 'container', iter(nodes), 'part', '/a/c') self.assertTrue('swift.container/a/c' in resp.environ) self.assertEqual(resp.environ['swift.container/a/c']['status'], 200) @@ -184,7 +188,7 @@ class TestFuncs(unittest.TestCase): req = Request.blank('/v1/a') with patch('swift.proxy.controllers.base.' 'http_connect', fake_http_connect(200)): - resp = base.GETorHEAD_base(req, 'account', FakeRing(), 'part', + resp = base.GETorHEAD_base(req, 'account', iter(nodes), 'part', '/a') self.assertTrue('swift.account/a' in resp.environ) self.assertEqual(resp.environ['swift.account/a']['status'], 200) @@ -546,7 +550,7 @@ class TestFuncs(unittest.TestCase): resp, headers_to_object_info(headers.items(), 200)) - def test_have_quorum(self): + def test_base_have_quorum(self): base = Controller(self.app) # just throw a bunch of test cases at it self.assertEqual(base.have_quorum([201, 404], 3), False) @@ -575,7 +579,7 @@ class TestFuncs(unittest.TestCase): overrides = {302: 204, 100: 204} resp = base.best_response(req, statuses, reasons, bodies, server_type, headers=headers, overrides=overrides) - self.assertEqual(resp.status, '503 Internal Server Error') + self.assertEqual(resp.status, '503 Service Unavailable') # next make a 404 quorum and make sure the last delete (real) 404 # status is the one returned. @@ -648,3 +652,88 @@ class TestFuncs(unittest.TestCase): self.assertEqual(v, dst_headers[k.lower()]) for k, v in bad_hdrs.iteritems(): self.assertFalse(k.lower() in dst_headers) + + def test_client_chunk_size(self): + + class TestSource(object): + def __init__(self, chunks): + self.chunks = list(chunks) + + def read(self, _read_size): + if self.chunks: + return self.chunks.pop(0) + else: + return '' + + source = TestSource(( + 'abcd', '1234', 'abc', 'd1', '234abcd1234abcd1', '2')) + req = Request.blank('/v1/a/c/o') + node = {} + handler = GetOrHeadHandler(self.app, req, None, None, None, None, {}, + client_chunk_size=8) + + app_iter = handler._make_app_iter(req, node, source) + client_chunks = list(app_iter) + self.assertEqual(client_chunks, [ + 'abcd1234', 'abcd1234', 'abcd1234', 'abcd12']) + + def test_client_chunk_size_resuming(self): + + class TestSource(object): + def __init__(self, chunks): + self.chunks = list(chunks) + + def read(self, _read_size): + if self.chunks: + chunk = self.chunks.pop(0) + if chunk is None: + raise exceptions.ChunkReadTimeout() + else: + return chunk + else: + return '' + + node = {'ip': '1.2.3.4', 'port': 6000, 'device': 'sda'} + + source1 = TestSource(['abcd', '1234', 'abc', None]) + source2 = TestSource(['efgh5678']) + req = Request.blank('/v1/a/c/o') + handler = GetOrHeadHandler( + self.app, req, 'Object', None, None, None, {}, + client_chunk_size=8) + + app_iter = handler._make_app_iter(req, node, source1) + with patch.object(handler, '_get_source_and_node', + lambda: (source2, node)): + client_chunks = list(app_iter) + self.assertEqual(client_chunks, ['abcd1234', 'efgh5678']) + self.assertEqual(handler.backend_headers['Range'], 'bytes=8-') + + def test_bytes_to_skip(self): + # if you start at the beginning, skip nothing + self.assertEqual(bytes_to_skip(1024, 0), 0) + + # missed the first 10 bytes, so we've got 1014 bytes of partial + # record + self.assertEqual(bytes_to_skip(1024, 10), 1014) + + # skipped some whole records first + self.assertEqual(bytes_to_skip(1024, 4106), 1014) + + # landed on a record boundary + self.assertEqual(bytes_to_skip(1024, 1024), 0) + self.assertEqual(bytes_to_skip(1024, 2048), 0) + + # big numbers + self.assertEqual(bytes_to_skip(2 ** 20, 2 ** 32), 0) + self.assertEqual(bytes_to_skip(2 ** 20, 2 ** 32 + 1), 2 ** 20 - 1) + self.assertEqual(bytes_to_skip(2 ** 20, 2 ** 32 + 2 ** 19), 2 ** 19) + + # odd numbers + self.assertEqual(bytes_to_skip(123, 0), 0) + self.assertEqual(bytes_to_skip(123, 23), 100) + self.assertEqual(bytes_to_skip(123, 247), 122) + + # prime numbers + self.assertEqual(bytes_to_skip(11, 7), 4) + self.assertEqual(bytes_to_skip(97, 7873823), 55) diff --git a/test/unit/proxy/controllers/test_obj.py b/test/unit/proxy/controllers/test_obj.py index 002582a1ac..a38e753ae0 100755 --- a/test/unit/proxy/controllers/test_obj.py +++ b/test/unit/proxy/controllers/test_obj.py @@ -14,11 +14,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import email.parser import itertools import random import time import unittest +from collections import defaultdict from contextlib import contextmanager +import json +from hashlib import md5 import mock from eventlet import Timeout @@ -26,13 +30,26 @@ from eventlet import Timeout import swift from swift.common import utils, swob from swift.proxy import server as proxy_server -from swift.common.storage_policy import StoragePolicy, POLICIES +from swift.proxy.controllers import obj +from swift.proxy.controllers.base import get_info as _real_get_info +from swift.common.storage_policy import POLICIES, ECDriverError from test.unit import FakeRing, FakeMemcache, fake_http_connect, \ - debug_logger, patch_policies + debug_logger, patch_policies, SlowBody from test.unit.proxy.test_server import node_error_count +def unchunk_body(chunked_body): + body = '' + remaining = chunked_body + while remaining: + hex_length, remaining = remaining.split('\r\n', 1) + length = int(hex_length, 16) + body += remaining[:length] + remaining = remaining[length + 2:] + return body + + @contextmanager def set_http_connect(*args, **kwargs): old_connect = swift.proxy.controllers.base.http_connect @@ -55,31 +72,76 @@ def set_http_connect(*args, **kwargs): class PatchedObjControllerApp(proxy_server.Application): """ - This patch is just a hook over handle_request to ensure that when - get_controller is called the ObjectController class is patched to - return a (possibly stubbed) ObjectController class. + This patch is just a hook over the proxy server's __call__ to ensure + that calls to get_info will return the stubbed value for + container_info if it's a container info call. """ - object_controller = proxy_server.ObjectController + container_info = {} + per_container_info = {} - def handle_request(self, req): - with mock.patch('swift.proxy.server.ObjectController', - new=self.object_controller): - return super(PatchedObjControllerApp, self).handle_request(req) + def __call__(self, *args, **kwargs): + + def _fake_get_info(app, env, account, container=None, **kwargs): + if container: + if container in self.per_container_info: + return self.per_container_info[container] + return self.container_info + else: + return _real_get_info(app, env, account, container, **kwargs) + + mock_path = 'swift.proxy.controllers.base.get_info' + with mock.patch(mock_path, new=_fake_get_info): + return super( + PatchedObjControllerApp, self).__call__(*args, **kwargs) -@patch_policies([StoragePolicy(0, 'zero', True, - object_ring=FakeRing(max_more_nodes=9))]) -class TestObjControllerWriteAffinity(unittest.TestCase): +class BaseObjectControllerMixin(object): + container_info = { + 'write_acl': None, + 'read_acl': None, + 'storage_policy': None, + 'sync_key': None, + 'versions': None, + } + + # this needs to be set on the test case + controller_cls = None + def setUp(self): - self.app = proxy_server.Application( + # setup fake rings with handoffs + for policy in POLICIES: + policy.object_ring.max_more_nodes = policy.object_ring.replicas + + self.logger = debug_logger('proxy-server') + self.logger.thread_locals = ('txn1', '127.0.0.2') + self.app = PatchedObjControllerApp( None, FakeMemcache(), account_ring=FakeRing(), - container_ring=FakeRing(), logger=debug_logger()) - self.app.request_node_count = lambda ring: 10000000 - self.app.sort_nodes = lambda l: l # stop shuffling the primary nodes + container_ring=FakeRing(), logger=self.logger) + # you can over-ride the container_info just by setting it on the app + self.app.container_info = dict(self.container_info) + # default policy and ring references + self.policy = POLICIES.default + self.obj_ring = self.policy.object_ring + self._ts_iter = (utils.Timestamp(t) for t in + itertools.count(int(time.time()))) + + def ts(self): + return self._ts_iter.next() + + def replicas(self, policy=None): + policy = policy or POLICIES.default + return policy.object_ring.replicas + + def quorum(self, policy=None): + policy = policy or POLICIES.default + return policy.quorum def test_iter_nodes_local_first_noops_when_no_affinity(self): - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + # this test needs a stable node order - most don't + self.app.sort_nodes = lambda l: l + controller = self.controller_cls( + self.app, 'a', 'c', 'o') self.app.write_affinity_is_local_fn = None object_ring = self.app.get_object_ring(None) all_nodes = object_ring.get_part_nodes(1) @@ -93,10 +155,50 @@ class TestObjControllerWriteAffinity(unittest.TestCase): self.assertEqual(all_nodes, local_first_nodes) def test_iter_nodes_local_first_moves_locals_first(self): - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = self.controller_cls( + self.app, 'a', 'c', 'o') + self.app.write_affinity_is_local_fn = ( + lambda node: node['region'] == 1) + # we'll write to one more than replica count local nodes + self.app.write_affinity_node_count = lambda r: r + 1 + + object_ring = self.app.get_object_ring(None) + # make our fake ring have plenty of nodes, and not get limited + # artificially by the proxy max request node count + object_ring.max_more_nodes = 100000 + self.app.request_node_count = lambda r: 100000 + + all_nodes = object_ring.get_part_nodes(1) + all_nodes.extend(object_ring.get_more_nodes(1)) + + # i guess fake_ring wants the get_more_nodes iter to more safely be + # converted to a list with a smallish sort of limit which *can* be + # lower than max_more_nodes + fake_rings_real_max_more_nodes_value = object_ring.replicas ** 2 + self.assertEqual(len(all_nodes), fake_rings_real_max_more_nodes_value) + + # make sure we have enough local nodes (sanity) + all_local_nodes = [n for n in all_nodes if + self.app.write_affinity_is_local_fn(n)] + self.assertTrue(len(all_local_nodes) >= self.replicas() + 1) + + # finally, create the local_first_nodes iter and flatten it out + local_first_nodes = list(controller.iter_nodes_local_first( + object_ring, 1)) + + # the local nodes move up in the ordering + self.assertEqual([1] * (self.replicas() + 1), [ + node['region'] for node in local_first_nodes[ + :self.replicas() + 1]]) + # we don't skip any nodes + self.assertEqual(len(all_nodes), len(local_first_nodes)) + self.assertEqual(sorted(all_nodes), sorted(local_first_nodes)) + + def test_iter_nodes_local_first_best_effort(self): + controller = self.controller_cls( + self.app, 'a', 'c', 'o') self.app.write_affinity_is_local_fn = ( lambda node: node['region'] == 1) - self.app.write_affinity_node_count = lambda ring: 4 object_ring = self.app.get_object_ring(None) all_nodes = object_ring.get_part_nodes(1) @@ -105,68 +207,283 @@ class TestObjControllerWriteAffinity(unittest.TestCase): local_first_nodes = list(controller.iter_nodes_local_first( object_ring, 1)) - # the local nodes move up in the ordering - self.assertEqual([1, 1, 1, 1], - [node['region'] for node in local_first_nodes[:4]]) - # we don't skip any nodes + # we won't have quite enough local nodes... + self.assertEqual(len(all_nodes), self.replicas() + + POLICIES.default.object_ring.max_more_nodes) + all_local_nodes = [n for n in all_nodes if + self.app.write_affinity_is_local_fn(n)] + self.assertEqual(len(all_local_nodes), self.replicas()) + # but the local nodes we do have are at the front of the local iter + first_n_local_first_nodes = local_first_nodes[:len(all_local_nodes)] + self.assertEqual(sorted(all_local_nodes), + sorted(first_n_local_first_nodes)) + # but we *still* don't *skip* any nodes self.assertEqual(len(all_nodes), len(local_first_nodes)) self.assertEqual(sorted(all_nodes), sorted(local_first_nodes)) def test_connect_put_node_timeout(self): - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = self.controller_cls( + self.app, 'a', 'c', 'o') self.app.conn_timeout = 0.05 with set_http_connect(slow_connect=True): nodes = [dict(ip='', port='', device='')] res = controller._connect_put_node(nodes, '', '', {}, ('', '')) self.assertTrue(res is None) + def test_DELETE_simple(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') + codes = [204] * self.replicas() + with set_http_connect(*codes): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 204) -@patch_policies([ - StoragePolicy(0, 'zero', True), - StoragePolicy(1, 'one'), - StoragePolicy(2, 'two'), -]) -class TestObjController(unittest.TestCase): - container_info = { - 'partition': 1, - 'nodes': [ - {'ip': '127.0.0.1', 'port': '1', 'device': 'sda'}, - {'ip': '127.0.0.1', 'port': '2', 'device': 'sda'}, - {'ip': '127.0.0.1', 'port': '3', 'device': 'sda'}, - ], - 'write_acl': None, - 'read_acl': None, - 'storage_policy': None, - 'sync_key': None, - 'versions': None, - } + def test_DELETE_missing_one(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') + codes = [404] + [204] * (self.replicas() - 1) + random.shuffle(codes) + with set_http_connect(*codes): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 204) - def setUp(self): - # setup fake rings with handoffs - self.obj_ring = FakeRing(max_more_nodes=3) - for policy in POLICIES: - policy.object_ring = self.obj_ring + def test_DELETE_not_found(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') + codes = [404] * (self.replicas() - 1) + [204] + with set_http_connect(*codes): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 404) - logger = debug_logger('proxy-server') - logger.thread_locals = ('txn1', '127.0.0.2') - self.app = PatchedObjControllerApp( - None, FakeMemcache(), account_ring=FakeRing(), - container_ring=FakeRing(), logger=logger) + def test_DELETE_mostly_found(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') + mostly_204s = [204] * self.quorum() + codes = mostly_204s + [404] * (self.replicas() - len(mostly_204s)) + self.assertEqual(len(codes), self.replicas()) + with set_http_connect(*codes): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 204) - class FakeContainerInfoObjController(proxy_server.ObjectController): + def test_DELETE_mostly_not_found(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') + mostly_404s = [404] * self.quorum() + codes = mostly_404s + [204] * (self.replicas() - len(mostly_404s)) + self.assertEqual(len(codes), self.replicas()) + with set_http_connect(*codes): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 404) - def container_info(controller, *args, **kwargs): - patch_path = 'swift.proxy.controllers.base.get_info' - with mock.patch(patch_path) as mock_get_info: - mock_get_info.return_value = dict(self.container_info) - return super(FakeContainerInfoObjController, - controller).container_info(*args, **kwargs) + def test_DELETE_half_not_found_statuses(self): + self.obj_ring.set_replicas(4) - # this is taking advantage of the fact that self.app is a - # PachedObjControllerApp, so handle_response will route into an - # instance of our FakeContainerInfoObjController just by - # overriding the class attribute for object_controller - self.app.object_controller = FakeContainerInfoObjController + req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') + with set_http_connect(404, 204, 404, 204): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 204) + + def test_DELETE_half_not_found_headers_and_body(self): + # Transformed responses have bogus bodies and headers, so make sure we + # send the client headers and body from a real node's response. + self.obj_ring.set_replicas(4) + + status_codes = (404, 404, 204, 204) + bodies = ('not found', 'not found', '', '') + headers = [{}, {}, {'Pick-Me': 'yes'}, {'Pick-Me': 'yes'}] + + req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') + with set_http_connect(*status_codes, body_iter=bodies, + headers=headers): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 204) + self.assertEquals(resp.headers.get('Pick-Me'), 'yes') + self.assertEquals(resp.body, '') + + def test_DELETE_handoff(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') + codes = [204] * self.replicas() + with set_http_connect(507, *codes): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 204) + + def test_POST_non_int_delete_after(self): + t = str(int(time.time() + 100)) + '.1' + req = swob.Request.blank('/v1/a/c/o', method='POST', + headers={'Content-Type': 'foo/bar', + 'X-Delete-After': t}) + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 400) + self.assertEqual('Non-integer X-Delete-After', resp.body) + + def test_PUT_non_int_delete_after(self): + t = str(int(time.time() + 100)) + '.1' + req = swob.Request.blank('/v1/a/c/o', method='PUT', body='', + headers={'Content-Type': 'foo/bar', + 'X-Delete-After': t}) + with set_http_connect(): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 400) + self.assertEqual('Non-integer X-Delete-After', resp.body) + + def test_POST_negative_delete_after(self): + req = swob.Request.blank('/v1/a/c/o', method='POST', + headers={'Content-Type': 'foo/bar', + 'X-Delete-After': '-60'}) + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 400) + self.assertEqual('X-Delete-After in past', resp.body) + + def test_PUT_negative_delete_after(self): + req = swob.Request.blank('/v1/a/c/o', method='PUT', body='', + headers={'Content-Type': 'foo/bar', + 'X-Delete-After': '-60'}) + with set_http_connect(): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 400) + self.assertEqual('X-Delete-After in past', resp.body) + + def test_POST_delete_at_non_integer(self): + t = str(int(time.time() + 100)) + '.1' + req = swob.Request.blank('/v1/a/c/o', method='POST', + headers={'Content-Type': 'foo/bar', + 'X-Delete-At': t}) + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 400) + self.assertEqual('Non-integer X-Delete-At', resp.body) + + def test_PUT_delete_at_non_integer(self): + t = str(int(time.time() - 100)) + '.1' + req = swob.Request.blank('/v1/a/c/o', method='PUT', body='', + headers={'Content-Type': 'foo/bar', + 'X-Delete-At': t}) + with set_http_connect(): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 400) + self.assertEqual('Non-integer X-Delete-At', resp.body) + + def test_POST_delete_at_in_past(self): + t = str(int(time.time() - 100)) + req = swob.Request.blank('/v1/a/c/o', method='POST', + headers={'Content-Type': 'foo/bar', + 'X-Delete-At': t}) + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 400) + self.assertEqual('X-Delete-At in past', resp.body) + + def test_PUT_delete_at_in_past(self): + t = str(int(time.time() - 100)) + req = swob.Request.blank('/v1/a/c/o', method='PUT', body='', + headers={'Content-Type': 'foo/bar', + 'X-Delete-At': t}) + with set_http_connect(): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 400) + self.assertEqual('X-Delete-At in past', resp.body) + + def test_HEAD_simple(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='HEAD') + with set_http_connect(200): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 200) + + def test_HEAD_x_newest(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='HEAD', + headers={'X-Newest': 'true'}) + with set_http_connect(200, 200, 200): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 200) + + def test_HEAD_x_newest_different_timestamps(self): + req = swob.Request.blank('/v1/a/c/o', method='HEAD', + headers={'X-Newest': 'true'}) + ts = (utils.Timestamp(t) for t in itertools.count(int(time.time()))) + timestamps = [next(ts) for i in range(3)] + newest_timestamp = timestamps[-1] + random.shuffle(timestamps) + backend_response_headers = [{ + 'X-Backend-Timestamp': t.internal, + 'X-Timestamp': t.normal + } for t in timestamps] + with set_http_connect(200, 200, 200, + headers=backend_response_headers): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 200) + self.assertEqual(resp.headers['x-timestamp'], newest_timestamp.normal) + + def test_HEAD_x_newest_with_two_vector_timestamps(self): + req = swob.Request.blank('/v1/a/c/o', method='HEAD', + headers={'X-Newest': 'true'}) + ts = (utils.Timestamp(time.time(), offset=offset) + for offset in itertools.count()) + timestamps = [next(ts) for i in range(3)] + newest_timestamp = timestamps[-1] + random.shuffle(timestamps) + backend_response_headers = [{ + 'X-Backend-Timestamp': t.internal, + 'X-Timestamp': t.normal + } for t in timestamps] + with set_http_connect(200, 200, 200, + headers=backend_response_headers): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 200) + self.assertEqual(resp.headers['x-backend-timestamp'], + newest_timestamp.internal) + + def test_HEAD_x_newest_with_some_missing(self): + req = swob.Request.blank('/v1/a/c/o', method='HEAD', + headers={'X-Newest': 'true'}) + ts = (utils.Timestamp(t) for t in itertools.count(int(time.time()))) + request_count = self.app.request_node_count(self.obj_ring.replicas) + backend_response_headers = [{ + 'x-timestamp': next(ts).normal, + } for i in range(request_count)] + responses = [404] * (request_count - 1) + responses.append(200) + request_log = [] + + def capture_requests(ip, port, device, part, method, path, + headers=None, **kwargs): + req = { + 'ip': ip, + 'port': port, + 'device': device, + 'part': part, + 'method': method, + 'path': path, + 'headers': headers, + } + request_log.append(req) + with set_http_connect(*responses, + headers=backend_response_headers, + give_connect=capture_requests): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 200) + for req in request_log: + self.assertEqual(req['method'], 'HEAD') + self.assertEqual(req['path'], '/a/c/o') + + def test_container_sync_delete(self): + ts = (utils.Timestamp(t) for t in itertools.count(int(time.time()))) + test_indexes = [None] + [int(p) for p in POLICIES] + for policy_index in test_indexes: + req = swob.Request.blank( + '/v1/a/c/o', method='DELETE', headers={ + 'X-Timestamp': ts.next().internal}) + codes = [409] * self.obj_ring.replicas + ts_iter = itertools.repeat(ts.next().internal) + with set_http_connect(*codes, timestamps=ts_iter): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 409) + + def test_PUT_requires_length(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT') + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 411) + +# end of BaseObjectControllerMixin + + +@patch_policies() +class TestReplicatedObjController(BaseObjectControllerMixin, + unittest.TestCase): + + controller_cls = obj.ReplicatedObjectController def test_PUT_simple(self): req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT') @@ -279,56 +596,6 @@ class TestObjController(unittest.TestCase): resp = req.get_response(self.app) self.assertEquals(resp.status_int, 404) - def test_DELETE_simple(self): - req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') - with set_http_connect(204, 204, 204): - resp = req.get_response(self.app) - self.assertEquals(resp.status_int, 204) - - def test_DELETE_missing_one(self): - req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') - with set_http_connect(404, 204, 204): - resp = req.get_response(self.app) - self.assertEquals(resp.status_int, 204) - - def test_DELETE_half_not_found_statuses(self): - self.obj_ring.set_replicas(4) - - req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') - with set_http_connect(404, 204, 404, 204): - resp = req.get_response(self.app) - self.assertEquals(resp.status_int, 204) - - def test_DELETE_half_not_found_headers_and_body(self): - # Transformed responses have bogus bodies and headers, so make sure we - # send the client headers and body from a real node's response. - self.obj_ring.set_replicas(4) - - status_codes = (404, 404, 204, 204) - bodies = ('not found', 'not found', '', '') - headers = [{}, {}, {'Pick-Me': 'yes'}, {'Pick-Me': 'yes'}] - - req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') - with set_http_connect(*status_codes, body_iter=bodies, - headers=headers): - resp = req.get_response(self.app) - self.assertEquals(resp.status_int, 204) - self.assertEquals(resp.headers.get('Pick-Me'), 'yes') - self.assertEquals(resp.body, '') - - def test_DELETE_not_found(self): - req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') - with set_http_connect(404, 404, 204): - resp = req.get_response(self.app) - self.assertEquals(resp.status_int, 404) - - def test_DELETE_handoff(self): - req = swift.common.swob.Request.blank('/v1/a/c/o', method='DELETE') - codes = [204] * self.obj_ring.replicas - with set_http_connect(507, *codes): - resp = req.get_response(self.app) - self.assertEquals(resp.status_int, 204) - def test_POST_as_COPY_simple(self): req = swift.common.swob.Request.blank('/v1/a/c/o', method='POST') head_resp = [200] * self.obj_ring.replicas + \ @@ -364,40 +631,27 @@ class TestObjController(unittest.TestCase): self.assertTrue('X-Delete-At-Partition' in given_headers) self.assertTrue('X-Delete-At-Container' in given_headers) - def test_POST_non_int_delete_after(self): - t = str(int(time.time() + 100)) + '.1' - req = swob.Request.blank('/v1/a/c/o', method='POST', - headers={'Content-Type': 'foo/bar', - 'X-Delete-After': t}) - resp = req.get_response(self.app) - self.assertEqual(resp.status_int, 400) - self.assertEqual('Non-integer X-Delete-After', resp.body) - - def test_POST_negative_delete_after(self): - req = swob.Request.blank('/v1/a/c/o', method='POST', - headers={'Content-Type': 'foo/bar', - 'X-Delete-After': '-60'}) - resp = req.get_response(self.app) - self.assertEqual(resp.status_int, 400) - self.assertEqual('X-Delete-After in past', resp.body) - - def test_POST_delete_at_non_integer(self): - t = str(int(time.time() + 100)) + '.1' - req = swob.Request.blank('/v1/a/c/o', method='POST', + def test_PUT_delete_at(self): + t = str(int(time.time() + 100)) + req = swob.Request.blank('/v1/a/c/o', method='PUT', body='', headers={'Content-Type': 'foo/bar', 'X-Delete-At': t}) - resp = req.get_response(self.app) - self.assertEqual(resp.status_int, 400) - self.assertEqual('Non-integer X-Delete-At', resp.body) + put_headers = [] - def test_POST_delete_at_in_past(self): - t = str(int(time.time() - 100)) - req = swob.Request.blank('/v1/a/c/o', method='POST', - headers={'Content-Type': 'foo/bar', - 'X-Delete-At': t}) - resp = req.get_response(self.app) - self.assertEqual(resp.status_int, 400) - self.assertEqual('X-Delete-At in past', resp.body) + def capture_headers(ip, port, device, part, method, path, headers, + **kwargs): + if method == 'PUT': + put_headers.append(headers) + codes = [201] * self.obj_ring.replicas + with set_http_connect(*codes, give_connect=capture_headers): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 201) + for given_headers in put_headers: + self.assertEquals(given_headers.get('X-Delete-At'), t) + self.assertTrue('X-Delete-At-Host' in given_headers) + self.assertTrue('X-Delete-At-Device' in given_headers) + self.assertTrue('X-Delete-At-Partition' in given_headers) + self.assertTrue('X-Delete-At-Container' in given_headers) def test_PUT_converts_delete_after_to_delete_at(self): req = swob.Request.blank('/v1/a/c/o', method='PUT', body='', @@ -424,71 +678,10 @@ class TestObjController(unittest.TestCase): self.assertTrue('X-Delete-At-Partition' in given_headers) self.assertTrue('X-Delete-At-Container' in given_headers) - def test_PUT_non_int_delete_after(self): - t = str(int(time.time() + 100)) + '.1' - req = swob.Request.blank('/v1/a/c/o', method='PUT', body='', - headers={'Content-Type': 'foo/bar', - 'X-Delete-After': t}) - with set_http_connect(): - resp = req.get_response(self.app) - self.assertEqual(resp.status_int, 400) - self.assertEqual('Non-integer X-Delete-After', resp.body) - - def test_PUT_negative_delete_after(self): - req = swob.Request.blank('/v1/a/c/o', method='PUT', body='', - headers={'Content-Type': 'foo/bar', - 'X-Delete-After': '-60'}) - with set_http_connect(): - resp = req.get_response(self.app) - self.assertEqual(resp.status_int, 400) - self.assertEqual('X-Delete-After in past', resp.body) - - def test_PUT_delete_at(self): - t = str(int(time.time() + 100)) - req = swob.Request.blank('/v1/a/c/o', method='PUT', body='', - headers={'Content-Type': 'foo/bar', - 'X-Delete-At': t}) - put_headers = [] - - def capture_headers(ip, port, device, part, method, path, headers, - **kwargs): - if method == 'PUT': - put_headers.append(headers) - codes = [201] * self.obj_ring.replicas - with set_http_connect(*codes, give_connect=capture_headers): - resp = req.get_response(self.app) - self.assertEquals(resp.status_int, 201) - for given_headers in put_headers: - self.assertEquals(given_headers.get('X-Delete-At'), t) - self.assertTrue('X-Delete-At-Host' in given_headers) - self.assertTrue('X-Delete-At-Device' in given_headers) - self.assertTrue('X-Delete-At-Partition' in given_headers) - self.assertTrue('X-Delete-At-Container' in given_headers) - - def test_PUT_delete_at_non_integer(self): - t = str(int(time.time() - 100)) + '.1' - req = swob.Request.blank('/v1/a/c/o', method='PUT', body='', - headers={'Content-Type': 'foo/bar', - 'X-Delete-At': t}) - with set_http_connect(): - resp = req.get_response(self.app) - self.assertEqual(resp.status_int, 400) - self.assertEqual('Non-integer X-Delete-At', resp.body) - - def test_PUT_delete_at_in_past(self): - t = str(int(time.time() - 100)) - req = swob.Request.blank('/v1/a/c/o', method='PUT', body='', - headers={'Content-Type': 'foo/bar', - 'X-Delete-At': t}) - with set_http_connect(): - resp = req.get_response(self.app) - self.assertEqual(resp.status_int, 400) - self.assertEqual('X-Delete-At in past', resp.body) - def test_container_sync_put_x_timestamp_not_found(self): test_indexes = [None] + [int(p) for p in POLICIES] for policy_index in test_indexes: - self.container_info['storage_policy'] = policy_index + self.app.container_info['storage_policy'] = policy_index put_timestamp = utils.Timestamp(time.time()).normal req = swob.Request.blank( '/v1/a/c/o', method='PUT', headers={ @@ -502,7 +695,7 @@ class TestObjController(unittest.TestCase): def test_container_sync_put_x_timestamp_match(self): test_indexes = [None] + [int(p) for p in POLICIES] for policy_index in test_indexes: - self.container_info['storage_policy'] = policy_index + self.app.container_info['storage_policy'] = policy_index put_timestamp = utils.Timestamp(time.time()).normal req = swob.Request.blank( '/v1/a/c/o', method='PUT', headers={ @@ -518,7 +711,7 @@ class TestObjController(unittest.TestCase): ts = (utils.Timestamp(t) for t in itertools.count(int(time.time()))) test_indexes = [None] + [int(p) for p in POLICIES] for policy_index in test_indexes: - self.container_info['storage_policy'] = policy_index + self.app.container_info['storage_policy'] = policy_index req = swob.Request.blank( '/v1/a/c/o', method='PUT', headers={ 'Content-Length': 0, @@ -544,19 +737,6 @@ class TestObjController(unittest.TestCase): resp = req.get_response(self.app) self.assertEqual(resp.status_int, 201) - def test_container_sync_delete(self): - ts = (utils.Timestamp(t) for t in itertools.count(int(time.time()))) - test_indexes = [None] + [int(p) for p in POLICIES] - for policy_index in test_indexes: - req = swob.Request.blank( - '/v1/a/c/o', method='DELETE', headers={ - 'X-Timestamp': ts.next().internal}) - codes = [409] * self.obj_ring.replicas - ts_iter = itertools.repeat(ts.next().internal) - with set_http_connect(*codes, timestamps=ts_iter): - resp = req.get_response(self.app) - self.assertEqual(resp.status_int, 409) - def test_put_x_timestamp_conflict(self): ts = (utils.Timestamp(t) for t in itertools.count(int(time.time()))) req = swob.Request.blank( @@ -624,88 +804,6 @@ class TestObjController(unittest.TestCase): resp = req.get_response(self.app) self.assertEquals(resp.status_int, 201) - def test_HEAD_simple(self): - req = swift.common.swob.Request.blank('/v1/a/c/o', method='HEAD') - with set_http_connect(200): - resp = req.get_response(self.app) - self.assertEquals(resp.status_int, 200) - - def test_HEAD_x_newest(self): - req = swift.common.swob.Request.blank('/v1/a/c/o', method='HEAD', - headers={'X-Newest': 'true'}) - with set_http_connect(200, 200, 200): - resp = req.get_response(self.app) - self.assertEquals(resp.status_int, 200) - - def test_HEAD_x_newest_different_timestamps(self): - req = swob.Request.blank('/v1/a/c/o', method='HEAD', - headers={'X-Newest': 'true'}) - ts = (utils.Timestamp(t) for t in itertools.count(int(time.time()))) - timestamps = [next(ts) for i in range(3)] - newest_timestamp = timestamps[-1] - random.shuffle(timestamps) - backend_response_headers = [{ - 'X-Backend-Timestamp': t.internal, - 'X-Timestamp': t.normal - } for t in timestamps] - with set_http_connect(200, 200, 200, - headers=backend_response_headers): - resp = req.get_response(self.app) - self.assertEqual(resp.status_int, 200) - self.assertEqual(resp.headers['x-timestamp'], newest_timestamp.normal) - - def test_HEAD_x_newest_with_two_vector_timestamps(self): - req = swob.Request.blank('/v1/a/c/o', method='HEAD', - headers={'X-Newest': 'true'}) - ts = (utils.Timestamp(time.time(), offset=offset) - for offset in itertools.count()) - timestamps = [next(ts) for i in range(3)] - newest_timestamp = timestamps[-1] - random.shuffle(timestamps) - backend_response_headers = [{ - 'X-Backend-Timestamp': t.internal, - 'X-Timestamp': t.normal - } for t in timestamps] - with set_http_connect(200, 200, 200, - headers=backend_response_headers): - resp = req.get_response(self.app) - self.assertEqual(resp.status_int, 200) - self.assertEqual(resp.headers['x-backend-timestamp'], - newest_timestamp.internal) - - def test_HEAD_x_newest_with_some_missing(self): - req = swob.Request.blank('/v1/a/c/o', method='HEAD', - headers={'X-Newest': 'true'}) - ts = (utils.Timestamp(t) for t in itertools.count(int(time.time()))) - request_count = self.app.request_node_count(self.obj_ring.replicas) - backend_response_headers = [{ - 'x-timestamp': next(ts).normal, - } for i in range(request_count)] - responses = [404] * (request_count - 1) - responses.append(200) - request_log = [] - - def capture_requests(ip, port, device, part, method, path, - headers=None, **kwargs): - req = { - 'ip': ip, - 'port': port, - 'device': device, - 'part': part, - 'method': method, - 'path': path, - 'headers': headers, - } - request_log.append(req) - with set_http_connect(*responses, - headers=backend_response_headers, - give_connect=capture_requests): - resp = req.get_response(self.app) - self.assertEqual(resp.status_int, 200) - for req in request_log: - self.assertEqual(req['method'], 'HEAD') - self.assertEqual(req['path'], '/a/c/o') - def test_PUT_log_info(self): req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT') req.headers['x-copy-from'] = 'some/where' @@ -731,18 +829,15 @@ class TestObjController(unittest.TestCase): self.assertEquals(req.environ.get('swift.log_info'), None) -@patch_policies([ - StoragePolicy(0, 'zero', True), - StoragePolicy(1, 'one'), - StoragePolicy(2, 'two'), -]) -class TestObjControllerLegacyCache(TestObjController): +@patch_policies(legacy_only=True) +class TestObjControllerLegacyCache(TestReplicatedObjController): """ This test pretends like memcache returned a stored value that should resemble whatever "old" format. It catches KeyErrors you'd get if your code was expecting some new format during a rolling upgrade. """ + # in this case policy_index is missing container_info = { 'read_acl': None, 'write_acl': None, @@ -750,6 +845,567 @@ class TestObjControllerLegacyCache(TestObjController): 'versions': None, } + def test_invalid_storage_policy_cache(self): + self.app.container_info['storage_policy'] = 1 + for method in ('GET', 'HEAD', 'POST', 'PUT', 'COPY'): + req = swob.Request.blank('/v1/a/c/o', method=method) + with set_http_connect(): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 503) + + +@patch_policies(with_ec_default=True) +class TestECObjController(BaseObjectControllerMixin, unittest.TestCase): + container_info = { + 'read_acl': None, + 'write_acl': None, + 'sync_key': None, + 'versions': None, + 'storage_policy': '0', + } + + controller_cls = obj.ECObjectController + + def test_determine_chunk_destinations(self): + class FakePutter(object): + def __init__(self, index): + self.node_index = index + + controller = self.controller_cls( + self.app, 'a', 'c', 'o') + + # create a dummy list of putters, check no handoffs + putters = [] + for index in range(0, 4): + putters.append(FakePutter(index)) + got = controller._determine_chunk_destinations(putters) + expected = {} + for i, p in enumerate(putters): + expected[p] = i + self.assertEquals(got, expected) + + # now lets make a handoff at the end + putters[3].node_index = None + got = controller._determine_chunk_destinations(putters) + self.assertEquals(got, expected) + putters[3].node_index = 3 + + # now lets make a handoff at the start + putters[0].node_index = None + got = controller._determine_chunk_destinations(putters) + self.assertEquals(got, expected) + putters[0].node_index = 0 + + # now lets make a handoff in the middle + putters[2].node_index = None + got = controller._determine_chunk_destinations(putters) + self.assertEquals(got, expected) + putters[2].node_index = 0 + + # now lets make all of them handoffs + for index in range(0, 4): + putters[index].node_index = None + got = controller._determine_chunk_destinations(putters) + self.assertEquals(got, expected) + + def test_GET_simple(self): + req = swift.common.swob.Request.blank('/v1/a/c/o') + get_resp = [200] * self.policy.ec_ndata + with set_http_connect(*get_resp): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 200) + + def test_GET_simple_x_newest(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', + headers={'X-Newest': 'true'}) + codes = [200] * self.replicas() + codes += [404] * self.obj_ring.max_more_nodes + with set_http_connect(*codes): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 200) + + def test_GET_error(self): + req = swift.common.swob.Request.blank('/v1/a/c/o') + get_resp = [503] + [200] * self.policy.ec_ndata + with set_http_connect(*get_resp): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 200) + + def test_GET_with_body(self): + req = swift.common.swob.Request.blank('/v1/a/c/o') + # turn a real body into fragments + segment_size = self.policy.ec_segment_size + real_body = ('asdf' * segment_size)[:-10] + # split it up into chunks + chunks = [real_body[x:x + segment_size] + for x in range(0, len(real_body), segment_size)] + fragment_payloads = [] + for chunk in chunks: + fragments = self.policy.pyeclib_driver.encode(chunk) + if not fragments: + break + fragment_payloads.append(fragments) + # sanity + sanity_body = '' + for fragment_payload in fragment_payloads: + sanity_body += self.policy.pyeclib_driver.decode( + fragment_payload) + self.assertEqual(len(real_body), len(sanity_body)) + self.assertEqual(real_body, sanity_body) + + node_fragments = zip(*fragment_payloads) + self.assertEqual(len(node_fragments), self.replicas()) # sanity + responses = [(200, ''.join(node_fragments[i]), {}) + for i in range(POLICIES.default.ec_ndata)] + status_codes, body_iter, headers = zip(*responses) + with set_http_connect(*status_codes, body_iter=body_iter, + headers=headers): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 200) + self.assertEqual(len(real_body), len(resp.body)) + self.assertEqual(real_body, resp.body) + + def test_PUT_simple(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT', + body='') + codes = [201] * self.replicas() + expect_headers = { + 'X-Obj-Metadata-Footer': 'yes', + 'X-Obj-Multiphase-Commit': 'yes' + } + with set_http_connect(*codes, expect_headers=expect_headers): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 201) + + def test_PUT_with_explicit_commit_status(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT', + body='') + codes = [(100, 100, 201)] * self.replicas() + expect_headers = { + 'X-Obj-Metadata-Footer': 'yes', + 'X-Obj-Multiphase-Commit': 'yes' + } + with set_http_connect(*codes, expect_headers=expect_headers): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 201) + + def test_PUT_error(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT', + body='') + codes = [503] * self.replicas() + expect_headers = { + 'X-Obj-Metadata-Footer': 'yes', + 'X-Obj-Multiphase-Commit': 'yes' + } + with set_http_connect(*codes, expect_headers=expect_headers): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 503) + + def test_PUT_mostly_success(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT', + body='') + codes = [201] * self.quorum() + codes += [503] * (self.replicas() - len(codes)) + random.shuffle(codes) + expect_headers = { + 'X-Obj-Metadata-Footer': 'yes', + 'X-Obj-Multiphase-Commit': 'yes' + } + with set_http_connect(*codes, expect_headers=expect_headers): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 201) + + def test_PUT_error_commit(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT', + body='') + codes = [(100, 503, Exception('not used'))] * self.replicas() + expect_headers = { + 'X-Obj-Metadata-Footer': 'yes', + 'X-Obj-Multiphase-Commit': 'yes' + } + with set_http_connect(*codes, expect_headers=expect_headers): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 503) + + def test_PUT_mostly_success_commit(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT', + body='') + codes = [201] * self.quorum() + codes += [(100, 503, Exception('not used'))] * ( + self.replicas() - len(codes)) + random.shuffle(codes) + expect_headers = { + 'X-Obj-Metadata-Footer': 'yes', + 'X-Obj-Multiphase-Commit': 'yes' + } + with set_http_connect(*codes, expect_headers=expect_headers): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 201) + + def test_PUT_mostly_error_commit(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT', + body='') + codes = [(100, 503, Exception('not used'))] * self.quorum() + codes += [201] * (self.replicas() - len(codes)) + random.shuffle(codes) + expect_headers = { + 'X-Obj-Metadata-Footer': 'yes', + 'X-Obj-Multiphase-Commit': 'yes' + } + with set_http_connect(*codes, expect_headers=expect_headers): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 503) + + def test_PUT_commit_timeout(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT', + body='') + codes = [201] * (self.replicas() - 1) + codes.append((100, Timeout(), Exception('not used'))) + expect_headers = { + 'X-Obj-Metadata-Footer': 'yes', + 'X-Obj-Multiphase-Commit': 'yes' + } + with set_http_connect(*codes, expect_headers=expect_headers): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 201) + + def test_PUT_commit_exception(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT', + body='') + codes = [201] * (self.replicas() - 1) + codes.append((100, Exception('kaboom!'), Exception('not used'))) + expect_headers = { + 'X-Obj-Metadata-Footer': 'yes', + 'X-Obj-Multiphase-Commit': 'yes' + } + with set_http_connect(*codes, expect_headers=expect_headers): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 201) + + def test_PUT_with_body(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT') + segment_size = self.policy.ec_segment_size + test_body = ('asdf' * segment_size)[:-10] + etag = md5(test_body).hexdigest() + size = len(test_body) + req.body = test_body + codes = [201] * self.replicas() + expect_headers = { + 'X-Obj-Metadata-Footer': 'yes', + 'X-Obj-Multiphase-Commit': 'yes' + } + + put_requests = defaultdict(lambda: {'boundary': None, 'chunks': []}) + + def capture_body(conn_id, chunk): + put_requests[conn_id]['chunks'].append(chunk) + + def capture_headers(ip, port, device, part, method, path, headers, + **kwargs): + conn_id = kwargs['connection_id'] + put_requests[conn_id]['boundary'] = headers[ + 'X-Backend-Obj-Multipart-Mime-Boundary'] + + with set_http_connect(*codes, expect_headers=expect_headers, + give_send=capture_body, + give_connect=capture_headers): + resp = req.get_response(self.app) + + self.assertEquals(resp.status_int, 201) + frag_archives = [] + for connection_id, info in put_requests.items(): + body = unchunk_body(''.join(info['chunks'])) + self.assertTrue(info['boundary'] is not None, + "didn't get boundary for conn %r" % ( + connection_id,)) + + # email.parser.FeedParser doesn't know how to take a multipart + # message and boundary together and parse it; it only knows how + # to take a string, parse the headers, and figure out the + # boundary on its own. + parser = email.parser.FeedParser() + parser.feed( + "Content-Type: multipart/nobodycares; boundary=%s\r\n\r\n" % + info['boundary']) + parser.feed(body) + message = parser.close() + + self.assertTrue(message.is_multipart()) # sanity check + mime_parts = message.get_payload() + self.assertEqual(len(mime_parts), 3) + obj_part, footer_part, commit_part = mime_parts + + # attach the body to frag_archives list + self.assertEqual(obj_part['X-Document'], 'object body') + frag_archives.append(obj_part.get_payload()) + + # validate some footer metadata + self.assertEqual(footer_part['X-Document'], 'object metadata') + footer_metadata = json.loads(footer_part.get_payload()) + self.assertTrue(footer_metadata) + expected = { + 'X-Object-Sysmeta-EC-Content-Length': str(size), + 'X-Backend-Container-Update-Override-Size': str(size), + 'X-Object-Sysmeta-EC-Etag': etag, + 'X-Backend-Container-Update-Override-Etag': etag, + 'X-Object-Sysmeta-EC-Segment-Size': str(segment_size), + } + for header, value in expected.items(): + self.assertEqual(footer_metadata[header], value) + + # sanity on commit message + self.assertEqual(commit_part['X-Document'], 'put commit') + + self.assertEqual(len(frag_archives), self.replicas()) + fragment_size = self.policy.fragment_size + node_payloads = [] + for fa in frag_archives: + payload = [fa[x:x + fragment_size] + for x in range(0, len(fa), fragment_size)] + node_payloads.append(payload) + fragment_payloads = zip(*node_payloads) + + expected_body = '' + for fragment_payload in fragment_payloads: + self.assertEqual(len(fragment_payload), self.replicas()) + if True: + fragment_payload = list(fragment_payload) + expected_body += self.policy.pyeclib_driver.decode( + fragment_payload) + + self.assertEqual(len(test_body), len(expected_body)) + self.assertEqual(test_body, expected_body) + + def test_PUT_old_obj_server(self): + req = swift.common.swob.Request.blank('/v1/a/c/o', method='PUT', + body='') + responses = [ + # one server will response 100-continue but not include the + # needful expect headers and the connection will be dropped + ((100, Exception('not used')), {}), + ] + [ + # and pleanty of successful responses too + (201, { + 'X-Obj-Metadata-Footer': 'yes', + 'X-Obj-Multiphase-Commit': 'yes', + }), + ] * self.replicas() + random.shuffle(responses) + if responses[-1][0] != 201: + # whoops, stupid random + responses = responses[1:] + [responses[0]] + codes, expect_headers = zip(*responses) + with set_http_connect(*codes, expect_headers=expect_headers): + resp = req.get_response(self.app) + self.assertEquals(resp.status_int, 201) + + def test_COPY_cross_policy_type_from_replicated(self): + self.app.per_container_info = { + 'c1': self.app.container_info.copy(), + 'c2': self.app.container_info.copy(), + } + # make c2 use replicated storage policy 1 + self.app.per_container_info['c2']['storage_policy'] = '1' + + # a put request with copy from source c2 + req = swift.common.swob.Request.blank('/v1/a/c1/o', method='PUT', + body='', headers={ + 'X-Copy-From': 'c2/o'}) + + # c2 get + codes = [200] * self.replicas(POLICIES[1]) + codes += [404] * POLICIES[1].object_ring.max_more_nodes + # c1 put + codes += [201] * self.replicas() + expect_headers = { + 'X-Obj-Metadata-Footer': 'yes', + 'X-Obj-Multiphase-Commit': 'yes' + } + with set_http_connect(*codes, expect_headers=expect_headers): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 201) + + def test_COPY_cross_policy_type_to_replicated(self): + self.app.per_container_info = { + 'c1': self.app.container_info.copy(), + 'c2': self.app.container_info.copy(), + } + # make c1 use replicated storage policy 1 + self.app.per_container_info['c1']['storage_policy'] = '1' + + # a put request with copy from source c2 + req = swift.common.swob.Request.blank('/v1/a/c1/o', method='PUT', + body='', headers={ + 'X-Copy-From': 'c2/o'}) + + # c2 get + codes = [200] * self.replicas() + codes += [404] * self.obj_ring.max_more_nodes + headers = { + 'X-Object-Sysmeta-Ec-Content-Length': 0, + } + # c1 put + codes += [201] * self.replicas(POLICIES[1]) + with set_http_connect(*codes, headers=headers): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 201) + + def test_COPY_cross_policy_type_unknown(self): + self.app.per_container_info = { + 'c1': self.app.container_info.copy(), + 'c2': self.app.container_info.copy(), + } + # make c1 use some made up storage policy index + self.app.per_container_info['c1']['storage_policy'] = '13' + + # a COPY request of c2 with destination in c1 + req = swift.common.swob.Request.blank('/v1/a/c2/o', method='COPY', + body='', headers={ + 'Destination': 'c1/o'}) + with set_http_connect(): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 503) + + def _make_ec_archive_bodies(self, test_body, policy=None): + policy = policy or self.policy + segment_size = policy.ec_segment_size + # split up the body into buffers + chunks = [test_body[x:x + segment_size] + for x in range(0, len(test_body), segment_size)] + # encode the buffers into fragment payloads + fragment_payloads = [] + for chunk in chunks: + fragments = self.policy.pyeclib_driver.encode(chunk) + if not fragments: + break + fragment_payloads.append(fragments) + + # join up the fragment payloads per node + ec_archive_bodies = [''.join(fragments) + for fragments in zip(*fragment_payloads)] + return ec_archive_bodies + + def test_GET_mismatched_fragment_archives(self): + segment_size = self.policy.ec_segment_size + test_data1 = ('test' * segment_size)[:-333] + # N.B. the object data *length* here is different + test_data2 = ('blah1' * segment_size)[:-333] + + etag1 = md5(test_data1).hexdigest() + etag2 = md5(test_data2).hexdigest() + + ec_archive_bodies1 = self._make_ec_archive_bodies(test_data1) + ec_archive_bodies2 = self._make_ec_archive_bodies(test_data2) + + headers1 = {'X-Object-Sysmeta-Ec-Etag': etag1} + # here we're going to *lie* and say the etag here matches + headers2 = {'X-Object-Sysmeta-Ec-Etag': etag1} + + responses1 = [(200, body, headers1) + for body in ec_archive_bodies1] + responses2 = [(200, body, headers2) + for body in ec_archive_bodies2] + + req = swob.Request.blank('/v1/a/c/o') + + # sanity check responses1 + responses = responses1[:self.policy.ec_ndata] + status_codes, body_iter, headers = zip(*responses) + with set_http_connect(*status_codes, body_iter=body_iter, + headers=headers): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 200) + self.assertEqual(md5(resp.body).hexdigest(), etag1) + + # sanity check responses2 + responses = responses2[:self.policy.ec_ndata] + status_codes, body_iter, headers = zip(*responses) + with set_http_connect(*status_codes, body_iter=body_iter, + headers=headers): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 200) + self.assertEqual(md5(resp.body).hexdigest(), etag2) + + # now mix the responses a bit + mix_index = random.randint(0, self.policy.ec_ndata - 1) + mixed_responses = responses1[:self.policy.ec_ndata] + mixed_responses[mix_index] = responses2[mix_index] + + status_codes, body_iter, headers = zip(*mixed_responses) + with set_http_connect(*status_codes, body_iter=body_iter, + headers=headers): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 200) + try: + resp.body + except ECDriverError: + pass + else: + self.fail('invalid ec fragment response body did not blow up!') + error_lines = self.logger.get_lines_for_level('error') + self.assertEqual(1, len(error_lines)) + msg = error_lines[0] + self.assertTrue('Error decoding fragments' in msg) + self.assertTrue('/a/c/o' in msg) + log_msg_args, log_msg_kwargs = self.logger.log_dict['error'][0] + self.assertEqual(log_msg_kwargs['exc_info'][0], ECDriverError) + + def test_GET_read_timeout(self): + segment_size = self.policy.ec_segment_size + test_data = ('test' * segment_size)[:-333] + etag = md5(test_data).hexdigest() + ec_archive_bodies = self._make_ec_archive_bodies(test_data) + headers = {'X-Object-Sysmeta-Ec-Etag': etag} + self.app.recoverable_node_timeout = 0.01 + responses = [(200, SlowBody(body, 0.1), headers) + for body in ec_archive_bodies] + + req = swob.Request.blank('/v1/a/c/o') + + status_codes, body_iter, headers = zip(*responses + [ + (404, '', {}) for i in range( + self.policy.object_ring.max_more_nodes)]) + with set_http_connect(*status_codes, body_iter=body_iter, + headers=headers): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 200) + # do this inside the fake http context manager, it'll try to + # resume but won't be able to give us all the right bytes + self.assertNotEqual(md5(resp.body).hexdigest(), etag) + error_lines = self.logger.get_lines_for_level('error') + self.assertEqual(self.replicas(), len(error_lines)) + nparity = self.policy.ec_nparity + for line in error_lines[:nparity]: + self.assertTrue('retrying' in line) + for line in error_lines[nparity:]: + self.assertTrue('ChunkReadTimeout (0.01s)' in line) + + def test_GET_read_timeout_resume(self): + segment_size = self.policy.ec_segment_size + test_data = ('test' * segment_size)[:-333] + etag = md5(test_data).hexdigest() + ec_archive_bodies = self._make_ec_archive_bodies(test_data) + headers = {'X-Object-Sysmeta-Ec-Etag': etag} + self.app.recoverable_node_timeout = 0.05 + # first one is slow + responses = [(200, SlowBody(ec_archive_bodies[0], 0.1), headers)] + # ... the rest are fine + responses += [(200, body, headers) + for body in ec_archive_bodies[1:]] + + req = swob.Request.blank('/v1/a/c/o') + + status_codes, body_iter, headers = zip( + *responses[:self.policy.ec_ndata + 1]) + with set_http_connect(*status_codes, body_iter=body_iter, + headers=headers): + resp = req.get_response(self.app) + self.assertEqual(resp.status_int, 200) + self.assertTrue(md5(resp.body).hexdigest(), etag) + error_lines = self.logger.get_lines_for_level('error') + self.assertEqual(1, len(error_lines)) + self.assertTrue('retrying' in error_lines[0]) + if __name__ == '__main__': unittest.main() diff --git a/test/unit/proxy/test_mem_server.py b/test/unit/proxy/test_mem_server.py index bc5b8794fc..f8bc2e3215 100644 --- a/test/unit/proxy/test_mem_server.py +++ b/test/unit/proxy/test_mem_server.py @@ -34,7 +34,22 @@ class TestProxyServer(test_server.TestProxyServer): class TestObjectController(test_server.TestObjectController): - pass + def test_PUT_no_etag_fallocate(self): + # mem server doesn't call fallocate(), believe it or not + pass + + # these tests all go looking in the filesystem + def test_policy_IO(self): + pass + + def test_PUT_ec(self): + pass + + def test_PUT_ec_multiple_segments(self): + pass + + def test_PUT_ec_fragment_archive_etag_mismatch(self): + pass class TestContainerController(test_server.TestContainerController): diff --git a/test/unit/proxy/test_server.py b/test/unit/proxy/test_server.py index 6d5bf0ed54..3319696eb7 100644 --- a/test/unit/proxy/test_server.py +++ b/test/unit/proxy/test_server.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Copyright (c) 2010-2012 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,10 +15,13 @@ # limitations under the License. import logging +import math import os +import pickle import sys import unittest -from contextlib import contextmanager, nested +from contextlib import closing, contextmanager, nested +from gzip import GzipFile from shutil import rmtree from StringIO import StringIO import gc @@ -25,7 +29,8 @@ import time from textwrap import dedent from urllib import quote from hashlib import md5 -from tempfile import mkdtemp +from pyeclib.ec_iface import ECDriverError +from tempfile import mkdtemp, NamedTemporaryFile import weakref import operator import functools @@ -35,30 +40,34 @@ import random import mock from eventlet import sleep, spawn, wsgi, listen, Timeout -from swift.common.utils import json +from swift.common.utils import hash_path, json, storage_directory, public from test.unit import ( connect_tcp, readuntil2crlfs, FakeLogger, fake_http_connect, FakeRing, FakeMemcache, debug_logger, patch_policies, write_fake_ring, mocked_http_conn) from swift.proxy import server as proxy_server +from swift.proxy.controllers.obj import ReplicatedObjectController from swift.account import server as account_server from swift.container import server as container_server from swift.obj import server as object_server from swift.common.middleware import proxy_logging from swift.common.middleware.acl import parse_acl, format_acl -from swift.common.exceptions import ChunkReadTimeout, DiskFileNotExist +from swift.common.exceptions import ChunkReadTimeout, DiskFileNotExist, \ + APIVersionError from swift.common import utils, constraints +from swift.common.ring import RingData from swift.common.utils import mkdirs, normalize_timestamp, NullLogger from swift.common.wsgi import monkey_patch_mimetools, loadapp from swift.proxy.controllers import base as proxy_base from swift.proxy.controllers.base import get_container_memcache_key, \ get_account_memcache_key, cors_validation import swift.proxy.controllers +import swift.proxy.controllers.obj from swift.common.swob import Request, Response, HTTPUnauthorized, \ - HTTPException + HTTPException, HTTPForbidden, HeaderKeyDict from swift.common import storage_policy -from swift.common.storage_policy import StoragePolicy, \ +from swift.common.storage_policy import StoragePolicy, ECStoragePolicy, \ StoragePolicyCollection, POLICIES from swift.common.request_helpers import get_sys_meta_prefix @@ -87,10 +96,9 @@ def do_setup(the_object_server): os.path.join(mkdtemp(), 'tmp_test_proxy_server_chunked') mkdirs(_testdir) rmtree(_testdir) - mkdirs(os.path.join(_testdir, 'sda1')) - mkdirs(os.path.join(_testdir, 'sda1', 'tmp')) - mkdirs(os.path.join(_testdir, 'sdb1')) - mkdirs(os.path.join(_testdir, 'sdb1', 'tmp')) + for drive in ('sda1', 'sdb1', 'sdc1', 'sdd1', 'sde1', + 'sdf1', 'sdg1', 'sdh1', 'sdi1'): + mkdirs(os.path.join(_testdir, drive, 'tmp')) conf = {'devices': _testdir, 'swift_dir': _testdir, 'mount_check': 'false', 'allowed_headers': 'content-encoding, x-object-manifest, content-disposition, foo', @@ -102,8 +110,10 @@ def do_setup(the_object_server): con2lis = listen(('localhost', 0)) obj1lis = listen(('localhost', 0)) obj2lis = listen(('localhost', 0)) + obj3lis = listen(('localhost', 0)) + objsocks = [obj1lis, obj2lis, obj3lis] _test_sockets = \ - (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, obj2lis) + (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, obj2lis, obj3lis) account_ring_path = os.path.join(_testdir, 'account.ring.gz') account_devs = [ {'port': acc1lis.getsockname()[1]}, @@ -119,27 +129,45 @@ def do_setup(the_object_server): storage_policy._POLICIES = StoragePolicyCollection([ StoragePolicy(0, 'zero', True), StoragePolicy(1, 'one', False), - StoragePolicy(2, 'two', False)]) + StoragePolicy(2, 'two', False), + ECStoragePolicy(3, 'ec', ec_type='jerasure_rs_vand', + ec_ndata=2, ec_nparity=1, ec_segment_size=4096)]) obj_rings = { 0: ('sda1', 'sdb1'), 1: ('sdc1', 'sdd1'), 2: ('sde1', 'sdf1'), + # sdg1, sdh1, sdi1 taken by policy 3 (see below) } for policy_index, devices in obj_rings.items(): policy = POLICIES[policy_index] - dev1, dev2 = devices obj_ring_path = os.path.join(_testdir, policy.ring_name + '.ring.gz') obj_devs = [ - {'port': obj1lis.getsockname()[1], 'device': dev1}, - {'port': obj2lis.getsockname()[1], 'device': dev2}, - ] + {'port': objsock.getsockname()[1], 'device': dev} + for objsock, dev in zip(objsocks, devices)] write_fake_ring(obj_ring_path, *obj_devs) + + # write_fake_ring can't handle a 3-element ring, and the EC policy needs + # at least 3 devs to work with, so we do it manually + devs = [{'id': 0, 'zone': 0, 'device': 'sdg1', 'ip': '127.0.0.1', + 'port': obj1lis.getsockname()[1]}, + {'id': 1, 'zone': 0, 'device': 'sdh1', 'ip': '127.0.0.1', + 'port': obj2lis.getsockname()[1]}, + {'id': 2, 'zone': 0, 'device': 'sdi1', 'ip': '127.0.0.1', + 'port': obj3lis.getsockname()[1]}] + pol3_replica2part2dev_id = [[0, 1, 2, 0], + [1, 2, 0, 1], + [2, 0, 1, 2]] + obj3_ring_path = os.path.join(_testdir, POLICIES[3].ring_name + '.ring.gz') + part_shift = 30 + with closing(GzipFile(obj3_ring_path, 'wb')) as fh: + pickle.dump(RingData(pol3_replica2part2dev_id, devs, part_shift), fh) + prosrv = proxy_server.Application(conf, FakeMemcacheReturnsNone(), logger=debug_logger('proxy')) for policy in POLICIES: # make sure all the rings are loaded prosrv.get_object_ring(policy.idx) - # don't loose this one! + # don't lose this one! _test_POLICIES = storage_policy._POLICIES acc1srv = account_server.AccountController( conf, logger=debug_logger('acct1')) @@ -153,8 +181,10 @@ def do_setup(the_object_server): conf, logger=debug_logger('obj1')) obj2srv = the_object_server.ObjectController( conf, logger=debug_logger('obj2')) + obj3srv = the_object_server.ObjectController( + conf, logger=debug_logger('obj3')) _test_servers = \ - (prosrv, acc1srv, acc2srv, con1srv, con2srv, obj1srv, obj2srv) + (prosrv, acc1srv, acc2srv, con1srv, con2srv, obj1srv, obj2srv, obj3srv) nl = NullLogger() logging_prosv = proxy_logging.ProxyLoggingMiddleware(prosrv, conf, logger=prosrv.logger) @@ -165,8 +195,9 @@ def do_setup(the_object_server): con2spa = spawn(wsgi.server, con2lis, con2srv, nl) obj1spa = spawn(wsgi.server, obj1lis, obj1srv, nl) obj2spa = spawn(wsgi.server, obj2lis, obj2srv, nl) + obj3spa = spawn(wsgi.server, obj3lis, obj3srv, nl) _test_coros = \ - (prospa, acc1spa, acc2spa, con1spa, con2spa, obj1spa, obj2spa) + (prospa, acc1spa, acc2spa, con1spa, con2spa, obj1spa, obj2spa, obj3spa) # Create account ts = normalize_timestamp(time.time()) partition, nodes = prosrv.account_ring.get_nodes('a') @@ -280,6 +311,15 @@ def sortHeaderNames(headerNames): return ', '.join(headers) +def parse_headers_string(headers_str): + headers_dict = HeaderKeyDict() + for line in headers_str.split('\r\n'): + if ': ' in line: + header, value = line.split(': ', 1) + headers_dict[header] = value + return headers_dict + + def node_error_count(proxy_app, ring_node): # Reach into the proxy's internals to get the error count for a # particular node @@ -665,7 +705,7 @@ class TestProxyServer(unittest.TestCase): class MyApp(proxy_server.Application): def get_controller(self, path): - raise Exception('this shouldnt be caught') + raise Exception('this shouldn\'t be caught') app = MyApp(None, FakeMemcache(), account_ring=FakeRing(), container_ring=FakeRing()) @@ -842,16 +882,18 @@ class TestProxyServer(unittest.TestCase): self.assertTrue(app.expose_info) self.assertTrue(isinstance(app.disallowed_sections, list)) - self.assertEqual(0, len(app.disallowed_sections)) + self.assertEqual(1, len(app.disallowed_sections)) + self.assertEqual(['swift.valid_api_versions'], + app.disallowed_sections) self.assertTrue(app.admin_key is None) def test_get_info_controller(self): - path = '/info' + req = Request.blank('/info') app = proxy_server.Application({}, FakeMemcache(), account_ring=FakeRing(), container_ring=FakeRing()) - controller, path_parts = app.get_controller(path) + controller, path_parts = app.get_controller(req) self.assertTrue('version' in path_parts) self.assertTrue(path_parts['version'] is None) @@ -861,6 +903,117 @@ class TestProxyServer(unittest.TestCase): self.assertEqual(controller.__name__, 'InfoController') + def test_error_limit_methods(self): + logger = debug_logger('test') + app = proxy_server.Application({}, FakeMemcache(), + account_ring=FakeRing(), + container_ring=FakeRing(), + logger=logger) + node = app.container_ring.get_part_nodes(0)[0] + # error occurred + app.error_occurred(node, 'test msg') + self.assertTrue('test msg' in + logger.get_lines_for_level('error')[-1]) + self.assertEqual(1, node_error_count(app, node)) + + # exception occurred + try: + raise Exception('kaboom1!') + except Exception as e1: + app.exception_occurred(node, 'test1', 'test1 msg') + line = logger.get_lines_for_level('error')[-1] + self.assertTrue('test1 server' in line) + self.assertTrue('test1 msg' in line) + log_args, log_kwargs = logger.log_dict['error'][-1] + self.assertTrue(log_kwargs['exc_info']) + self.assertEqual(log_kwargs['exc_info'][1], e1) + self.assertEqual(2, node_error_count(app, node)) + + # warning exception occurred + try: + raise Exception('kaboom2!') + except Exception as e2: + app.exception_occurred(node, 'test2', 'test2 msg', + level=logging.WARNING) + line = logger.get_lines_for_level('warning')[-1] + self.assertTrue('test2 server' in line) + self.assertTrue('test2 msg' in line) + log_args, log_kwargs = logger.log_dict['warning'][-1] + self.assertTrue(log_kwargs['exc_info']) + self.assertEqual(log_kwargs['exc_info'][1], e2) + self.assertEqual(3, node_error_count(app, node)) + + # custom exception occurred + try: + raise Exception('kaboom3!') + except Exception as e3: + e3_info = sys.exc_info() + try: + raise Exception('kaboom4!') + except Exception: + pass + app.exception_occurred(node, 'test3', 'test3 msg', + level=logging.WARNING, exc_info=e3_info) + line = logger.get_lines_for_level('warning')[-1] + self.assertTrue('test3 server' in line) + self.assertTrue('test3 msg' in line) + log_args, log_kwargs = logger.log_dict['warning'][-1] + self.assertTrue(log_kwargs['exc_info']) + self.assertEqual(log_kwargs['exc_info'][1], e3) + self.assertEqual(4, node_error_count(app, node)) + + def test_valid_api_version(self): + app = proxy_server.Application({}, FakeMemcache(), + account_ring=FakeRing(), + container_ring=FakeRing()) + + # The version string is only checked for account, container and object + # requests; the raised APIVersionError returns a 404 to the client + for path in [ + '/v2/a', + '/v2/a/c', + '/v2/a/c/o']: + req = Request.blank(path) + self.assertRaises(APIVersionError, app.get_controller, req) + + # Default valid API versions are ok + for path in [ + '/v1/a', + '/v1/a/c', + '/v1/a/c/o', + '/v1.0/a', + '/v1.0/a/c', + '/v1.0/a/c/o']: + req = Request.blank(path) + controller, path_parts = app.get_controller(req) + self.assertTrue(controller is not None) + + # Ensure settings valid API version constraint works + for version in ["42", 42]: + try: + with NamedTemporaryFile() as f: + f.write('[swift-constraints]\n') + f.write('valid_api_versions = %s\n' % version) + f.flush() + with mock.patch.object(utils, 'SWIFT_CONF_FILE', f.name): + constraints.reload_constraints() + + req = Request.blank('/%s/a' % version) + controller, _ = app.get_controller(req) + self.assertTrue(controller is not None) + + # In this case v1 is invalid + req = Request.blank('/v1/a') + self.assertRaises(APIVersionError, app.get_controller, req) + finally: + constraints.reload_constraints() + + # Check that the valid_api_versions is not exposed by default + req = Request.blank('/info') + controller, path_parts = app.get_controller(req) + self.assertTrue('swift.valid_api_versions' in + path_parts.get('disallowed_sections')) + @patch_policies([ StoragePolicy(0, 'zero', is_default=True), @@ -981,6 +1134,23 @@ class TestObjectController(unittest.TestCase): for policy in POLICIES: policy.object_ring = FakeRing(base_port=3000) + def put_container(self, policy_name, container_name): + # Note: only works if called with unpatched policies + prolis = _test_sockets[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('PUT /v1/a/%s HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Content-Length: 0\r\n' + 'X-Storage-Token: t\r\n' + 'X-Storage-Policy: %s\r\n' + '\r\n' % (container_name, policy_name)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 2' + self.assertEqual(headers[:len(exp)], exp) + def assert_status_map(self, method, statuses, expected, raise_exc=False): with save_globals(): kwargs = {} @@ -993,7 +1163,10 @@ class TestObjectController(unittest.TestCase): headers={'Content-Length': '0', 'Content-Type': 'text/plain'}) self.app.update_request(req) - res = method(req) + try: + res = method(req) + except HTTPException as res: + pass self.assertEquals(res.status_int, expected) # repeat test @@ -1003,25 +1176,22 @@ class TestObjectController(unittest.TestCase): headers={'Content-Length': '0', 'Content-Type': 'text/plain'}) self.app.update_request(req) - res = method(req) + try: + res = method(req) + except HTTPException as res: + pass self.assertEquals(res.status_int, expected) @unpatch_policies def test_policy_IO(self): - if hasattr(_test_servers[-1], '_filesystem'): - # ironically, the _filesystem attribute on the object server means - # the in-memory diskfile is in use, so this test does not apply - return - - def check_file(policy_idx, cont, devs, check_val): - partition, nodes = prosrv.get_object_ring(policy_idx).get_nodes( - 'a', cont, 'o') + def check_file(policy, cont, devs, check_val): + partition, nodes = policy.object_ring.get_nodes('a', cont, 'o') conf = {'devices': _testdir, 'mount_check': 'false'} df_mgr = diskfile.DiskFileManager(conf, FakeLogger()) for dev in devs: file = df_mgr.get_diskfile(dev, partition, 'a', cont, 'o', - policy_idx=policy_idx) + policy=policy) if check_val is True: file.open() @@ -1052,8 +1222,8 @@ class TestObjectController(unittest.TestCase): self.assertEqual(res.status_int, 200) self.assertEqual(res.body, obj) - check_file(0, 'c', ['sda1', 'sdb1'], True) - check_file(0, 'c', ['sdc1', 'sdd1', 'sde1', 'sdf1'], False) + check_file(POLICIES[0], 'c', ['sda1', 'sdb1'], True) + check_file(POLICIES[0], 'c', ['sdc1', 'sdd1', 'sde1', 'sdf1'], False) # check policy 1: put file on c1, read it back, check loc on disk sock = connect_tcp(('localhost', prolis.getsockname()[1])) @@ -1078,8 +1248,8 @@ class TestObjectController(unittest.TestCase): self.assertEqual(res.status_int, 200) self.assertEqual(res.body, obj) - check_file(1, 'c1', ['sdc1', 'sdd1'], True) - check_file(1, 'c1', ['sda1', 'sdb1', 'sde1', 'sdf1'], False) + check_file(POLICIES[1], 'c1', ['sdc1', 'sdd1'], True) + check_file(POLICIES[1], 'c1', ['sda1', 'sdb1', 'sde1', 'sdf1'], False) # check policy 2: put file on c2, read it back, check loc on disk sock = connect_tcp(('localhost', prolis.getsockname()[1])) @@ -1104,8 +1274,8 @@ class TestObjectController(unittest.TestCase): self.assertEqual(res.status_int, 200) self.assertEqual(res.body, obj) - check_file(2, 'c2', ['sde1', 'sdf1'], True) - check_file(2, 'c2', ['sda1', 'sdb1', 'sdc1', 'sdd1'], False) + check_file(POLICIES[2], 'c2', ['sde1', 'sdf1'], True) + check_file(POLICIES[2], 'c2', ['sda1', 'sdb1', 'sdc1', 'sdd1'], False) @unpatch_policies def test_policy_IO_override(self): @@ -1140,7 +1310,7 @@ class TestObjectController(unittest.TestCase): conf = {'devices': _testdir, 'mount_check': 'false'} df_mgr = diskfile.DiskFileManager(conf, FakeLogger()) df = df_mgr.get_diskfile(node['device'], partition, 'a', - 'c1', 'wrong-o', policy_idx=2) + 'c1', 'wrong-o', policy=POLICIES[2]) with df.open(): contents = ''.join(df.reader()) self.assertEqual(contents, "hello") @@ -1172,12 +1342,11 @@ class TestObjectController(unittest.TestCase): self.assertEqual(res.status_int, 204) df = df_mgr.get_diskfile(node['device'], partition, 'a', - 'c1', 'wrong-o', policy_idx=2) + 'c1', 'wrong-o', policy=POLICIES[2]) try: df.open() except DiskFileNotExist as e: - now = time.time() - self.assert_(now - 1 < float(e.timestamp) < now + 1) + self.assert_(float(e.timestamp) > 0) else: self.fail('did not raise DiskFileNotExist') @@ -1209,6 +1378,619 @@ class TestObjectController(unittest.TestCase): self.assertEqual(res.status_int, 200) self.assertEqual(res.body, obj) + @unpatch_policies + def test_PUT_ec(self): + policy = POLICIES[3] + self.put_container("ec", "ec-con") + + obj = 'abCD' * 10 # small, so we don't get multiple EC stripes + prolis = _test_sockets[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('PUT /v1/a/ec-con/o1 HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Etag: "%s"\r\n' + 'Content-Length: %d\r\n' + 'X-Storage-Token: t\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n%s' % (md5(obj).hexdigest(), len(obj), obj)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 201' + self.assertEqual(headers[:len(exp)], exp) + + ecd = policy.pyeclib_driver + expected_pieces = set(ecd.encode(obj)) + + # go to disk to make sure it's there and all erasure-coded + partition, nodes = policy.object_ring.get_nodes('a', 'ec-con', 'o1') + conf = {'devices': _testdir, 'mount_check': 'false'} + df_mgr = diskfile.DiskFileManager(conf, FakeLogger()) + + got_pieces = set() + got_indices = set() + got_durable = [] + for node_index, node in enumerate(nodes): + df = df_mgr.get_diskfile(node['device'], partition, + 'a', 'ec-con', 'o1', + policy=policy) + with df.open(): + meta = df.get_metadata() + contents = ''.join(df.reader()) + got_pieces.add(contents) + + # check presence for a .durable file for the timestamp + durable_file = os.path.join( + _testdir, node['device'], storage_directory( + diskfile.get_data_dir(policy), + partition, hash_path('a', 'ec-con', 'o1')), + utils.Timestamp(df.timestamp).internal + '.durable') + + if os.path.isfile(durable_file): + got_durable.append(True) + + lmeta = dict((k.lower(), v) for k, v in meta.items()) + got_indices.add( + lmeta['x-object-sysmeta-ec-frag-index']) + + self.assertEqual( + lmeta['x-object-sysmeta-ec-etag'], + md5(obj).hexdigest()) + self.assertEqual( + lmeta['x-object-sysmeta-ec-content-length'], + str(len(obj))) + self.assertEqual( + lmeta['x-object-sysmeta-ec-segment-size'], + '4096') + self.assertEqual( + lmeta['x-object-sysmeta-ec-scheme'], + 'jerasure_rs_vand 2+1') + self.assertEqual( + lmeta['etag'], + md5(contents).hexdigest()) + + self.assertEqual(expected_pieces, got_pieces) + self.assertEqual(set(('0', '1', '2')), got_indices) + + # verify at least 2 puts made it all the way to the end of 2nd + # phase, ie at least 2 .durable statuses were written + num_durable_puts = sum(d is True for d in got_durable) + self.assertTrue(num_durable_puts >= 2) + + @unpatch_policies + def test_PUT_ec_multiple_segments(self): + ec_policy = POLICIES[3] + self.put_container("ec", "ec-con") + + pyeclib_header_size = len(ec_policy.pyeclib_driver.encode("")[0]) + segment_size = ec_policy.ec_segment_size + + # Big enough to have multiple segments. Also a multiple of the + # segment size to get coverage of that path too. + obj = 'ABC' * segment_size + + prolis = _test_sockets[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('PUT /v1/a/ec-con/o2 HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Content-Length: %d\r\n' + 'X-Storage-Token: t\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n%s' % (len(obj), obj)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 201' + self.assertEqual(headers[:len(exp)], exp) + + # it's a 2+1 erasure code, so each fragment archive should be half + # the length of the object, plus three inline pyeclib metadata + # things (one per segment) + expected_length = (len(obj) / 2 + pyeclib_header_size * 3) + + partition, nodes = ec_policy.object_ring.get_nodes( + 'a', 'ec-con', 'o2') + + conf = {'devices': _testdir, 'mount_check': 'false'} + df_mgr = diskfile.DiskFileManager(conf, FakeLogger()) + + got_durable = [] + fragment_archives = [] + for node in nodes: + df = df_mgr.get_diskfile( + node['device'], partition, 'a', + 'ec-con', 'o2', policy=ec_policy) + with df.open(): + contents = ''.join(df.reader()) + fragment_archives.append(contents) + self.assertEqual(len(contents), expected_length) + + # check presence for a .durable file for the timestamp + durable_file = os.path.join( + _testdir, node['device'], storage_directory( + diskfile.get_data_dir(ec_policy), + partition, hash_path('a', 'ec-con', 'o2')), + utils.Timestamp(df.timestamp).internal + '.durable') + + if os.path.isfile(durable_file): + got_durable.append(True) + + # Verify that we can decode each individual fragment and that they + # are all the correct size + fragment_size = ec_policy.fragment_size + nfragments = int( + math.ceil(float(len(fragment_archives[0])) / fragment_size)) + + for fragment_index in range(nfragments): + fragment_start = fragment_index * fragment_size + fragment_end = (fragment_index + 1) * fragment_size + + try: + frags = [fa[fragment_start:fragment_end] + for fa in fragment_archives] + seg = ec_policy.pyeclib_driver.decode(frags) + except ECDriverError: + self.fail("Failed to decode fragments %d; this probably " + "means the fragments are not the sizes they " + "should be" % fragment_index) + + segment_start = fragment_index * segment_size + segment_end = (fragment_index + 1) * segment_size + + self.assertEqual(seg, obj[segment_start:segment_end]) + + # verify at least 2 puts made it all the way to the end of 2nd + # phase, ie at least 2 .durable statuses were written + num_durable_puts = sum(d is True for d in got_durable) + self.assertTrue(num_durable_puts >= 2) + + @unpatch_policies + def test_PUT_ec_object_etag_mismatch(self): + self.put_container("ec", "ec-con") + + obj = '90:6A:02:60:B1:08-96da3e706025537fc42464916427727e' + prolis = _test_sockets[0] + prosrv = _test_servers[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('PUT /v1/a/ec-con/o3 HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Etag: %s\r\n' + 'Content-Length: %d\r\n' + 'X-Storage-Token: t\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n%s' % (md5('something else').hexdigest(), len(obj), obj)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 422' + self.assertEqual(headers[:len(exp)], exp) + + # nothing should have made it to disk on the object servers + partition, nodes = prosrv.get_object_ring(3).get_nodes( + 'a', 'ec-con', 'o3') + conf = {'devices': _testdir, 'mount_check': 'false'} + + partition, nodes = prosrv.get_object_ring(3).get_nodes( + 'a', 'ec-con', 'o3') + conf = {'devices': _testdir, 'mount_check': 'false'} + df_mgr = diskfile.DiskFileManager(conf, FakeLogger()) + + for node in nodes: + df = df_mgr.get_diskfile(node['device'], partition, + 'a', 'ec-con', 'o3', policy=POLICIES[3]) + self.assertRaises(DiskFileNotExist, df.open) + + @unpatch_policies + def test_PUT_ec_fragment_archive_etag_mismatch(self): + self.put_container("ec", "ec-con") + + # Cause a hash mismatch by feeding one particular MD5 hasher some + # extra data. The goal here is to get exactly one of the hashers in + # an object server. + countdown = [1] + + def busted_md5_constructor(initial_str=""): + hasher = md5(initial_str) + if countdown[0] == 0: + hasher.update('wrong') + countdown[0] -= 1 + return hasher + + obj = 'uvarovite-esurience-cerated-symphysic' + prolis = _test_sockets[0] + prosrv = _test_servers[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + with mock.patch('swift.obj.server.md5', busted_md5_constructor): + fd = sock.makefile() + fd.write('PUT /v1/a/ec-con/pimento HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Etag: %s\r\n' + 'Content-Length: %d\r\n' + 'X-Storage-Token: t\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n%s' % (md5(obj).hexdigest(), len(obj), obj)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 503' # no quorum + self.assertEqual(headers[:len(exp)], exp) + + # 2/3 of the fragment archives should have landed on disk + partition, nodes = prosrv.get_object_ring(3).get_nodes( + 'a', 'ec-con', 'pimento') + conf = {'devices': _testdir, 'mount_check': 'false'} + + partition, nodes = prosrv.get_object_ring(3).get_nodes( + 'a', 'ec-con', 'pimento') + conf = {'devices': _testdir, 'mount_check': 'false'} + + df_mgr = diskfile.DiskFileManager(conf, FakeLogger()) + + found = 0 + for node in nodes: + df = df_mgr.get_diskfile(node['device'], partition, + 'a', 'ec-con', 'pimento', + policy=POLICIES[3]) + try: + df.open() + found += 1 + except DiskFileNotExist: + pass + self.assertEqual(found, 2) + + @unpatch_policies + def test_PUT_ec_if_none_match(self): + self.put_container("ec", "ec-con") + + obj = 'ananepionic-lepidophyllous-ropewalker-neglectful' + prolis = _test_sockets[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('PUT /v1/a/ec-con/inm HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Etag: "%s"\r\n' + 'Content-Length: %d\r\n' + 'X-Storage-Token: t\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n%s' % (md5(obj).hexdigest(), len(obj), obj)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 201' + self.assertEqual(headers[:len(exp)], exp) + + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('PUT /v1/a/ec-con/inm HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'If-None-Match: *\r\n' + 'Etag: "%s"\r\n' + 'Content-Length: %d\r\n' + 'X-Storage-Token: t\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n%s' % (md5(obj).hexdigest(), len(obj), obj)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 412' + self.assertEqual(headers[:len(exp)], exp) + + @unpatch_policies + def test_GET_ec(self): + self.put_container("ec", "ec-con") + + obj = '0123456' * 11 * 17 + + prolis = _test_sockets[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('PUT /v1/a/ec-con/go-get-it HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Content-Length: %d\r\n' + 'X-Storage-Token: t\r\n' + 'X-Object-Meta-Color: chartreuse\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n%s' % (len(obj), obj)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 201' + self.assertEqual(headers[:len(exp)], exp) + + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('GET /v1/a/ec-con/go-get-it HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'X-Storage-Token: t\r\n' + '\r\n') + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 200' + self.assertEqual(headers[:len(exp)], exp) + + headers = parse_headers_string(headers) + self.assertEqual(str(len(obj)), headers['Content-Length']) + self.assertEqual(md5(obj).hexdigest(), headers['Etag']) + self.assertEqual('chartreuse', headers['X-Object-Meta-Color']) + + gotten_obj = '' + while True: + buf = fd.read(64) + if not buf: + break + gotten_obj += buf + self.assertEqual(gotten_obj, obj) + + @unpatch_policies + def test_conditional_GET_ec(self): + self.put_container("ec", "ec-con") + + obj = 'this object has an etag and is otherwise unimportant' + etag = md5(obj).hexdigest() + not_etag = md5(obj + "blahblah").hexdigest() + + prolis = _test_sockets[0] + prosrv = _test_servers[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('PUT /v1/a/ec-con/conditionals HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Content-Length: %d\r\n' + 'X-Storage-Token: t\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n%s' % (len(obj), obj)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 201' + self.assertEqual(headers[:len(exp)], exp) + + for verb in ('GET', 'HEAD'): + # If-Match + req = Request.blank( + '/v1/a/ec-con/conditionals', + environ={'REQUEST_METHOD': verb}, + headers={'If-Match': etag}) + resp = req.get_response(prosrv) + self.assertEqual(resp.status_int, 200) + + req = Request.blank( + '/v1/a/ec-con/conditionals', + environ={'REQUEST_METHOD': verb}, + headers={'If-Match': not_etag}) + resp = req.get_response(prosrv) + self.assertEqual(resp.status_int, 412) + + req = Request.blank( + '/v1/a/ec-con/conditionals', + environ={'REQUEST_METHOD': verb}, + headers={'If-Match': "*"}) + resp = req.get_response(prosrv) + self.assertEqual(resp.status_int, 200) + + # If-None-Match + req = Request.blank( + '/v1/a/ec-con/conditionals', + environ={'REQUEST_METHOD': verb}, + headers={'If-None-Match': etag}) + resp = req.get_response(prosrv) + self.assertEqual(resp.status_int, 304) + + req = Request.blank( + '/v1/a/ec-con/conditionals', + environ={'REQUEST_METHOD': verb}, + headers={'If-None-Match': not_etag}) + resp = req.get_response(prosrv) + self.assertEqual(resp.status_int, 200) + + req = Request.blank( + '/v1/a/ec-con/conditionals', + environ={'REQUEST_METHOD': verb}, + headers={'If-None-Match': "*"}) + resp = req.get_response(prosrv) + self.assertEqual(resp.status_int, 304) + + @unpatch_policies + def test_GET_ec_big(self): + self.put_container("ec", "ec-con") + + # our EC segment size is 4 KiB, so this is multiple (3) segments; + # we'll verify that with a sanity check + obj = 'a moose once bit my sister' * 400 + self.assertTrue( + len(obj) > POLICIES.get_by_name("ec").ec_segment_size * 2, + "object is too small for proper testing") + + prolis = _test_sockets[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('PUT /v1/a/ec-con/big-obj-get HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Content-Length: %d\r\n' + 'X-Storage-Token: t\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n%s' % (len(obj), obj)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 201' + self.assertEqual(headers[:len(exp)], exp) + + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('GET /v1/a/ec-con/big-obj-get HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'X-Storage-Token: t\r\n' + '\r\n') + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 200' + self.assertEqual(headers[:len(exp)], exp) + + headers = parse_headers_string(headers) + self.assertEqual(str(len(obj)), headers['Content-Length']) + self.assertEqual(md5(obj).hexdigest(), headers['Etag']) + + gotten_obj = '' + while True: + buf = fd.read(64) + if not buf: + break + gotten_obj += buf + # This may look like a redundant test, but when things fail, this + # has a useful failure message while the subsequent one spews piles + # of garbage and demolishes your terminal's scrollback buffer. + self.assertEqual(len(gotten_obj), len(obj)) + self.assertEqual(gotten_obj, obj) + + @unpatch_policies + def test_GET_ec_failure_handling(self): + self.put_container("ec", "ec-con") + + obj = 'look at this object; it is simply amazing ' * 500 + prolis = _test_sockets[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('PUT /v1/a/ec-con/crash-test-dummy HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Content-Length: %d\r\n' + 'X-Storage-Token: t\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n%s' % (len(obj), obj)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 201' + self.assertEqual(headers[:len(exp)], exp) + + def explodey_iter(inner_iter): + yield next(inner_iter) + raise Exception("doom ba doom") + + real_ec_app_iter = swift.proxy.controllers.obj.ECAppIter + + def explodey_ec_app_iter(path, policy, iterators, *a, **kw): + # Each thing in `iterators` here is a document-parts iterator, + # and we want to fail after getting a little into each part. + # + # That way, we ensure we've started streaming the response to + # the client when things go wrong. + return real_ec_app_iter( + path, policy, + [explodey_iter(i) for i in iterators], + *a, **kw) + + with mock.patch("swift.proxy.controllers.obj.ECAppIter", + explodey_ec_app_iter): + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('GET /v1/a/ec-con/crash-test-dummy HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'X-Storage-Token: t\r\n' + '\r\n') + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 200' + self.assertEqual(headers[:len(exp)], exp) + + headers = parse_headers_string(headers) + self.assertEqual(str(len(obj)), headers['Content-Length']) + self.assertEqual(md5(obj).hexdigest(), headers['Etag']) + + gotten_obj = '' + try: + with Timeout(300): # don't hang the testrun when this fails + while True: + buf = fd.read(64) + if not buf: + break + gotten_obj += buf + except Timeout: + self.fail("GET hung when connection failed") + + # Ensure we failed partway through, otherwise the mocks could + # get out of date without anyone noticing + self.assertTrue(0 < len(gotten_obj) < len(obj)) + + @unpatch_policies + def test_HEAD_ec(self): + self.put_container("ec", "ec-con") + + obj = '0123456' * 11 * 17 + + prolis = _test_sockets[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('PUT /v1/a/ec-con/go-head-it HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Content-Length: %d\r\n' + 'X-Storage-Token: t\r\n' + 'X-Object-Meta-Color: chartreuse\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n%s' % (len(obj), obj)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 201' + self.assertEqual(headers[:len(exp)], exp) + + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('HEAD /v1/a/ec-con/go-head-it HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'X-Storage-Token: t\r\n' + '\r\n') + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 200' + self.assertEqual(headers[:len(exp)], exp) + + headers = parse_headers_string(headers) + self.assertEqual(str(len(obj)), headers['Content-Length']) + self.assertEqual(md5(obj).hexdigest(), headers['Etag']) + self.assertEqual('chartreuse', headers['X-Object-Meta-Color']) + + @unpatch_policies + def test_GET_ec_404(self): + self.put_container("ec", "ec-con") + + prolis = _test_sockets[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('GET /v1/a/ec-con/yes-we-have-no-bananas HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'X-Storage-Token: t\r\n' + '\r\n') + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 404' + self.assertEqual(headers[:len(exp)], exp) + + @unpatch_policies + def test_HEAD_ec_404(self): + self.put_container("ec", "ec-con") + + prolis = _test_sockets[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('HEAD /v1/a/ec-con/yes-we-have-no-bananas HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'X-Storage-Token: t\r\n' + '\r\n') + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 404' + self.assertEqual(headers[:len(exp)], exp) + def test_PUT_expect_header_zero_content_length(self): test_errors = [] @@ -1220,8 +2002,8 @@ class TestObjectController(unittest.TestCase): 'server!') with save_globals(): - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') # The (201, Exception('test')) tuples in there have the effect of # changing the status of the initial expect response. The default # expect response from FakeConn for 201 is 100. @@ -1256,8 +2038,8 @@ class TestObjectController(unittest.TestCase): 'non-zero byte PUT!') with save_globals(): - controller = \ - proxy_server.ObjectController(self.app, 'a', 'c', 'o.jpg') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o.jpg') # the (100, 201) tuples in there are just being extra explicit # about the FakeConn returning the 100 Continue status when the # object controller calls getexpect. Which is FakeConn's default @@ -1292,7 +2074,8 @@ class TestObjectController(unittest.TestCase): self.app.write_affinity_node_count = lambda r: 3 controller = \ - proxy_server.ObjectController(self.app, 'a', 'c', 'o.jpg') + ReplicatedObjectController( + self.app, 'a', 'c', 'o.jpg') set_http_connect(200, 200, 201, 201, 201, give_connect=test_connect) req = Request.blank('/v1/a/c/o.jpg', {}) @@ -1327,7 +2110,8 @@ class TestObjectController(unittest.TestCase): self.app.write_affinity_node_count = lambda r: 3 controller = \ - proxy_server.ObjectController(self.app, 'a', 'c', 'o.jpg') + ReplicatedObjectController( + self.app, 'a', 'c', 'o.jpg') self.app.error_limit( object_ring.get_part_nodes(1)[0], 'test') set_http_connect(200, 200, # account, container @@ -1348,6 +2132,27 @@ class TestObjectController(unittest.TestCase): self.assertEqual(0, written_to[1][1] % 2) self.assertNotEqual(0, written_to[2][1] % 2) + @unpatch_policies + def test_PUT_no_etag_fallocate(self): + with mock.patch('swift.obj.diskfile.fallocate') as mock_fallocate: + prolis = _test_sockets[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + obj = 'hemoleucocytic-surfactant' + fd.write('PUT /v1/a/c/o HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Content-Length: %d\r\n' + 'X-Storage-Token: t\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n%s' % (len(obj), obj)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 201' + self.assertEqual(headers[:len(exp)], exp) + # one for each obj server; this test has 2 + self.assertEqual(len(mock_fallocate.mock_calls), 2) + @unpatch_policies def test_PUT_message_length_using_content_length(self): prolis = _test_sockets[0] @@ -1587,7 +2392,8 @@ class TestObjectController(unittest.TestCase): "last_modified": "1970-01-01T00:00:01.000000"}]) body_iter = ('', '', body, '', '', '', '', '', '', '', '', '', '', '') with save_globals(): - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') # HEAD HEAD GET GET HEAD GET GET GET PUT PUT # PUT DEL DEL DEL set_http_connect(200, 200, 200, 200, 200, 200, 200, 200, 201, 201, @@ -1608,7 +2414,10 @@ class TestObjectController(unittest.TestCase): StoragePolicy(1, 'one', True, object_ring=FakeRing()) ]) def test_DELETE_on_expired_versioned_object(self): + # reset the router post patch_policies + self.app.obj_controller_router = proxy_server.ObjectControllerRouter() methods = set() + authorize_call_count = [0] def test_connect(ipaddr, port, device, partition, method, path, headers=None, query_string=None): @@ -1634,9 +2443,13 @@ class TestObjectController(unittest.TestCase): for obj in object_list: yield obj + def fake_authorize(req): + authorize_call_count[0] += 1 + return None # allow the request + with save_globals(): - controller = proxy_server.ObjectController(self.app, - 'a', 'c', 'o') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') controller.container_info = fake_container_info controller._listing_iter = fake_list_iter set_http_connect(404, 404, 404, # get for the previous version @@ -1645,7 +2458,8 @@ class TestObjectController(unittest.TestCase): 204, 204, 204, # delete for the pre-previous give_connect=test_connect) req = Request.blank('/v1/a/c/o', - environ={'REQUEST_METHOD': 'DELETE'}) + environ={'REQUEST_METHOD': 'DELETE', + 'swift.authorize': fake_authorize}) self.app.memcache.store = {} self.app.update_request(req) @@ -1655,11 +2469,73 @@ class TestObjectController(unittest.TestCase): ('PUT', '/a/c/o'), ('DELETE', '/a/foo/2')] self.assertEquals(set(exp_methods), (methods)) + self.assertEquals(authorize_call_count[0], 2) + + @patch_policies([ + StoragePolicy(0, 'zero', False, object_ring=FakeRing()), + StoragePolicy(1, 'one', True, object_ring=FakeRing()) + ]) + def test_denied_DELETE_of_versioned_object(self): + """ + Verify that a request with read access to a versions container + is unable to cause any write operations on the versioned container. + """ + # reset the router post patch_policies + self.app.obj_controller_router = proxy_server.ObjectControllerRouter() + methods = set() + authorize_call_count = [0] + + def test_connect(ipaddr, port, device, partition, method, path, + headers=None, query_string=None): + methods.add((method, path)) + + def fake_container_info(account, container, req): + return {'status': 200, 'sync_key': None, + 'meta': {}, 'cors': {'allow_origin': None, + 'expose_headers': None, + 'max_age': None}, + 'sysmeta': {}, 'read_acl': None, 'object_count': None, + 'write_acl': None, 'versions': 'foo', + 'partition': 1, 'bytes': None, 'storage_policy': '1', + 'nodes': [{'zone': 0, 'ip': '10.0.0.0', 'region': 0, + 'id': 0, 'device': 'sda', 'port': 1000}, + {'zone': 1, 'ip': '10.0.0.1', 'region': 1, + 'id': 1, 'device': 'sdb', 'port': 1001}, + {'zone': 2, 'ip': '10.0.0.2', 'region': 0, + 'id': 2, 'device': 'sdc', 'port': 1002}]} + + def fake_list_iter(container, prefix, env): + object_list = [{'name': '1'}, {'name': '2'}, {'name': '3'}] + for obj in object_list: + yield obj + + def fake_authorize(req): + # deny write access + authorize_call_count[0] += 1 + return HTTPForbidden(req) # allow the request + + with save_globals(): + controller = ReplicatedObjectController(self.app, 'a', 'c', 'o') + controller.container_info = fake_container_info + # patching _listing_iter simulates request being authorized + # to list versions container + controller._listing_iter = fake_list_iter + set_http_connect(give_connect=test_connect) + req = Request.blank('/v1/a/c/o', + environ={'REQUEST_METHOD': 'DELETE', + 'swift.authorize': fake_authorize}) + + self.app.memcache.store = {} + self.app.update_request(req) + resp = controller.DELETE(req) + self.assertEqual(403, resp.status_int) + self.assertFalse(methods, methods) + self.assertEquals(authorize_call_count[0], 1) def test_PUT_auto_content_type(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') def test_content_type(filename, expected): # The three responses here are for account_info() (HEAD to @@ -1705,8 +2581,8 @@ class TestObjectController(unittest.TestCase): def test_PUT(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') def test_status_map(statuses, expected): set_http_connect(*statuses) @@ -1725,8 +2601,8 @@ class TestObjectController(unittest.TestCase): def test_PUT_connect_exceptions(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') def test_status_map(statuses, expected): set_http_connect(*statuses) @@ -1734,7 +2610,10 @@ class TestObjectController(unittest.TestCase): req = Request.blank('/v1/a/c/o.jpg', {}) req.content_length = 0 self.app.update_request(req) - res = controller.PUT(req) + try: + res = controller.PUT(req) + except HTTPException as res: + pass expected = str(expected) self.assertEquals(res.status[:len(expected)], expected) test_status_map((200, 200, 201, 201, -1), 201) # connect exc @@ -1753,8 +2632,8 @@ class TestObjectController(unittest.TestCase): def test_PUT_send_exceptions(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') def test_status_map(statuses, expected): self.app.memcache.store = {} @@ -1763,7 +2642,10 @@ class TestObjectController(unittest.TestCase): environ={'REQUEST_METHOD': 'PUT'}, body='some data') self.app.update_request(req) - res = controller.PUT(req) + try: + res = controller.PUT(req) + except HTTPException as res: + pass expected = str(expected) self.assertEquals(res.status[:len(expected)], expected) test_status_map((200, 200, 201, -1, 201), 201) @@ -1773,8 +2655,8 @@ class TestObjectController(unittest.TestCase): def test_PUT_max_size(self): with save_globals(): set_http_connect(201, 201, 201) - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') req = Request.blank('/v1/a/c/o', {}, headers={ 'Content-Length': str(constraints.MAX_FILE_SIZE + 1), 'Content-Type': 'foo/bar'}) @@ -1785,8 +2667,8 @@ class TestObjectController(unittest.TestCase): def test_PUT_bad_content_type(self): with save_globals(): set_http_connect(201, 201, 201) - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') req = Request.blank('/v1/a/c/o', {}, headers={ 'Content-Length': 0, 'Content-Type': 'foo/bar;swift_hey=45'}) self.app.update_request(req) @@ -1796,8 +2678,8 @@ class TestObjectController(unittest.TestCase): def test_PUT_getresponse_exceptions(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') def test_status_map(statuses, expected): self.app.memcache.store = {} @@ -1805,7 +2687,10 @@ class TestObjectController(unittest.TestCase): req = Request.blank('/v1/a/c/o.jpg', {}) req.content_length = 0 self.app.update_request(req) - res = controller.PUT(req) + try: + res = controller.PUT(req) + except HTTPException as res: + pass expected = str(expected) self.assertEquals(res.status[:len(str(expected))], str(expected)) @@ -1839,6 +2724,8 @@ class TestObjectController(unittest.TestCase): StoragePolicy(1, 'one', object_ring=FakeRing()), ]) def test_POST_backend_headers(self): + # reset the router post patch_policies + self.app.obj_controller_router = proxy_server.ObjectControllerRouter() self.app.object_post_as_copy = False self.app.sort_nodes = lambda nodes: nodes backend_requests = [] @@ -2109,8 +2996,8 @@ class TestObjectController(unittest.TestCase): with save_globals(): limit = constraints.MAX_META_VALUE_LENGTH self.app.object_post_as_copy = False - proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + ReplicatedObjectController( + self.app, 'account', 'container', 'object') set_http_connect(200, 200, 202, 202, 202) # acct cont obj obj obj req = Request.blank('/v1/a/c/o', {'REQUEST_METHOD': 'POST'}, @@ -2657,8 +3544,8 @@ class TestObjectController(unittest.TestCase): self.assertEqual(node_list, got_nodes) def test_best_response_sets_headers(self): - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'GET'}) resp = controller.best_response(req, [200] * 3, ['OK'] * 3, [''] * 3, 'Object', headers=[{'X-Test': '1'}, @@ -2667,8 +3554,8 @@ class TestObjectController(unittest.TestCase): self.assertEquals(resp.headers['X-Test'], '1') def test_best_response_sets_etag(self): - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'GET'}) resp = controller.best_response(req, [200] * 3, ['OK'] * 3, [''] * 3, 'Object') @@ -2701,8 +3588,8 @@ class TestObjectController(unittest.TestCase): req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'HEAD'}) self.app.update_request(req) - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') set_http_connect(200, 200, 200) resp = controller.HEAD(req) self.assertEquals(resp.status_int, 200) @@ -2714,8 +3601,8 @@ class TestObjectController(unittest.TestCase): def test_error_limiting(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') controller.app.sort_nodes = lambda l: l object_ring = controller.app.get_object_ring(None) self.assert_status_map(controller.HEAD, (200, 200, 503, 200, 200), @@ -2751,8 +3638,8 @@ class TestObjectController(unittest.TestCase): def test_error_limiting_survives_ring_reload(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') controller.app.sort_nodes = lambda l: l object_ring = controller.app.get_object_ring(None) self.assert_status_map(controller.HEAD, (200, 200, 503, 200, 200), @@ -2779,8 +3666,8 @@ class TestObjectController(unittest.TestCase): def test_PUT_error_limiting(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') controller.app.sort_nodes = lambda l: l object_ring = controller.app.get_object_ring(None) # acc con obj obj obj @@ -2798,8 +3685,8 @@ class TestObjectController(unittest.TestCase): def test_PUT_error_limiting_last_node(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') controller.app.sort_nodes = lambda l: l object_ring = controller.app.get_object_ring(None) # acc con obj obj obj @@ -2819,8 +3706,8 @@ class TestObjectController(unittest.TestCase): with save_globals(): self.app.memcache = FakeMemcacheReturnsNone() self.app._error_limiting = {} - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') set_http_connect(200, 200, 200, 200, 200, 200) req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'DELETE'}) @@ -2916,8 +3803,8 @@ class TestObjectController(unittest.TestCase): with save_globals(): self.app.object_post_as_copy = False self.app.memcache = FakeMemcacheReturnsNone() - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') set_http_connect(200, 404, 404, 404, 200, 200, 200) req = Request.blank('/v1/a/c/o', @@ -2937,8 +3824,8 @@ class TestObjectController(unittest.TestCase): def test_PUT_POST_as_copy_requires_container_exist(self): with save_globals(): self.app.memcache = FakeMemcacheReturnsNone() - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') set_http_connect(200, 404, 404, 404, 200, 200, 200) req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'PUT'}) self.app.update_request(req) @@ -2955,8 +3842,8 @@ class TestObjectController(unittest.TestCase): def test_bad_metadata(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') set_http_connect(200, 200, 201, 201, 201) # acct cont obj obj obj req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, @@ -3052,8 +3939,8 @@ class TestObjectController(unittest.TestCase): @contextmanager def controller_context(self, req, *args, **kwargs): _v, account, container, obj = utils.split_path(req.path, 4, 4, True) - controller = proxy_server.ObjectController(self.app, account, - container, obj) + controller = ReplicatedObjectController( + self.app, account, container, obj) self.app.update_request(req) self.app.memcache.store = {} with save_globals(): @@ -3391,7 +4278,10 @@ class TestObjectController(unittest.TestCase): self.app.update_request(req) self.app.memcache.store = {} - resp = controller.PUT(req) + try: + resp = controller.PUT(req) + except HTTPException as resp: + pass self.assertEquals(resp.status_int, 413) def test_basic_COPY(self): @@ -3632,7 +4522,10 @@ class TestObjectController(unittest.TestCase): kwargs = dict(body=copy_from_obj_body) with self.controller_context(req, *status_list, **kwargs) as controller: - resp = controller.COPY(req) + try: + resp = controller.COPY(req) + except HTTPException as resp: + pass self.assertEquals(resp.status_int, 413) @_limit_max_file_size @@ -3656,12 +4549,16 @@ class TestObjectController(unittest.TestCase): kwargs = dict(body=copy_from_obj_body) with self.controller_context(req, *status_list, **kwargs) as controller: - resp = controller.COPY(req) + try: + resp = controller.COPY(req) + except HTTPException as resp: + pass self.assertEquals(resp.status_int, 413) def test_COPY_newest(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'COPY'}, headers={'Destination': '/c/o'}) @@ -3679,7 +4576,8 @@ class TestObjectController(unittest.TestCase): def test_COPY_account_newest(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'COPY'}, headers={'Destination': '/c1/o', @@ -3698,41 +4596,46 @@ class TestObjectController(unittest.TestCase): def test_COPY_delete_at(self): with save_globals(): - given_headers = {} + backend_requests = [] - def fake_connect_put_node(nodes, part, path, headers, - logger_thread_locals): - given_headers.update(headers) + def capture_requests(ipaddr, port, device, partition, method, path, + headers=None, query_string=None): + backend_requests.append((method, path, headers)) - controller = proxy_server.ObjectController(self.app, 'a', - 'c', 'o') - controller._connect_put_node = fake_connect_put_node - set_http_connect(200, 200, 200, 200, 200, 201, 201, 201) + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') + set_http_connect(200, 200, 200, 200, 200, 201, 201, 201, + give_connect=capture_requests) self.app.memcache.store = {} req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'COPY'}, headers={'Destination': '/c/o'}) self.app.update_request(req) - controller.COPY(req) - self.assertEquals(given_headers.get('X-Delete-At'), '9876543210') - self.assertTrue('X-Delete-At-Host' in given_headers) - self.assertTrue('X-Delete-At-Device' in given_headers) - self.assertTrue('X-Delete-At-Partition' in given_headers) - self.assertTrue('X-Delete-At-Container' in given_headers) + resp = controller.COPY(req) + self.assertEqual(201, resp.status_int) # sanity + for method, path, given_headers in backend_requests: + if method != 'PUT': + continue + self.assertEquals(given_headers.get('X-Delete-At'), + '9876543210') + self.assertTrue('X-Delete-At-Host' in given_headers) + self.assertTrue('X-Delete-At-Device' in given_headers) + self.assertTrue('X-Delete-At-Partition' in given_headers) + self.assertTrue('X-Delete-At-Container' in given_headers) def test_COPY_account_delete_at(self): with save_globals(): - given_headers = {} + backend_requests = [] - def fake_connect_put_node(nodes, part, path, headers, - logger_thread_locals): - given_headers.update(headers) + def capture_requests(ipaddr, port, device, partition, method, path, + headers=None, query_string=None): + backend_requests.append((method, path, headers)) - controller = proxy_server.ObjectController(self.app, 'a', - 'c', 'o') - controller._connect_put_node = fake_connect_put_node - set_http_connect(200, 200, 200, 200, 200, 200, 200, 201, 201, 201) + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') + set_http_connect(200, 200, 200, 200, 200, 200, 200, 201, 201, 201, + give_connect=capture_requests) self.app.memcache.store = {} req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'COPY'}, @@ -3740,12 +4643,17 @@ class TestObjectController(unittest.TestCase): 'Destination-Account': 'a1'}) self.app.update_request(req) - controller.COPY(req) - self.assertEquals(given_headers.get('X-Delete-At'), '9876543210') - self.assertTrue('X-Delete-At-Host' in given_headers) - self.assertTrue('X-Delete-At-Device' in given_headers) - self.assertTrue('X-Delete-At-Partition' in given_headers) - self.assertTrue('X-Delete-At-Container' in given_headers) + resp = controller.COPY(req) + self.assertEqual(201, resp.status_int) # sanity + for method, path, given_headers in backend_requests: + if method != 'PUT': + continue + self.assertEquals(given_headers.get('X-Delete-At'), + '9876543210') + self.assertTrue('X-Delete-At-Host' in given_headers) + self.assertTrue('X-Delete-At-Device' in given_headers) + self.assertTrue('X-Delete-At-Partition' in given_headers) + self.assertTrue('X-Delete-At-Container' in given_headers) def test_chunked_put(self): @@ -3770,8 +4678,8 @@ class TestObjectController(unittest.TestCase): with save_globals(): set_http_connect(201, 201, 201, 201) - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'COPY'}, headers={'Transfer-Encoding': 'chunked', @@ -3801,7 +4709,7 @@ class TestObjectController(unittest.TestCase): def test_chunked_put_bad_version(self): # Check bad version (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, - obj2lis) = _test_sockets + obj2lis, obj3lis) = _test_sockets sock = connect_tcp(('localhost', prolis.getsockname()[1])) fd = sock.makefile() fd.write('GET /v0 HTTP/1.1\r\nHost: localhost\r\n' @@ -3815,7 +4723,7 @@ class TestObjectController(unittest.TestCase): def test_chunked_put_bad_path(self): # Check bad path (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, - obj2lis) = _test_sockets + obj2lis, obj3lis) = _test_sockets sock = connect_tcp(('localhost', prolis.getsockname()[1])) fd = sock.makefile() fd.write('GET invalid HTTP/1.1\r\nHost: localhost\r\n' @@ -3829,7 +4737,7 @@ class TestObjectController(unittest.TestCase): def test_chunked_put_bad_utf8(self): # Check invalid utf-8 (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, - obj2lis) = _test_sockets + obj2lis, obj3lis) = _test_sockets sock = connect_tcp(('localhost', prolis.getsockname()[1])) fd = sock.makefile() fd.write('GET /v1/a%80 HTTP/1.1\r\nHost: localhost\r\n' @@ -3844,7 +4752,7 @@ class TestObjectController(unittest.TestCase): def test_chunked_put_bad_path_no_controller(self): # Check bad path, no controller (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, - obj2lis) = _test_sockets + obj2lis, obj3lis) = _test_sockets sock = connect_tcp(('localhost', prolis.getsockname()[1])) fd = sock.makefile() fd.write('GET /v1 HTTP/1.1\r\nHost: localhost\r\n' @@ -3859,7 +4767,7 @@ class TestObjectController(unittest.TestCase): def test_chunked_put_bad_method(self): # Check bad method (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, - obj2lis) = _test_sockets + obj2lis, obj3lis) = _test_sockets sock = connect_tcp(('localhost', prolis.getsockname()[1])) fd = sock.makefile() fd.write('LICK /v1/a HTTP/1.1\r\nHost: localhost\r\n' @@ -3874,9 +4782,9 @@ class TestObjectController(unittest.TestCase): def test_chunked_put_unhandled_exception(self): # Check unhandled exception (prosrv, acc1srv, acc2srv, con1srv, con2srv, obj1srv, - obj2srv) = _test_servers + obj2srv, obj3srv) = _test_servers (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, - obj2lis) = _test_sockets + obj2lis, obj3lis) = _test_sockets orig_update_request = prosrv.update_request def broken_update_request(*args, **kwargs): @@ -3900,7 +4808,7 @@ class TestObjectController(unittest.TestCase): # the part Application.log_request that 'enforces' a # content_length on the response. (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, - obj2lis) = _test_sockets + obj2lis, obj3lis) = _test_sockets sock = connect_tcp(('localhost', prolis.getsockname()[1])) fd = sock.makefile() fd.write('HEAD /v1/a HTTP/1.1\r\nHost: localhost\r\n' @@ -3924,7 +4832,7 @@ class TestObjectController(unittest.TestCase): ustr_short = '\xe1\xbc\xb8\xce\xbf\xe1\xbd\xbatest' # Create ustr container (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, - obj2lis) = _test_sockets + obj2lis, obj3lis) = _test_sockets sock = connect_tcp(('localhost', prolis.getsockname()[1])) fd = sock.makefile() fd.write('PUT /v1/a/%s HTTP/1.1\r\nHost: localhost\r\n' @@ -4036,7 +4944,7 @@ class TestObjectController(unittest.TestCase): def test_chunked_put_chunked_put(self): # Do chunked object put (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, - obj2lis) = _test_sockets + obj2lis, obj3lis) = _test_sockets sock = connect_tcp(('localhost', prolis.getsockname()[1])) fd = sock.makefile() # Also happens to assert that x-storage-token is taken as a @@ -4067,7 +4975,7 @@ class TestObjectController(unittest.TestCase): versions_to_create = 3 # Create a container for our versioned object testing (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, - obj2lis) = _test_sockets + obj2lis, obj3lis) = _test_sockets sock = connect_tcp(('localhost', prolis.getsockname()[1])) fd = sock.makefile() pre = quote('%03x' % len(o)) @@ -4451,8 +5359,8 @@ class TestObjectController(unittest.TestCase): @unpatch_policies def test_conditional_range_get(self): - (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, obj2lis) = \ - _test_sockets + (prolis, acc1lis, acc2lis, con1lis, con2lis, obj1lis, obj2lis, + obj3lis) = _test_sockets sock = connect_tcp(('localhost', prolis.getsockname()[1])) # make a container @@ -4500,8 +5408,8 @@ class TestObjectController(unittest.TestCase): def test_mismatched_etags(self): with save_globals(): # no etag supplied, object servers return success w/ diff values - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, headers={'Content-Length': '0'}) self.app.update_request(req) @@ -4532,8 +5440,8 @@ class TestObjectController(unittest.TestCase): with save_globals(): req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'GET'}) self.app.update_request(req) - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') set_http_connect(200, 200, 200) resp = controller.GET(req) self.assert_('accept-ranges' in resp.headers) @@ -4544,8 +5452,8 @@ class TestObjectController(unittest.TestCase): req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'HEAD'}) self.app.update_request(req) - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') set_http_connect(200, 200, 200) resp = controller.HEAD(req) self.assert_('accept-ranges' in resp.headers) @@ -4559,8 +5467,8 @@ class TestObjectController(unittest.TestCase): return HTTPUnauthorized(request=req) with save_globals(): set_http_connect(200, 200, 201, 201, 201) - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') req = Request.blank('/v1/a/c/o') req.environ['swift.authorize'] = authorize self.app.update_request(req) @@ -4575,8 +5483,8 @@ class TestObjectController(unittest.TestCase): return HTTPUnauthorized(request=req) with save_globals(): set_http_connect(200, 200, 201, 201, 201) - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') req = Request.blank('/v1/a/c/o', {'REQUEST_METHOD': 'HEAD'}) req.environ['swift.authorize'] = authorize self.app.update_request(req) @@ -4592,8 +5500,8 @@ class TestObjectController(unittest.TestCase): with save_globals(): self.app.object_post_as_copy = False set_http_connect(200, 200, 201, 201, 201) - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'POST'}, headers={'Content-Length': '5'}, body='12345') @@ -4610,8 +5518,8 @@ class TestObjectController(unittest.TestCase): return HTTPUnauthorized(request=req) with save_globals(): set_http_connect(200, 200, 200, 200, 200, 201, 201, 201) - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'POST'}, headers={'Content-Length': '5'}, body='12345') @@ -4628,8 +5536,8 @@ class TestObjectController(unittest.TestCase): return HTTPUnauthorized(request=req) with save_globals(): set_http_connect(200, 200, 201, 201, 201) - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, headers={'Content-Length': '5'}, body='12345') req.environ['swift.authorize'] = authorize @@ -4645,8 +5553,8 @@ class TestObjectController(unittest.TestCase): return HTTPUnauthorized(request=req) with save_globals(): set_http_connect(200, 200, 200, 200, 200, 201, 201, 201) - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'COPY'}, headers={'Destination': 'c/o'}) @@ -4658,8 +5566,8 @@ class TestObjectController(unittest.TestCase): def test_POST_converts_delete_after_to_delete_at(self): with save_globals(): self.app.object_post_as_copy = False - controller = proxy_server.ObjectController(self.app, 'account', - 'container', 'object') + controller = ReplicatedObjectController( + self.app, 'account', 'container', 'object') set_http_connect(200, 200, 202, 202, 202) self.app.memcache.store = {} orig_time = time.time @@ -4682,6 +5590,8 @@ class TestObjectController(unittest.TestCase): StoragePolicy(1, 'one', True, object_ring=FakeRing()) ]) def test_PUT_versioning_with_nonzero_default_policy(self): + # reset the router post patch_policies + self.app.obj_controller_router = proxy_server.ObjectControllerRouter() def test_connect(ipaddr, port, device, partition, method, path, headers=None, query_string=None): @@ -4707,8 +5617,8 @@ class TestObjectController(unittest.TestCase): {'zone': 2, 'ip': '10.0.0.2', 'region': 0, 'id': 2, 'device': 'sdc', 'port': 1002}]} with save_globals(): - controller = proxy_server.ObjectController(self.app, 'a', - 'c', 'o.jpg') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o.jpg') controller.container_info = fake_container_info set_http_connect(200, 200, 200, # head: for the last version @@ -4729,6 +5639,8 @@ class TestObjectController(unittest.TestCase): StoragePolicy(1, 'one', True, object_ring=FakeRing()) ]) def test_cross_policy_DELETE_versioning(self): + # reset the router post patch_policies + self.app.obj_controller_router = proxy_server.ObjectControllerRouter() requests = [] def capture_requests(ipaddr, port, device, partition, method, path, @@ -4858,8 +5770,8 @@ class TestObjectController(unittest.TestCase): def test_OPTIONS(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'a', - 'c', 'o.jpg') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o.jpg') def my_empty_container_info(*args): return {} @@ -4966,7 +5878,8 @@ class TestObjectController(unittest.TestCase): def test_CORS_valid(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') def stubContainerInfo(*args): return { @@ -5019,7 +5932,8 @@ class TestObjectController(unittest.TestCase): def test_CORS_valid_with_obj_headers(self): with save_globals(): - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') def stubContainerInfo(*args): return { @@ -5080,7 +5994,8 @@ class TestObjectController(unittest.TestCase): def test_PUT_x_container_headers_with_equal_replicas(self): req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, headers={'Content-Length': '5'}, body='12345') - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') seen_headers = self._gather_x_container_headers( controller.PUT, req, 200, 200, 201, 201, 201) # HEAD HEAD PUT PUT PUT @@ -5101,7 +6016,8 @@ class TestObjectController(unittest.TestCase): req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, headers={'Content-Length': '5'}, body='12345') - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') seen_headers = self._gather_x_container_headers( controller.PUT, req, 200, 200, 201, 201, 201) # HEAD HEAD PUT PUT PUT @@ -5123,7 +6039,8 @@ class TestObjectController(unittest.TestCase): req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'PUT'}, headers={'Content-Length': '5'}, body='12345') - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') seen_headers = self._gather_x_container_headers( controller.PUT, req, 200, 200, 201, 201, 201) # HEAD HEAD PUT PUT PUT @@ -5147,7 +6064,8 @@ class TestObjectController(unittest.TestCase): req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'POST'}, headers={'Content-Type': 'application/stuff'}) - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') seen_headers = self._gather_x_container_headers( controller.POST, req, 200, 200, 200, 200, 200) # HEAD HEAD POST POST POST @@ -5170,7 +6088,8 @@ class TestObjectController(unittest.TestCase): req = Request.blank('/v1/a/c/o', environ={'REQUEST_METHOD': 'DELETE'}, headers={'Content-Type': 'application/stuff'}) - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') seen_headers = self._gather_x_container_headers( controller.DELETE, req, 200, 200, 200, 200, 200) # HEAD HEAD DELETE DELETE DELETE @@ -5199,7 +6118,8 @@ class TestObjectController(unittest.TestCase): headers={'Content-Type': 'application/stuff', 'Content-Length': '0', 'X-Delete-At': str(delete_at_timestamp)}) - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') seen_headers = self._gather_x_container_headers( controller.PUT, req, 200, 200, 201, 201, 201, # HEAD HEAD PUT PUT PUT @@ -5235,7 +6155,8 @@ class TestObjectController(unittest.TestCase): headers={'Content-Type': 'application/stuff', 'Content-Length': 0, 'X-Delete-At': str(delete_at_timestamp)}) - controller = proxy_server.ObjectController(self.app, 'a', 'c', 'o') + controller = ReplicatedObjectController( + self.app, 'a', 'c', 'o') seen_headers = self._gather_x_container_headers( controller.PUT, req, 200, 200, 201, 201, 201, # HEAD HEAD PUT PUT PUT @@ -5257,6 +6178,373 @@ class TestObjectController(unittest.TestCase): ]) +class TestECMismatchedFA(unittest.TestCase): + def tearDown(self): + prosrv = _test_servers[0] + # don't leak error limits and poison other tests + prosrv._error_limiting = {} + + def test_mixing_different_objects_fragment_archives(self): + (prosrv, acc1srv, acc2srv, con1srv, con2srv, obj1srv, + obj2srv, obj3srv) = _test_servers + ec_policy = POLICIES[3] + + @public + def bad_disk(req): + return Response(status=507, body="borken") + + ensure_container = Request.blank( + "/v1/a/ec-crazytown", + environ={"REQUEST_METHOD": "PUT"}, + headers={"X-Storage-Policy": "ec", "X-Auth-Token": "t"}) + resp = ensure_container.get_response(prosrv) + self.assertTrue(resp.status_int in (201, 202)) + + obj1 = "first version..." + put_req1 = Request.blank( + "/v1/a/ec-crazytown/obj", + environ={"REQUEST_METHOD": "PUT"}, + headers={"X-Auth-Token": "t"}) + put_req1.body = obj1 + + obj2 = u"versión segundo".encode("utf-8") + put_req2 = Request.blank( + "/v1/a/ec-crazytown/obj", + environ={"REQUEST_METHOD": "PUT"}, + headers={"X-Auth-Token": "t"}) + put_req2.body = obj2 + + # pyeclib has checks for unequal-length; we don't want to trip those + self.assertEqual(len(obj1), len(obj2)) + + # Servers obj1 and obj2 will have the first version of the object + prosrv._error_limiting = {} + with nested( + mock.patch.object(obj3srv, 'PUT', bad_disk), + mock.patch( + 'swift.common.storage_policy.ECStoragePolicy.quorum')): + type(ec_policy).quorum = mock.PropertyMock(return_value=2) + resp = put_req1.get_response(prosrv) + self.assertEqual(resp.status_int, 201) + + # Server obj3 (and, in real life, some handoffs) will have the + # second version of the object. + prosrv._error_limiting = {} + with nested( + mock.patch.object(obj1srv, 'PUT', bad_disk), + mock.patch.object(obj2srv, 'PUT', bad_disk), + mock.patch( + 'swift.common.storage_policy.ECStoragePolicy.quorum'), + mock.patch( + 'swift.proxy.controllers.base.Controller._quorum_size', + lambda *a, **kw: 1)): + type(ec_policy).quorum = mock.PropertyMock(return_value=1) + resp = put_req2.get_response(prosrv) + self.assertEqual(resp.status_int, 201) + + # A GET that only sees 1 fragment archive should fail + get_req = Request.blank("/v1/a/ec-crazytown/obj", + environ={"REQUEST_METHOD": "GET"}, + headers={"X-Auth-Token": "t"}) + prosrv._error_limiting = {} + with nested( + mock.patch.object(obj1srv, 'GET', bad_disk), + mock.patch.object(obj2srv, 'GET', bad_disk)): + resp = get_req.get_response(prosrv) + self.assertEqual(resp.status_int, 503) + + # A GET that sees 2 matching FAs will work + get_req = Request.blank("/v1/a/ec-crazytown/obj", + environ={"REQUEST_METHOD": "GET"}, + headers={"X-Auth-Token": "t"}) + prosrv._error_limiting = {} + with mock.patch.object(obj3srv, 'GET', bad_disk): + resp = get_req.get_response(prosrv) + self.assertEqual(resp.status_int, 200) + self.assertEqual(resp.body, obj1) + + # A GET that sees 2 mismatching FAs will fail + get_req = Request.blank("/v1/a/ec-crazytown/obj", + environ={"REQUEST_METHOD": "GET"}, + headers={"X-Auth-Token": "t"}) + prosrv._error_limiting = {} + with mock.patch.object(obj2srv, 'GET', bad_disk): + resp = get_req.get_response(prosrv) + self.assertEqual(resp.status_int, 503) + + +class TestObjectECRangedGET(unittest.TestCase): + def setUp(self): + self.app = proxy_server.Application( + None, FakeMemcache(), + logger=debug_logger('proxy-ut'), + account_ring=FakeRing(), + container_ring=FakeRing()) + + @classmethod + def setUpClass(cls): + cls.obj_name = 'range-get-test' + cls.tiny_obj_name = 'range-get-test-tiny' + cls.aligned_obj_name = 'range-get-test-aligned' + + # Note: only works if called with unpatched policies + prolis = _test_sockets[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('PUT /v1/a/ec-con HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Content-Length: 0\r\n' + 'X-Storage-Token: t\r\n' + 'X-Storage-Policy: ec\r\n' + '\r\n') + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 2' + assert headers[:len(exp)] == exp, "container PUT failed" + + seg_size = POLICIES.get_by_name("ec").ec_segment_size + cls.seg_size = seg_size + # EC segment size is 4 KiB, hence this gives 4 segments, which we + # then verify with a quick sanity check + cls.obj = ' my hovercraft is full of eels '.join( + str(s) for s in range(431)) + assert seg_size * 4 > len(cls.obj) > seg_size * 3, \ + "object is wrong number of segments" + + cls.tiny_obj = 'tiny, tiny object' + assert len(cls.tiny_obj) < seg_size, "tiny_obj too large" + + cls.aligned_obj = "".join( + "abcdEFGHijkl%04d" % x for x in range(512)) + assert len(cls.aligned_obj) % seg_size == 0, "aligned obj not aligned" + + for obj_name, obj in ((cls.obj_name, cls.obj), + (cls.tiny_obj_name, cls.tiny_obj), + (cls.aligned_obj_name, cls.aligned_obj)): + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('PUT /v1/a/ec-con/%s HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'Content-Length: %d\r\n' + 'X-Storage-Token: t\r\n' + 'Content-Type: application/octet-stream\r\n' + '\r\n%s' % (obj_name, len(obj), obj)) + fd.flush() + headers = readuntil2crlfs(fd) + exp = 'HTTP/1.1 201' + assert headers[:len(exp)] == exp, \ + "object PUT failed %s" % obj_name + + def _get_obj(self, range_value, obj_name=None): + if obj_name is None: + obj_name = self.obj_name + + prolis = _test_sockets[0] + sock = connect_tcp(('localhost', prolis.getsockname()[1])) + fd = sock.makefile() + fd.write('GET /v1/a/ec-con/%s HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Connection: close\r\n' + 'X-Storage-Token: t\r\n' + 'Range: %s\r\n' + '\r\n' % (obj_name, range_value)) + fd.flush() + headers = readuntil2crlfs(fd) + # e.g. "HTTP/1.1 206 Partial Content\r\n..." + status_code = int(headers[9:12]) + headers = parse_headers_string(headers) + + gotten_obj = '' + while True: + buf = fd.read(64) + if not buf: + break + gotten_obj += buf + + return (status_code, headers, gotten_obj) + + def test_unaligned(self): + # One segment's worth of data, but straddling two segment boundaries + # (so it has data from three segments) + status, headers, gotten_obj = self._get_obj("bytes=3783-7878") + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], "4096") + self.assertEqual(headers['Content-Range'], "bytes 3783-7878/14513") + self.assertEqual(len(gotten_obj), 4096) + self.assertEqual(gotten_obj, self.obj[3783:7879]) + + def test_aligned_left(self): + # First byte is aligned to a segment boundary, last byte is not + status, headers, gotten_obj = self._get_obj("bytes=0-5500") + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], "5501") + self.assertEqual(headers['Content-Range'], "bytes 0-5500/14513") + self.assertEqual(len(gotten_obj), 5501) + self.assertEqual(gotten_obj, self.obj[:5501]) + + def test_aligned_range(self): + # Ranged GET that wants exactly one segment + status, headers, gotten_obj = self._get_obj("bytes=4096-8191") + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], "4096") + self.assertEqual(headers['Content-Range'], "bytes 4096-8191/14513") + self.assertEqual(len(gotten_obj), 4096) + self.assertEqual(gotten_obj, self.obj[4096:8192]) + + def test_aligned_range_end(self): + # Ranged GET that wants exactly the last segment + status, headers, gotten_obj = self._get_obj("bytes=12288-14512") + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], "2225") + self.assertEqual(headers['Content-Range'], "bytes 12288-14512/14513") + self.assertEqual(len(gotten_obj), 2225) + self.assertEqual(gotten_obj, self.obj[12288:]) + + def test_aligned_range_aligned_obj(self): + # Ranged GET that wants exactly the last segment, which is full-size + status, headers, gotten_obj = self._get_obj("bytes=4096-8191", + self.aligned_obj_name) + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], "4096") + self.assertEqual(headers['Content-Range'], "bytes 4096-8191/8192") + self.assertEqual(len(gotten_obj), 4096) + self.assertEqual(gotten_obj, self.aligned_obj[4096:8192]) + + def test_byte_0(self): + # Just the first byte, but it's index 0, so that's easy to get wrong + status, headers, gotten_obj = self._get_obj("bytes=0-0") + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], "1") + self.assertEqual(headers['Content-Range'], "bytes 0-0/14513") + self.assertEqual(gotten_obj, self.obj[0]) + + def test_unsatisfiable(self): + # Goes just one byte too far off the end of the object, so it's + # unsatisfiable + status, _junk, _junk = self._get_obj( + "bytes=%d-%d" % (len(self.obj), len(self.obj) + 100)) + self.assertEqual(status, 416) + + def test_off_end(self): + # Ranged GET that's mostly off the end of the object, but overlaps + # it in just the last byte + status, headers, gotten_obj = self._get_obj( + "bytes=%d-%d" % (len(self.obj) - 1, len(self.obj) + 100)) + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], '1') + self.assertEqual(headers['Content-Range'], 'bytes 14512-14512/14513') + self.assertEqual(gotten_obj, self.obj[-1]) + + def test_aligned_off_end(self): + # Ranged GET that starts on a segment boundary but asks for a whole lot + status, headers, gotten_obj = self._get_obj( + "bytes=%d-%d" % (8192, len(self.obj) + 100)) + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], '6321') + self.assertEqual(headers['Content-Range'], 'bytes 8192-14512/14513') + self.assertEqual(gotten_obj, self.obj[8192:]) + + def test_way_off_end(self): + # Ranged GET that's mostly off the end of the object, but overlaps + # it in just the last byte, and wants multiple segments' worth off + # the end + status, headers, gotten_obj = self._get_obj( + "bytes=%d-%d" % (len(self.obj) - 1, len(self.obj) * 1000)) + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], '1') + self.assertEqual(headers['Content-Range'], 'bytes 14512-14512/14513') + self.assertEqual(gotten_obj, self.obj[-1]) + + def test_boundaries(self): + # Wants the last byte of segment 1 + the first byte of segment 2 + status, headers, gotten_obj = self._get_obj("bytes=4095-4096") + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], '2') + self.assertEqual(headers['Content-Range'], 'bytes 4095-4096/14513') + self.assertEqual(gotten_obj, self.obj[4095:4097]) + + def test_until_end(self): + # Wants the last byte of segment 1 + the rest + status, headers, gotten_obj = self._get_obj("bytes=4095-") + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], '10418') + self.assertEqual(headers['Content-Range'], 'bytes 4095-14512/14513') + self.assertEqual(gotten_obj, self.obj[4095:]) + + def test_small_suffix(self): + # Small range-suffix GET: the last 100 bytes (less than one segment) + status, headers, gotten_obj = self._get_obj("bytes=-100") + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], '100') + self.assertEqual(headers['Content-Range'], 'bytes 14413-14512/14513') + self.assertEqual(len(gotten_obj), 100) + self.assertEqual(gotten_obj, self.obj[-100:]) + + def test_small_suffix_aligned(self): + # Small range-suffix GET: the last 100 bytes, last segment is + # full-size + status, headers, gotten_obj = self._get_obj("bytes=-100", + self.aligned_obj_name) + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], '100') + self.assertEqual(headers['Content-Range'], 'bytes 8092-8191/8192') + self.assertEqual(len(gotten_obj), 100) + + def test_suffix_two_segs(self): + # Ask for enough data that we need the last two segments. The last + # segment is short, though, so this ensures we compensate for that. + # + # Note that the total range size is less than one full-size segment. + suffix_len = len(self.obj) % self.seg_size + 1 + + status, headers, gotten_obj = self._get_obj("bytes=-%d" % suffix_len) + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], str(suffix_len)) + self.assertEqual(headers['Content-Range'], + 'bytes %d-%d/%d' % (len(self.obj) - suffix_len, + len(self.obj) - 1, + len(self.obj))) + self.assertEqual(len(gotten_obj), suffix_len) + + def test_large_suffix(self): + # Large range-suffix GET: the last 5000 bytes (more than one segment) + status, headers, gotten_obj = self._get_obj("bytes=-5000") + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], '5000') + self.assertEqual(headers['Content-Range'], 'bytes 9513-14512/14513') + self.assertEqual(len(gotten_obj), 5000) + self.assertEqual(gotten_obj, self.obj[-5000:]) + + def test_overlarge_suffix(self): + # The last N+1 bytes of an N-byte object + status, headers, gotten_obj = self._get_obj( + "bytes=-%d" % (len(self.obj) + 1)) + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], '14513') + self.assertEqual(headers['Content-Range'], 'bytes 0-14512/14513') + self.assertEqual(len(gotten_obj), len(self.obj)) + self.assertEqual(gotten_obj, self.obj) + + def test_small_suffix_tiny_object(self): + status, headers, gotten_obj = self._get_obj( + "bytes=-5", self.tiny_obj_name) + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], '5') + self.assertEqual(headers['Content-Range'], 'bytes 12-16/17') + self.assertEqual(gotten_obj, self.tiny_obj[12:]) + + def test_overlarge_suffix_tiny_object(self): + status, headers, gotten_obj = self._get_obj( + "bytes=-1234567890", self.tiny_obj_name) + self.assertEqual(status, 206) + self.assertEqual(headers['Content-Length'], '17') + self.assertEqual(headers['Content-Range'], 'bytes 0-16/17') + self.assertEqual(len(gotten_obj), len(self.tiny_obj)) + self.assertEqual(gotten_obj, self.tiny_obj) + + @patch_policies([ StoragePolicy(0, 'zero', True, object_ring=FakeRing(base_port=3000)), StoragePolicy(1, 'one', False, object_ring=FakeRing(base_port=3000)), @@ -5499,7 +6787,7 @@ class TestContainerController(unittest.TestCase): headers) self.assertEqual(int(headers ['X-Backend-Storage-Policy-Index']), - policy.idx) + int(policy)) # make sure all mocked responses are consumed self.assertRaises(StopIteration, mock_conn.code_iter.next) @@ -7347,9 +8635,12 @@ class TestSwiftInfo(unittest.TestCase): self.assertTrue('strict_cors_mode' in si) self.assertEqual(si['allow_account_management'], False) self.assertEqual(si['account_autocreate'], False) + # This setting is by default excluded by disallowed_sections + self.assertEqual(si['valid_api_versions'], + constraints.VALID_API_VERSIONS) # this next test is deliberately brittle in order to alert if # other items are added to swift info - self.assertEqual(len(si), 16) + self.assertEqual(len(si), 17) self.assertTrue('policies' in si) sorted_pols = sorted(si['policies'], key=operator.itemgetter('name')) diff --git a/test/unit/proxy/test_sysmeta.py b/test/unit/proxy/test_sysmeta.py index c3b6731082..6b5727a461 100644 --- a/test/unit/proxy/test_sysmeta.py +++ b/test/unit/proxy/test_sysmeta.py @@ -70,6 +70,9 @@ class FakeServerConnection(WSGIContext): def send(self, data): self.data += data + def close(self): + pass + def __call__(self, ipaddr, port, device, partition, method, path, headers=None, query_string=None): self.path = quote('/' + device + '/' + str(partition) + path) @@ -132,7 +135,7 @@ class TestObjectSysmeta(unittest.TestCase): self.tmpdir = mkdtemp() self.testdir = os.path.join(self.tmpdir, 'tmp_test_object_server_ObjectController') - mkdirs(os.path.join(self.testdir, 'sda1', 'tmp')) + mkdirs(os.path.join(self.testdir, 'sda', 'tmp')) conf = {'devices': self.testdir, 'mount_check': 'false'} self.obj_ctlr = object_server.ObjectController( conf, logger=debug_logger('obj-ut')) @@ -141,11 +144,15 @@ class TestObjectSysmeta(unittest.TestCase): fake_http_connect(200), FakeServerConnection(self.obj_ctlr)) + self.orig_base_http_connect = swift.proxy.controllers.base.http_connect + self.orig_obj_http_connect = swift.proxy.controllers.obj.http_connect swift.proxy.controllers.base.http_connect = http_connect swift.proxy.controllers.obj.http_connect = http_connect def tearDown(self): shutil.rmtree(self.tmpdir) + swift.proxy.controllers.base.http_connect = self.orig_base_http_connect + swift.proxy.controllers.obj.http_connect = self.orig_obj_http_connect original_sysmeta_headers_1 = {'x-object-sysmeta-test0': 'val0', 'x-object-sysmeta-test1': 'val1'}